refactor big file

This commit is contained in:
Chris Lettieri 2025-04-16 11:33:35 -04:00
parent f738e45035
commit 1d7bd175eb
11 changed files with 435 additions and 306 deletions

307
main.ts
View file

@ -1,304 +1,5 @@
import { Plugin, TFile, Notice, PluginSettingTab, App, Setting } from 'obsidian';
// This file re-exports the plugin from the src directory
// This is required for compatibility with Obsidian's plugin system
interface TranscriptParserSettings {
apiKey: string;
summaryFolder: string;
notesFolder: string;
}
const DEFAULT_SETTINGS: TranscriptParserSettings = {
apiKey: '',
summaryFolder: 'OpenAugi/Summaries',
notesFolder: 'OpenAugi/Notes'
};
export default class TranscriptParserPlugin extends Plugin {
settings: TranscriptParserSettings;
async onload() {
await this.loadSettings();
// Add a command to manually parse a transcript file
this.addCommand({
id: 'parse-transcript',
name: 'Parse Transcript',
callback: async () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile.extension === 'md') {
try {
const content = await this.app.vault.read(activeFile);
await this.parseAndOutput(activeFile.basename, content);
new Notice(`Successfully parsed transcript: ${activeFile.basename}`);
} catch (error) {
console.error('Failed to parse transcript:', error);
new Notice('Failed to parse transcript. Check console for details.');
}
} else {
new Notice('Please open a markdown transcript file first');
}
}
});
// Add settings tab
this.addSettingTab(new TranscriptParserSettingTab(this.app, this));
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
// Sanitize filename to remove invalid characters
sanitizeFilename(filename: string): string {
// Replace characters that aren't allowed in filenames
return filename.replace(/[\\/:*?"<>|]/g, '-');
}
async ensureDirectoriesExist() {
const dirs = [
this.settings.summaryFolder,
this.settings.notesFolder
];
for (const dir of dirs) {
const exists = await this.app.vault.adapter.exists(dir);
if (!exists) {
await this.app.vault.createFolder(dir);
}
}
}
async parseAndOutput(filename: string, content: string) {
console.log("Parsing and outputting transcript:", filename);
// Ensure output directories exist
await this.ensureDirectoriesExist();
if (!this.settings.apiKey) {
new Notice('Please set your OpenAI API key in the plugin settings');
throw new Error('OpenAI API key not set');
}
// Begin Prompt
const prompt = `
You are an expert agent helping users process their voice notes into structured, useful Obsidian notes. Your mission is to capture the user's ideas, actions, and reflections in clean, atomic form. You act like a smart second brain, formatting output as Obsidian-ready markdown.
# Special Command Handling
- Commands will be marked with the special keyword **AUGI** or close variants ("augie", "auggie", "augi").
- These are **explicit commands** and should override default behavior.
- Commands apply only to preceding or surrounding content, not the entire transcript.
- If the speaker gives an unclear command, do your best to interpret faithfully using recent context.
# Default Behavior (use the Augie commands to guide your behavior)
Follow these steps **in order** to parse the transcript:
### 1. Atomic Notes
- Break down ideas into **self-contained, atomic notes** (1 idea per note).
- Include **supporting details**, context, or reasoning. Be concise but rich in insight.
- Use \`[[Obsidian backlinks]]\` between notes *only when meaningful* and relevant.
- Avoid repetition across notes. Think of each as a unique mental building block.
### 2. Tasks
- Extract clear, actionable tasks or to-dos.
- Format as: \`- [ ] Description of task [[Linked Atomic Note]]\` (if relevant).
- Tasks should only be added if they are genuinely actionable, not vague thoughts.
- If the author **explicitly says** to add a task (e.g., via AUGI), always include it.
### 3. Summary
- Write a 13 sentence summary of **what was said**, not how it was said.
- Highlight key concepts, questions raised, or insights.
- Use \`[[Backlinks]]\` to connect to all relevant atomic notes mentioned formatted as a list.
### 4. Journal Entry (Optional)
- Only create if explicitly asked (e.g. via: "augie this is a journal entry").
- Use **first-person**, preserve author's words as much as possible.
- Clean up repetitions or filler, but stay true to original tone.
- Add tag \`#journal\` at the end.
# Example AUGI Commands
- **"Auggie create note titled XYZ"** Create a note titled "XYZ".
- **"augie summarize this"** Summarize recent thoughts.
- **"augi add task ABC"** Add task "ABC".
- **"auggie the above is a journal entry"** Capture verbatim reflection in journal format.
# Reasoning Strategy
1. **Plan first**: Before writing, identify structure in the speaker's thoughts.
2. **Group context**: Organize ideas around coherent units. These units fomr the foundation of atomic notes.
3. **Respect ambiguity**: When unsure, err on the side of creating a thoughtful atomic note.
4. **Don't repeat**: Avoid redundancy across notes, tasks, or summary.
Transcript:
${content}`;
// End Prompt
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.settings.apiKey}`
},
body: JSON.stringify({
model: 'gpt-4.1-2025-04-14',
messages: [{ role: 'user', content: prompt }],
temperature: 0.2,
response_format: {
type: "json_schema",
json_schema: {
name: "transcript_parser",
schema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Short 13 sentence summary of the transcript (no commands included), backlinks to atomic notes should be included."
},
notes: {
type: "array",
items: {
type: "object",
properties: {
title: {
type: "string",
description: "Title of the atomic note"
},
content: {
type: "string",
description: "Markdown-formatted, self-contained idea with backlinks if relevant"
}
},
required: ["title", "content"],
additionalProperties: false
}
},
tasks: {
type: "array",
items: {
type: "string",
description: "Markdown-formatted task with checkbox"
}
}
},
required: ["summary", "notes", "tasks"],
additionalProperties: false
},
strict: true
},
}
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`OpenAI API error: ${response.status} ${errorData.error?.message || response.statusText}`);
}
const responseData = await response.json();
const structuredData = responseData.choices[0].message.content;
// Check for API refusal - a new field available with structured outputs
if (responseData.choices[0].message.refusal) {
throw new Error(`API refusal: ${responseData.choices[0].message.refusal}`);
}
// Parse the JSON (the content should already be valid JSON due to the response_format)
let parsedData;
try {
// For newer API versions, the content might already be parsed
parsedData = typeof structuredData === 'string' ? JSON.parse(structuredData) : structuredData;
} catch (parseError) {
console.error('Failed to parse JSON response:', structuredData);
throw new Error(`Failed to parse JSON response: ${parseError.message}`);
}
// Format summary content with tasks included
let summaryContent = parsedData.summary;
// Add tasks section if there are tasks
if (parsedData.tasks && parsedData.tasks.length > 0) {
summaryContent += '\n\n## Tasks\n' + parsedData.tasks.join('\n');
}
// Sanitize filename
const sanitizedFilename = this.sanitizeFilename(filename);
// Output Summary with tasks
await this.app.vault.create(`${this.settings.summaryFolder}/${sanitizedFilename} - summary.md`, summaryContent);
// Output Notes
for (const note of parsedData.notes) {
// Sanitize note title for filename
const sanitizedTitle = this.sanitizeFilename(note.title);
await this.app.vault.create(`${this.settings.notesFolder}/${sanitizedTitle}.md`, note.content);
}
} catch (error) {
console.error('Error calling OpenAI API:', error);
throw error;
}
}
}
class TranscriptParserSettingTab extends PluginSettingTab {
plugin: TranscriptParserPlugin;
constructor(app: App, plugin: TranscriptParserPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'OpenAugi Settings'});
new Setting(containerEl)
.setName('OpenAI API Key')
.setDesc('Your OpenAI API key')
.addText(text => text
.setPlaceholder('sk-...')
.setValue(this.plugin.settings.apiKey)
.inputEl.type = 'password'
)
.addButton(button => button
.setButtonText(this.plugin.settings.apiKey ? 'Update' : 'Save')
.onClick(async () => {
const inputEl = button.buttonEl.parentElement?.querySelector('input');
if (inputEl) {
this.plugin.settings.apiKey = inputEl.value;
await this.plugin.saveSettings();
new Notice('API Key saved');
button.setButtonText('Update');
}
})
);
new Setting(containerEl)
.setName('Summaries Folder')
.setDesc('Folder path where summary files will be saved')
.addText(text => text
.setPlaceholder('OpenAugi/Summaries')
.setValue(this.plugin.settings.summaryFolder)
.onChange(async (value) => {
this.plugin.settings.summaryFolder = value;
await this.plugin.saveSettings();
await this.plugin.ensureDirectoriesExist();
})
);
new Setting(containerEl)
.setName('Notes Folder')
.setDesc('Folder path where atomic notes will be saved')
.addText(text => text
.setPlaceholder('OpenAugi/Notes')
.setValue(this.plugin.settings.notesFolder)
.onChange(async (value) => {
this.plugin.settings.notesFolder = value;
await this.plugin.saveSettings();
await this.plugin.ensureDirectoriesExist();
})
);
}
}
import OpenAugiPlugin from './src/main';
export default OpenAugiPlugin;

View file

@ -1,7 +1,7 @@
{
"id": "openaugi-obsidian-voice-plugin",
"name": "OpenAugi - AI for Thinkers",
"version": "0.0.1",
"name": "OpenAugi",
"version": "0.0.2",
"minAppVersion": "0.0.1",
"description": "Voice to Self-Organizing Second Brain. Open Augi allows you to use voice commands to create, edit, and organize your notes in Obsidian. Augmented Intelligence is the AI that empowers you to be your best self.",
"author": "Chris Lettieri",

88
src/main.ts Normal file
View file

@ -0,0 +1,88 @@
import { Plugin, Notice, TFile } from 'obsidian';
import { OpenAugiSettings, DEFAULT_SETTINGS } from './types/settings';
import { OpenAIService } from './services/openai-service';
import { FileService } from './services/file-service';
import { OpenAugiSettingTab } from './ui/settings-tab';
export default class OpenAugiPlugin extends Plugin {
settings: OpenAugiSettings;
openAIService: OpenAIService;
fileService: FileService;
async onload() {
// Load settings
await this.loadSettings();
// Initialize services
this.initializeServices();
// Add command to manually parse a transcript file
this.addCommand({
id: 'parse-transcript',
name: 'Parse Transcript',
callback: async () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && activeFile.extension === 'md') {
await this.processTranscriptFile(activeFile);
} else {
new Notice('Please open a markdown transcript file first');
}
}
});
// Add settings tab
this.addSettingTab(new OpenAugiSettingTab(this.app, this));
}
/**
* Initialize services with current settings
*/
private initializeServices(): void {
this.openAIService = new OpenAIService(this.settings.apiKey);
this.fileService = new FileService(
this.app,
this.settings.summaryFolder,
this.settings.notesFolder
);
}
/**
* Process a transcript file
* @param file The file to process
*/
private async processTranscriptFile(file: TFile): Promise<void> {
try {
const content = await this.app.vault.read(file);
// Check if API key is set
if (!this.settings.apiKey) {
new Notice('Please set your OpenAI API key in the plugin settings');
return;
}
// Update openAIService with latest API key
this.openAIService = new OpenAIService(this.settings.apiKey);
// Parse transcript
const parsedData = await this.openAIService.parseTranscript(content);
// Write result to files
await this.fileService.writeTranscriptFiles(file.basename, parsedData);
new Notice(`Successfully parsed transcript: ${file.basename}`);
} catch (error) {
console.error('Failed to parse transcript:', error);
new Notice('Failed to parse transcript. Check console for details.');
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
// Reinitialize services with new settings
this.initializeServices();
}
}

View file

@ -0,0 +1,66 @@
import { App, TFile, Vault } from 'obsidian';
import { sanitizeFilename } from '../utils/filename-utils';
import { TranscriptResponse } from '../types/transcript';
/**
* Service for handling file operations
*/
export class FileService {
private vault: Vault;
private summaryFolder: string;
private notesFolder: string;
constructor(app: App, summaryFolder: string, notesFolder: string) {
this.vault = app.vault;
this.summaryFolder = summaryFolder;
this.notesFolder = notesFolder;
}
/**
* Ensure output directories exist
*/
async ensureDirectoriesExist(): Promise<void> {
const dirs = [
this.summaryFolder,
this.notesFolder
];
for (const dir of dirs) {
const exists = await this.vault.adapter.exists(dir);
if (!exists) {
await this.vault.createFolder(dir);
}
}
}
/**
* Write transcript data to files
* @param filename Base filename
* @param data Parsed transcript data
*/
async writeTranscriptFiles(filename: string, data: TranscriptResponse): Promise<void> {
// Ensure directories exist
await this.ensureDirectoriesExist();
// Sanitize filename
const sanitizedFilename = sanitizeFilename(filename);
// Format summary content with tasks included
let summaryContent = data.summary;
// Add tasks section if there are tasks
if (data.tasks && data.tasks.length > 0) {
summaryContent += '\n\n## Tasks\n' + data.tasks.join('\n');
}
// Output Summary with tasks
await this.vault.create(`${this.summaryFolder}/${sanitizedFilename} - summary.md`, summaryContent);
// Output Notes
for (const note of data.notes) {
// Sanitize note title for filename
const sanitizedTitle = sanitizeFilename(note.title);
await this.vault.create(`${this.notesFolder}/${sanitizedTitle}.md`, note.content);
}
}
}

View file

@ -0,0 +1,163 @@
import { TranscriptResponse } from '../types/transcript';
/**
* Service for handling OpenAI API calls
*/
export class OpenAIService {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
/**
* Get the system prompt for the OpenAI API
* @param content The transcript content
* @returns The formatted prompt
*/
private getPrompt(content: string): string {
return `
You are an expert agent helping users process their voice notes into structured, useful Obsidian notes. Your mission is to capture the user's ideas, actions, and reflections in clean, atomic form. You act like a smart second brain, formatting output as Obsidian-ready markdown.
# Special Command Handling
- Commands will be marked with the special keyword **AUGI** or close variants ("augie", "auggie", "augi").
- These are **explicit commands** and should override default behavior.
- Commands apply only to preceding or surrounding content, not the entire transcript.
- If the speaker gives an unclear command, do your best to interpret faithfully using recent context.
# Default Behavior (use the Augie commands to guide your behavior)
Follow these steps **in order** to parse the transcript:
### 1. Atomic Notes
- Break down ideas into **self-contained, atomic notes** (1 idea per note).
- Include **supporting details**, context, or reasoning. Be concise but rich in insight.
- Use \`[[Obsidian backlinks]]\` between notes *only when meaningful* and relevant.
- Avoid repetition across notes. Think of each as a unique mental building block.
### 2. Tasks
- Extract clear, actionable tasks or to-dos.
- Format as: \`- [ ] Description of task [[Linked Atomic Note]]\` (if relevant).
- Tasks should only be added if they are genuinely actionable, not vague thoughts.
- If the author **explicitly says** to add a task (e.g., via AUGI), always include it.
### 3. Summary
- Write a 13 sentence summary of **what was said**, not how it was said.
- Highlight key concepts, questions raised, or insights.
- Use \`[[Backlinks]]\` to connect to all relevant atomic notes mentioned formatted as a list.
### 4. Journal Entry (Optional)
- Only create if explicitly asked (e.g. via: "augie this is a journal entry").
- Use **first-person**, preserve author's words as much as possible.
- Clean up repetitions or filler, but stay true to original tone.
- Add tag \`#journal\` at the end.
# Example AUGI Commands
- **"Auggie create note titled XYZ"** Create a note titled "XYZ".
- **"augie summarize this"** Summarize recent thoughts.
- **"augi add task ABC"** Add task "ABC".
- **"auggie the above is a journal entry"** Capture verbatim reflection in journal format.
# Reasoning Strategy
1. **Plan first**: Before writing, identify structure in the speaker's thoughts.
2. **Group context**: Organize ideas around coherent units. These units fomr the foundation of atomic notes.
3. **Respect ambiguity**: When unsure, err on the side of creating a thoughtful atomic note.
4. **Don't repeat**: Avoid redundancy across notes, tasks, or summary.
Transcript:
${content}`;
}
/**
* Call the OpenAI API to parse a transcript
* @param content The transcript content
* @returns Parsed transcript data
*/
async parseTranscript(content: string): Promise<TranscriptResponse> {
if (!this.apiKey) {
throw new Error('OpenAI API key not set');
}
const prompt = this.getPrompt(content);
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify({
model: 'gpt-4.1-2025-04-14',
messages: [{ role: 'user', content: prompt }],
temperature: 0.2,
response_format: {
type: "json_schema",
json_schema: {
name: "transcript_parser",
schema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Short 13 sentence summary of the transcript (no commands included), backlinks to atomic notes should be included."
},
notes: {
type: "array",
items: {
type: "object",
properties: {
title: {
type: "string",
description: "Title of the atomic note"
},
content: {
type: "string",
description: "Markdown-formatted, self-contained idea with backlinks if relevant"
}
},
required: ["title", "content"],
additionalProperties: false
}
},
tasks: {
type: "array",
items: {
type: "string",
description: "Markdown-formatted task with checkbox"
}
}
},
required: ["summary", "notes", "tasks"],
additionalProperties: false
},
strict: true
},
}
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`OpenAI API error: ${response.status} ${errorData.error?.message || response.statusText}`);
}
const responseData = await response.json();
const structuredData = responseData.choices[0].message.content;
// Check for API refusal
if (responseData.choices[0].message.refusal) {
throw new Error(`API refusal: ${responseData.choices[0].message.refusal}`);
}
// Parse the JSON
const parsedData: TranscriptResponse = typeof structuredData === 'string'
? JSON.parse(structuredData)
: structuredData;
return parsedData;
} catch (error) {
console.error('Error calling OpenAI API:', error);
throw error;
}
}
}

11
src/types/plugin.ts Normal file
View file

@ -0,0 +1,11 @@
import { Plugin } from 'obsidian';
import { OpenAugiSettings } from './settings';
import { OpenAIService } from '../services/openai-service';
import { FileService } from '../services/file-service';
export default interface OpenAugiPlugin extends Plugin {
settings: OpenAugiSettings;
openAIService: OpenAIService;
fileService: FileService;
saveSettings(): Promise<void>;
}

13
src/types/settings.ts Normal file
View file

@ -0,0 +1,13 @@
import { App } from 'obsidian';
export interface OpenAugiSettings {
apiKey: string;
summaryFolder: string;
notesFolder: string;
}
export const DEFAULT_SETTINGS: OpenAugiSettings = {
apiKey: '',
summaryFolder: 'OpenAugi/Summaries',
notesFolder: 'OpenAugi/Notes'
};

10
src/types/transcript.ts Normal file
View file

@ -0,0 +1,10 @@
export interface TranscriptNote {
title: string;
content: string;
}
export interface TranscriptResponse {
summary: string;
notes: TranscriptNote[];
tasks: string[];
}

68
src/ui/settings-tab.ts Normal file
View file

@ -0,0 +1,68 @@
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
import type OpenAugiPlugin from '../types/plugin';
export class OpenAugiSettingTab extends PluginSettingTab {
plugin: OpenAugiPlugin;
constructor(app: App, plugin: OpenAugiPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'OpenAugi Settings'});
new Setting(containerEl)
.setName('OpenAI API Key')
.setDesc('Your OpenAI API key')
.addText(text => text
.setPlaceholder('sk-...')
.setValue(this.plugin.settings.apiKey)
.inputEl.type = 'password'
)
.addButton(button => button
.setButtonText(this.plugin.settings.apiKey ? 'Update' : 'Save')
.onClick(async () => {
const inputEl = button.buttonEl.parentElement?.querySelector('input');
if (inputEl) {
this.plugin.settings.apiKey = inputEl.value;
await this.plugin.saveSettings();
new Notice('API Key saved');
button.setButtonText('Update');
}
})
);
new Setting(containerEl)
.setName('Summaries Folder')
.setDesc('Folder path where summary files will be saved')
.addText(text => text
.setPlaceholder('OpenAugi/Summaries')
.setValue(this.plugin.settings.summaryFolder)
.onChange(async (value) => {
this.plugin.settings.summaryFolder = value;
await this.plugin.saveSettings();
// Ensure directories exist after folder path changes
await this.plugin.fileService.ensureDirectoriesExist();
})
);
new Setting(containerEl)
.setName('Notes Folder')
.setDesc('Folder path where atomic notes will be saved')
.addText(text => text
.setPlaceholder('OpenAugi/Notes')
.setValue(this.plugin.settings.notesFolder)
.onChange(async (value) => {
this.plugin.settings.notesFolder = value;
await this.plugin.saveSettings();
// Ensure directories exist after folder path changes
await this.plugin.fileService.ensureDirectoriesExist();
})
);
}
}

View file

@ -0,0 +1,8 @@
/**
* Sanitizes a filename to remove characters that aren't allowed in filenames
* @param filename The filename to sanitize
* @returns The sanitized filename
*/
export function sanitizeFilename(filename: string): string {
return filename.replace(/[\\/:*?"<>|]/g, ' - ');
}

View file

@ -19,6 +19,7 @@
]
},
"include": [
"**/*.ts"
"**/*.ts",
"src/**/*.ts"
]
}