diff --git a/main.ts b/main.ts index 8834a1e..4056083 100644 --- a/main.ts +++ b/main.ts @@ -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 1–3 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 1–3 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(); - }) - ); - } -} \ No newline at end of file +import OpenAugiPlugin from './src/main'; +export default OpenAugiPlugin; \ No newline at end of file diff --git a/manifest.json b/manifest.json index 999675d..819e796 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..396b3a4 --- /dev/null +++ b/src/main.ts @@ -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 { + 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(); + } +} \ No newline at end of file diff --git a/src/services/file-service.ts b/src/services/file-service.ts new file mode 100644 index 0000000..5db7b3a --- /dev/null +++ b/src/services/file-service.ts @@ -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 { + 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 { + // 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); + } + } +} \ No newline at end of file diff --git a/src/services/openai-service.ts b/src/services/openai-service.ts new file mode 100644 index 0000000..fcfe425 --- /dev/null +++ b/src/services/openai-service.ts @@ -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 1–3 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 { + 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 1–3 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; + } + } +} \ No newline at end of file diff --git a/src/types/plugin.ts b/src/types/plugin.ts new file mode 100644 index 0000000..41c3209 --- /dev/null +++ b/src/types/plugin.ts @@ -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; +} \ No newline at end of file diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 0000000..202f299 --- /dev/null +++ b/src/types/settings.ts @@ -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' +}; \ No newline at end of file diff --git a/src/types/transcript.ts b/src/types/transcript.ts new file mode 100644 index 0000000..4cd0cbe --- /dev/null +++ b/src/types/transcript.ts @@ -0,0 +1,10 @@ +export interface TranscriptNote { + title: string; + content: string; +} + +export interface TranscriptResponse { + summary: string; + notes: TranscriptNote[]; + tasks: string[]; +} \ No newline at end of file diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts new file mode 100644 index 0000000..5005611 --- /dev/null +++ b/src/ui/settings-tab.ts @@ -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(); + }) + ); + } +} \ No newline at end of file diff --git a/src/utils/filename-utils.ts b/src/utils/filename-utils.ts new file mode 100644 index 0000000..5d130f0 --- /dev/null +++ b/src/utils/filename-utils.ts @@ -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, ' - '); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c44b729..2c097c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ ] }, "include": [ - "**/*.ts" + "**/*.ts", + "src/**/*.ts" ] }