diff --git a/CONTEXT_GATHERING.md b/CONTEXT_GATHERING.md new file mode 100644 index 0000000..7ca5224 --- /dev/null +++ b/CONTEXT_GATHERING.md @@ -0,0 +1,643 @@ +# Unified Context Gathering System + +## Overview + +OpenAugi's unified context gathering system is a powerful foundation that intelligently discovers, filters, and aggregates content from your notes before processing. Instead of having separate, rigid workflows for different commands, you now have **one flexible system** that lets you configure exactly what context to gather, review it before processing, and then choose how to process it. + +Think of it as a three-stage pipeline: +1. **Gather**: Discover and collect notes based on your criteria +2. **Review**: Preview and refine your selection with checkboxes +3. **Process**: Choose to distill, publish, or just save the raw context + +## Why This Matters + +### The Problem It Solves + +Previously, each command had its own way of gathering notes: +- "Distill linked notes" only looked at direct links (depth 1) +- "Distill recent activity" used time-based discovery +- No way to combine approaches or preview before processing +- No way to save raw context without AI processing + +### The Solution + +The unified system gives you: +- **Flexible discovery**: Link traversal up to 3 levels deep OR time-based discovery +- **Intelligent filtering**: Character limits, folder exclusions, journal section filtering +- **Checkbox review**: See exactly what will be included and toggle individual notes +- **Multiple outputs**: Distill to atomic notes, publish as blog post, or save raw context +- **Reusable architecture**: All commands use the same context gathering foundation + +## Core Concepts + +### Source Modes + +**Linked Notes Mode** +- Starts from current note +- Follows links breadth-first up to 3 levels deep +- Includes: `[[wikilinks]]`, embeds, dataview queries, checked checkboxes +- Perfect for: Processing related content, topic deep-dives, project summaries + +**Recent Activity Mode** +- Discovers notes by modification time or filename dates +- Supports "last N days" or specific date range +- Perfect for: Daily/weekly reviews, periodic summaries, time-based synthesis + +### Link Depth Traversal + +The system uses **Breadth-First Search (BFS)** to traverse links: + +``` +Depth 0: Root note (your starting point) +Depth 1: Direct links from root +Depth 2: Links from depth 1 notes +Depth 3: Links from depth 2 notes +``` + +**Example:** +``` +You're on: [[Project Overview]] + ├─ Depth 1: [[Meeting Notes]], [[Technical Specs]] + │ ├─ Depth 2: [[Action Items]], [[Architecture Decisions]] + │ │ └─ Depth 3: [[Implementation Details]], [[Risk Assessment]] +``` + +**Why BFS?** +- Captures breadth of related content first +- Prevents going too deep into narrow tangents +- More predictable and controllable than depth-first search + +### Character Limits + +Default: **100,000 characters** (~25,000 tokens) + +**How it works:** +- System tracks total characters as it discovers notes +- When approaching limit, marks remaining notes as "discovered but not included" +- You can still manually include them via checkboxes (up to you!) +- Prevents token overflow and excessive API costs + +### Journal Section Filtering + +For journal-style notes with date headers (e.g., `### 2025-01-15`): +- **Enabled**: Only extracts sections within your time window +- **Disabled**: Includes entire note content +- Configurable date header format in settings + +**Use case:** Your daily journal has 365 sections. You only want the last 7 days worth of entries, not the entire year. + +## New Commands + +### 1. OpenAugi: Process Notes + +**Default behavior:** +- Source: Linked notes from current note +- Depth: 1 (direct links only) +- Shows processing type selector: Distill OR Publish + +**Best for:** +- Processing a curated set of notes +- Topic-focused synthesis +- Creating blog posts from research notes + +**Example workflow:** +1. Create note: `2025 Q1 Learning.md` +2. Add links: `[[Book Notes]]`, `[[Course Notes]]`, `[[Project Insights]]` +3. Run command → adjust depth to 2 → review checkboxes → publish as blog post + +### 2. OpenAugi: Process Recent Activity + +**Default behavior:** +- Source: Recent activity (last 7 days) +- Depth: N/A (time-based, not link-based) +- Shows processing type selector: Distill OR Publish + +**Best for:** +- Weekly reviews +- Activity summaries +- Periodic reflection posts + +**Example workflow:** +1. Run command +2. Change to "last 30 days" for monthly review +3. Exclude "Archive" and "Templates" folders +4. Select only the notes relevant to your review +5. Distill to atomic notes for your weekly review summary + +### 3. OpenAugi: Save Context + +**Default behavior:** +- Source: Linked notes from current note +- Depth: 1 +- Skips AI processing, saves raw aggregated content + +**Best for:** +- Gathering research before manual synthesis +- Creating reference documents +- Debugging context gathering +- Exporting collections for external tools + +**Example workflow:** +1. Create note with links to all project documentation +2. Run "Save Context" → depth 3 → deselect irrelevant notes +3. Get single markdown file with all content aggregated +4. Use for offline reading, sharing, or manual analysis + +## The Complete Flow + +### Step 1: Context Gathering Configuration + +**Modal: "Gather Context"** + +Configure HOW to discover notes: + +**Source:** +- `Linked notes from current note` (follows links) +- `Recently modified notes` (time-based) + +**For Linked Notes:** +- `Root note`: Shows current note (auto-filled) +- `Link depth`: Slider 1-3 levels +- `Max characters`: Default 100,000 (adjustable) + +**For Recent Activity:** +- `Time window`: "Last N days" OR "Specific date range" +- `Days back`: 1, 7, 30, etc. +- `From/To dates`: YYYY-MM-DD format + +**Filtering:** +- `Exclude folders`: Comma-separated list (e.g., "Templates, Archive") +- `Recent sections only`: Toggle for journal filtering +- `Journal sections days back`: How many days for section filtering + +**Estimate display:** +Shows estimated note count and character count before discovery + +**Action:** Click "Discover Notes" → + +### Step 2: Checkbox Selection + +**Modal: "Select Notes to Include"** + +Review ALL discovered notes with full control: + +**Features:** +- ✅ Checkbox for each note (checked = include) +- Organized by depth level (for linked notes) +- Shows character count per note +- Shows discovery path ("root", "linked from [[X]]", "recent activity") +- `Select All` / `Deselect All` buttons +- Real-time summary: "Selected: 8 of 12 notes (24,532 characters, ~6,133 tokens)" + +**Why this step matters:** +- You see exactly what was discovered +- Remove irrelevant notes before processing +- Verify the context makes sense +- Control costs by managing total size + +**Action:** Click "Continue" → + +### Step 3: Context Preview + +**Modal: "Context Preview"** + +Final review before processing: + +**Displays:** +- 📊 Summary stats (notes, characters, tokens, source mode, link depth) +- 📝 List of all included notes +- 👁️ Preview of first 1000 characters of aggregated content + +**Two paths:** + +**Path A: Save Raw Context** +- Creates note: `Context YYYY-MM-DD HH-mm-ss.md` +- Saved to: `OpenAugi/` folder +- Contains: Metadata + full aggregated content +- No AI processing, no API cost +- Opens immediately for review + +**Path B: Process with AI** +- Shows prompt selection modal → + +### Step 4A: Save Raw Context (Path A) + +**Output:** +```markdown +# Gathered Context + +**Source**: linked-notes +**Notes**: 8 +**Characters**: 24,532 +**Timestamp**: 2025-10-13T14:30:00Z +**Link Depth**: 2 + +## Included Notes +- [[Root Note]] +- [[Note 1]] +- [[Note 2]] +... + +--- + +[Full aggregated content here] +``` + +**Use cases:** +- Manual synthesis and writing +- Debugging context issues +- Sharing context with team +- Backup before AI processing + +### Step 4B: Process with AI (Path B) + +**Modal: "Process Context"** + +Choose processing type and prompt: + +**Output format:** +- `Distill to atomic notes` (default) +- `Publish as single post` (new!) + +**Custom prompt (optional):** +- Dropdown of prompts from `OpenAugi/Prompts/` folder +- Or use default prompt + +**Action:** Click "Process" → + +### Step 5: AI Processing & Output + +**If Distill:** +- Creates atomic notes (one concept per note) +- Creates summary with links to atomic notes +- Extracts tasks +- Saved to: `OpenAugi/Notes/Distill [Name] YYYY-MM-DD/` +- Summary: `OpenAugi/Summaries/[Name] - distilled.md` + +**If Publish:** +- Creates single polished blog post +- Conversational, reader-friendly tone +- Preserves your voice and personality +- Saved to: `OpenAugi/Published/[Title] - Published YYYY-MM-DD.md` +- Includes frontmatter with metadata + +## Processing Types Explained + +### Distill to Atomic Notes + +**What it does:** +- Analyzes content for distinct concepts and ideas +- Creates separate notes (one idea per note) +- Deduplicates and merges overlapping concepts +- Extracts actionable tasks +- Generates summary linking all atomic notes + +**Output structure:** +``` +OpenAugi/Notes/Distill MyProject 2025-10-13 14-30-52/ + ├─ Core Concept.md + ├─ Key Decision.md + ├─ Technical Approach.md + └─ Risk Consideration.md + +OpenAugi/Summaries/MyProject - distilled.md + └─ [Summary with links to above notes + tasks] +``` + +**Best for:** +- Building knowledge base +- Organizing research +- Breaking down complex topics +- Creating reusable concept notes + +### Publish as Single Post + +**What it does:** +- Transforms raw notes into ONE cohesive blog post +- Preserves your unique voice and personality +- Adds narrative structure and transitions +- Formats for readability (headers, paragraphs, emphasis) +- Creates conversational, direct tone + +**Output structure:** +``` +OpenAugi/Published/Building Second Brains - Published 2025-10-13.md + +--- +type: published-post +published_date: 2025-10-13T14:30:00Z +prompt_used: default +status: draft +source_notes: [[Note 1]], [[Note 2]], [[Note 3]] +--- + +# Building a Second Brain That Actually Works + +[Full polished blog post ready to copy-paste] + +--- +*Generated from notes using OpenAugi. Source notes: [[Note 1]], [[Note 2]]...* +``` + +**Best for:** +- Creating shareable content +- Blog posts and articles +- Discord/forum posts +- Documentation +- Turning notes into publishable writing + +**Publishing prompt focus:** +- PRESERVE: Your voice, tone, creative phrases, core insights +- ADD: Context, transitions, narrative arc, why it matters +- FORMAT: Short paragraphs, headers, emphasis, conversational +- TONE: Direct, honest, friend-to-friend explanation + +## Configuration Settings + +**Settings → OpenAugi → Context Gathering Settings** + +### Default Link Depth +- Slider: 1-3 +- Default: 1 (direct links only) +- What it does: Sets initial depth when opening context gathering modal + +### Default Max Characters +- Input: Number +- Default: 100,000 +- What it does: Character limit before stopping discovery +- Recommended: 50k-150k depending on your vault size + +### Filter Recent Sections by Default +- Toggle: On/Off +- Default: On +- What it does: Enables journal section filtering in recent activity mode +- When to disable: If you want full note content always + +### Published Folder +- Input: Path +- Default: `OpenAugi/Published` +- What it does: Where published blog posts are saved + +## How I Intend to Use This + +### Weekly Review & Publishing + +**Every Sunday:** +1. Run "Process Recent Activity" +2. Set to "last 7 days" +3. Exclude "Archive", "Templates", "OpenAugi" +4. Enable "Recent sections only" (my daily journal is huge) +5. Review checkboxes - uncheck meeting notes, keep insights +6. Choose "Publish as single post" +7. Get weekly reflection blog post ready for my blog + +**Why this works:** +- Automated discovery of my week's work +- Filter out noise (meetings, templates) +- Only recent journal sections (not entire year) +- Direct path from notes → publishable post +- Preserves my voice without over-editing + +### Deep Research Synthesis + +**When finishing a research project:** +1. Create note: `Project X - Research Hub.md` +2. Add links to all research notes, papers, meeting notes +3. Run "Process Notes" +4. Set depth to 3 (capture transitively related notes) +5. Max characters: 150k (large project) +6. Review checkboxes, keep only relevant notes +7. Choose "Distill to atomic notes" +8. Get organized knowledge base of research insights + +**Why this works:** +- Depth 3 captures comprehensive context +- Checkbox review removes tangential notes +- Distillation creates reusable concept notes +- Summary shows connections I might have missed + +### Content Pipeline + +**For creating blog content:** + +**Stage 1: Gather context** +1. Create `Blog Ideas - October.md` +2. Link to rough notes: `[[Idea 1]]`, `[[Idea 2]]`, etc. +3. Run "Save Context" (depth 2) +4. Review raw aggregated content +5. Manually edit/refine the context note + +**Stage 2: Publish** +1. Run "Process Notes" on the refined context note +2. Depth 1 (just the context itself) +3. Choose "Publish as single post" +4. Get polished blog post + +**Why this works:** +- Save context first lets me review before AI processing +- Manual refinement adds my editorial eye +- Publishing step transforms refined notes to blog post +- Two-stage process: gather → refine → publish + +### Project Documentation + +**Creating comprehensive project docs:** +1. Run "Process Notes" on `Project Overview.md` +2. Depth 3, max 200k characters +3. Include all technical specs, decisions, meeting notes +4. Save raw context first (for reference) +5. Then process with custom "Technical Documentation" prompt +6. Get structured technical docs in atomic note format + +**Why this works:** +- Raw context saved for completeness +- Deep traversal ensures nothing missed +- Custom prompt focuses on technical aspects +- Atomic notes create navigable documentation structure + +## Advanced Tips + +### Combining Source Modes + +**Strategy: Recent notes as seed, then traverse links** +1. First, run "Process Recent" to discover recent work +2. Save the collection (checkbox selection) +3. Then run "Process Notes" on that collection with depth 2 +4. Captures recent work PLUS related context + +### Managing Character Limits + +**For large vaults:** +- Start with 50k character limit +- Review what gets excluded in checkbox modal +- If needed, increase limit and re-run +- Or: Process in batches (multiple depth-1 runs) + +### Journal Section Filtering + +**Best practices:** +- Set date header format to match your journals +- Enable "Recent sections only" by default +- Adjust "Journal sections days back" to match time window +- Example: 30-day review → 30-day journal sections + +### Custom Prompts for Publishing + +**Create publishing prompt variants:** +- `Technical Blog Post.md` - Focus on explaining technical concepts +- `Personal Reflection.md` - Emphasis on insights and growth +- `Tutorial Style.md` - Step-by-step instructional tone + +**Store in:** `OpenAugi/Prompts/` + +### Iterative Refinement + +**Process:** +1. Save raw context first (see what you're working with) +2. Review, identify gaps or noise +3. Adjust gathering config (depth, exclusions) +4. Re-run with refined config +5. Process with AI when context is clean + +## Troubleshooting + +### "No notes discovered" + +**Check:** +- Is current note open? (for linked notes mode) +- Do links exist in current note? +- Are folders excluded that contain your notes? +- For recent activity: Is date range correct? + +### "Character limit exceeded" warning + +**Solutions:** +- Increase max characters in config +- Use folder exclusions to filter large notes +- Reduce link depth +- Process in smaller batches + +### "Journal section filtering not working" + +**Check:** +- Date header format matches your notes exactly +- Format includes YYYY, MM, DD placeholders +- Headers are markdown headers (##, ###, etc.) +- Enable "Recent sections only" toggle + +### Processing takes too long + +**Optimize:** +- Reduce character count (smaller context) +- Use folder exclusions aggressively +- Start with depth 1, increase if needed +- Check for very large linked notes + +### Output not what expected + +**Try:** +- Review raw context first (save context command) +- Verify checkbox selections +- Use custom prompt for specific focus +- Add custom context in notes for guidance + +## Future Enhancements + +Potential additions to the system: + +**Section-level selection:** +- Checkbox specific headings within notes +- Include only relevant sections +- More granular control than note-level + +**Smart filtering:** +- Auto-exclude based on tags +- Include only notes with specific properties +- Filter by note type or template + +**Preset configurations:** +- Save gathering configs as presets +- Quick-select "Weekly Review" config +- Share configs between vaults + +**Graph-based discovery:** +- Visual graph of discovered notes +- Click to toggle inclusion +- See relationships before processing + +**Batch operations:** +- Process multiple root notes at once +- Merge contexts from different sources +- Parallel processing workflows + +## Technical Details + +### BFS Algorithm + +The link traversal uses breadth-first search: + +```typescript +Queue: [root] +Depth 0: Process root, add direct links to queue +Depth 1: Process direct links, add their links to queue +Depth 2: Process second-level links, add their links to queue +Depth 3: Process third-level links (stop, max depth reached) +``` + +**Properties:** +- Explores broadly before deeply +- Guarantees shortest path to any note +- Respects character limits during traversal +- Marks overflow notes as "discovered but excluded" + +### Character Counting + +Tracks cumulative characters during discovery: + +```typescript +totalChars = 0 +for each discovered note: + contentSize = note.length + if totalChars + contentSize > maxChars: + mark note as excluded (overflow) + else: + include note + totalChars += contentSize +``` + +User can manually override exclusions in checkbox modal. + +### Aggregation Format + +Content aggregated as: + +```markdown +# Note: [Note 1 Title] + +[Note 1 full content] + +# Note: [Note 2 Title] + +[Note 2 full content] +``` + +This format: +- Preserves note boundaries +- Maintains context for AI +- Easy to read in raw format +- Works well for both distill and publish + +## Comparison with Previous System + +| Feature | Before | Now | +|---------|--------|-----| +| Link depth | 1 only | 1-3 configurable | +| Preview before processing | No | Yes (checkbox modal) | +| Character limits | None | Configurable | +| Save raw context | No | Yes | +| Publishing support | No | Yes | +| Source flexibility | Fixed per command | Choose in modal | +| Manual selection | Only for recent activity | All modes | +| Reusable architecture | No | Yes | + +The new system is a **superset** of the old functionality - everything that worked before still works, but with more control and flexibility. diff --git a/README.md b/README.md index 02a70e3..03a7f4a 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Unlock the power of voice capture and go faster. Open Augi ("auggie") is an open source augmented intelligence plugin for Obsidian. It's designed for people who like to think out loud (like me). +**✨ NEW: Unified Context Gathering System** - Intelligently discover notes (up to 3 levels deep), review with checkboxes, and choose to distill into atomic notes OR publish as a polished blog post. One flexible system, multiple outputs. [Read the full guide →](CONTEXT_GATHERING.md) + Just capture your voice note, drop hints to Augi, and let Open Augi's agentic workflow process your note into a self-organizing second brain for you. This is designed to run in a separate folder within your vault. Any agentic actions taken on existing notes, not created by Augi, will be sent to you for review. @@ -24,7 +26,7 @@ Parent [repo](https://github.com/bitsofchris/openaugi). ## Main Commands -OpenAugi offers two primary commands: +OpenAugi offers commands for different workflows - from voice transcripts to unified context gathering: ### 1. Parse Transcript @@ -50,7 +52,112 @@ Using "auggie" as a special token during your voice note can improve accuracy of - Say "auggie summarize this" to get a summary of recent thoughts - Say "auggie this is a journal entry" to format text as a journal entry -### 2. Distill Linked Notes +--- + +## 🆕 Unified Context Gathering Commands + +**NEW: Flexible, powerful context gathering with link traversal, checkboxes, and dual output modes (distill OR publish).** + +These commands use OpenAugi's unified context gathering system - a three-stage pipeline that gives you full control: + +1. **Configure** - Choose source (linked notes or recent activity), depth, filters +2. **Review** - See discovered notes in checkbox list, toggle individual notes on/off +3. **Process** - Choose to distill into atomic notes OR publish as a single blog post + +[📖 Read the complete Context Gathering Guide](CONTEXT_GATHERING.md) + +### Process Notes + +**Best for:** Processing curated sets of linked notes, creating blog posts from research, topic-focused synthesis + +**How it works:** +1. Open any note with links to content you want to process +2. Run `OpenAugi: Process notes` +3. Configure discovery: + - **Link depth**: 1-3 levels (breadth-first traversal) + - **Max characters**: Default 100k (prevents overflow) + - **Folder exclusions**: Skip Templates, Archive, etc. + - **Journal filtering**: Extract only recent sections from journal notes +4. Review discovered notes in checkbox list +5. See preview with stats (notes, characters, tokens) +6. Choose output: **Distill to atomic notes** OR **Publish as single post** +7. Optionally select custom prompt lens + +**Outputs:** +- **Distill**: Atomic notes in `OpenAugi/Notes/`, summary in `OpenAugi/Summaries/` +- **Publish**: Single blog post in `OpenAugi/Published/` with frontmatter + +**Example use case:** +```markdown +# Q4 2024 Learning.md + +Links to process: +- [[Book: Building a Second Brain]] +- [[Course: Knowledge Management]] +- [[Project Insights]] + +Run "Process notes" → Depth 2 → Publish as blog post +→ Get: "What I Learned About Knowledge Management - Published 2025-10-13.md" +``` + +### Process Recent Activity + +**Best for:** Weekly reviews, activity summaries, periodic reflection posts + +**How it works:** +1. Run `OpenAugi: Process recent activity` +2. Configure time window: + - **Last N days** (quick: 1, 7, 30 days) + - **Specific date range** (exact: 2025-01-01 to 2025-01-31) +3. Same review → preview → process flow as above + +**Example use case:** +``` +Weekly review every Sunday: +1. Run "Process recent activity" +2. Set to "Last 7 days" +3. Exclude "Archive", "Templates" +4. Enable "Recent sections only" for journal filtering +5. Uncheck meeting notes, keep insights +6. Publish as blog post +→ Get: Weekly reflection ready for blog +``` + +### Save Context + +**Best for:** Gathering research without AI processing, creating reference documents, debugging context + +**How it works:** +1. Same configuration and review flow +2. But skips AI processing entirely +3. Saves raw aggregated content to `OpenAugi/Context YYYY-MM-DD.md` + +**Example use case:** +``` +Gather all project documentation: +1. Create note with links to all specs, decisions, notes +2. Run "Save context" → Depth 3 +3. Get single markdown file with everything aggregated +→ Use for offline reading, sharing, manual synthesis +``` + +### Key Features + +✅ **Link depth traversal** - Go up to 3 levels deep (breadth-first search) +✅ **Checkbox review** - Toggle individual notes before processing +✅ **Character limits** - Prevents token overflow (default: 100k) +✅ **Dual output modes** - Distill (atomic notes) OR Publish (blog post) +✅ **Journal filtering** - Extract only recent sections from journal notes +✅ **Raw context saving** - Skip AI, just aggregate content +✅ **Custom prompts** - Use lenses for focused processing + +--- + +## Legacy Commands + +These commands still work but are now superseded by the unified context gathering system above. + +### 2. Distill Linked Notes (Legacy) This command analyzes a set of linked notes and synthesizes them into a coherent set of atomic notes. @@ -71,64 +178,6 @@ The plugin will: - Generate a summary that connects the key concepts - Extract any actionable tasks found across the notes -### 3. Distill Recent Activity - -This command automatically discovers and distills notes that have been recently modified or have date prefixes in their filenames, perfect for daily/weekly reviews. - -**Usage:** -1. Hit `CMD+P` (or `CTRL+P` on Windows/Linux) to open the command palette -2. Run `OpenAugi: Distill Recent Activity` -3. Configure your preferences in the modal: - - **Time Window Selection**: - - **Default Mode**: "Days to look back" - specify how many days of activity to include (default: 7) - - **Date Range Mode**: Toggle "Use specific date range" to select exact start and end dates - - **Filter journal sections**: For journal-style notes with date headers, only include recent sections - - **Root note**: Optionally specify a note to provide additional context - - **Exclude folders**: Skip folders like Templates or Archive -4. Click "Select Notes" to preview and choose which notes to include: - - View all discovered notes with their modification dates - - Check/uncheck individual notes to customize your selection - - Use "Select All" toggle for bulk selection -5. Optional: Click "Save as Collection" to create a persistent checkbox list for future use -6. Click "Distill" to process -7. Select a custom prompt "lens" or use the default prompt - -**The plugin will:** -- Show discovered notes in an interactive checkbox list -- Process only the notes you've selected -- For journal-style notes with date headers (e.g., `### 2024-01-20`), extract only recent sections -- Synthesize the activity into organized atomic notes using your selected lens -- Create atomic notes in a timestamped session folder (e.g., `OpenAugi/Notes/Recent Activity 2024-01-20 14-30-52/`) -- Generate a summary with a descriptive name based on content - -**Date Range Selection:** -Toggle between two modes for selecting your time window: -- **"Last N days"**: Quick selection for recent activity (1 day, 7 days, etc.) -- **Specific date range**: Choose exact start and end dates using date pickers - - Perfect for reviewing specific periods like "last week" or "this month" - - Includes all notes modified or dated within the range - -**Note Selection Interface:** -The new selection interface lets you: -- Preview all notes that match your criteria before processing -- See each note's folder location and modification time -- Exclude specific notes by unchecking them -- Save your selection as a collection for future reference - -**Save as Collection:** -Create a persistent record of your note selection: -- Saves to `OpenAugi/Collections/` folder -- Preserves checked/unchecked states -- Can be used later with "Distill Linked Notes" command -- Perfect for creating curated sets of notes for repeated analysis - -**Date-Based Note Discovery:** -Notes with filenames beginning with `YYYY-MM-DD` format are automatically included if their date falls within your specified time window, regardless of when they were last modified. This is perfect for: -- Meeting notes dated by when they occurred -- Daily logs or journal entries -- Event-based documentation -- Any notes you want to organize by their content date rather than modification time - ## Custom Prompt Lenses **NEW**: OpenAugi now supports custom prompt templates that act as "lenses" to focus processing on specific aspects of your notes. @@ -139,7 +188,7 @@ Custom prompts allow you to guide OpenAugi's AI processing with specific perspec ### Using Custom Prompts -1. When you run "Distill Linked Notes" or "Distill Recent Activity", you'll see a prompt selection modal +1. When you run "Distill Linked Notes", you'll see a prompt selection modal 2. Choose from any prompt in your prompts folder (default: `OpenAugi/Prompts`) 3. The selected prompt replaces the default processing instructions while keeping the structured output format 4. Or select "Use default prompt" for general-purpose processing @@ -247,7 +296,7 @@ The custom context allows you to narrow the focus of processing to extract speci ## Use Cases ### Daily/Weekly Reviews -Use "Distill Recent Activity" to automatically summarize your work: +Use "Process Recent Activity" to automatically summarize your work: - Set to 1 day for daily reviews - Set to 7 days for weekly reviews - Automatically captures all your recent thoughts and work @@ -261,14 +310,14 @@ Use "Distill Linked Notes" with a project hub note: ### Research Synthesis Combine both commands for research workflows: -- Use "Distill Recent Activity" to review recent research notes +- Use "Process Recent Activity" to review recent research notes - Use "Distill Linked Notes" on topic-specific collections - Add custom context to focus on findings, methodologies, or insights ### Journal Processing Take advantage of journal-style note support: - Keep daily journal entries with date headers -- Use "Distill Recent Activity" to extract recent insights +- Use "Process Recent Activity" to extract recent insights - Only relevant date sections are processed, keeping context focused ## Requirements @@ -285,10 +334,18 @@ OpenAugi provides several configuration options in Settings → OpenAugi: - **Summaries Folder**: Where summary files are saved (default: `OpenAugi/Summaries`) - **Notes Folder**: Where atomic notes are saved (default: `OpenAugi/Notes`) - **Prompts Folder**: Where custom prompt templates are stored (default: `OpenAugi/Prompts`) +- **Published Folder**: Where published blog posts are saved (default: `OpenAugi/Published`) - **Use Dataview**: Enable processing of dataview queries in distillation +### Context Gathering Settings +Configure defaults for the unified context gathering system: + +- **Default Link Depth**: Initial depth for link traversal (1-3, default: 1) +- **Default Max Characters**: Character limit before stopping discovery (default: 100,000) +- **Filter Recent Sections by Default**: Automatically enable journal section filtering (default: On) + ### Recent Activity Settings -Configure defaults for the "Distill Recent Activity" command: +Configure defaults for recent activity processing: - **Default Days to Look Back**: How many days of activity to include by default (default: 7) - **Filter Journal Sections**: When enabled, only includes recent sections from journal-style notes @@ -322,7 +379,7 @@ Yesterday's reflections... Older content that may be filtered out... ``` -When using "Distill Recent Activity" with a 7-day window, only the recent sections would be processed. +When using "Process Recent Activity" with a 7-day window, only the recent sections would be processed. ## Output Structure diff --git a/manifest.json b/manifest.json index 9b45160..5794d90 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "openaugi", "name": "OpenAugi", - "version": "0.2.0", + "version": "0.3.0", "minAppVersion": "1.8.9", "description": "Parse your voice notes into atomic notes, tasks, and summaries. OpenAugi is the voice to self-organizing second brain. While taking a voice note say 'auggie' to help the agent. Augmented Intelligence is AI for thinkers.", "author": "Chris Lettieri", diff --git a/src/main.ts b/src/main.ts index 68f97c6..902836c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,11 +3,15 @@ import { OpenAugiSettings, DEFAULT_SETTINGS } from './types/settings'; import { OpenAIService } from './services/openai-service'; import { FileService } from './services/file-service'; import { DistillService } from './services/distill-service'; +import { ContextGatheringService } from './services/context-gathering-service'; import { OpenAugiSettingTab } from './ui/settings-tab'; import { LoadingIndicator } from './ui/loading-indicator'; import { sanitizeFilename, createFileWithCollisionHandling } from './utils/filename-utils'; -import { RecentActivityModal, RecentActivityConfig } from './ui/recent-activity-modal'; import { PromptSelectionModal, PromptSelectionConfig } from './ui/prompt-selection-modal'; +import { ContextGatheringModal } from './ui/context-gathering-modal'; +import { ContextSelectionModal } from './ui/context-selection-modal'; +import { ContextPreviewModal } from './ui/context-preview-modal'; +import { CommandOptions, ContextGatheringConfig, GatheredContext, DiscoveredNote } from './types/context'; /** * A simple tokeinzer to estimate the number of tokens @@ -24,6 +28,7 @@ export default class OpenAugiPlugin extends Plugin { openAIService: OpenAIService; fileService: FileService; distillService: DistillService; + contextGatheringService: ContextGatheringService; loadingIndicator: LoadingIndicator; async onload() { @@ -69,12 +74,41 @@ export default class OpenAugiPlugin extends Plugin { } }); - // Add command to distill recent activity + // Add new unified context gathering commands this.addCommand({ - id: 'distill-recent-activity', - name: 'Distill recent activity', + id: 'openaugi-process-notes', + name: 'Process notes', callback: async () => { - await this.distillRecentActivity(); + await this.gatherAndProcessContext({ + commandType: 'distill', + defaultSourceMode: 'linked-notes', + defaultDepth: 1 + }); + } + }); + + this.addCommand({ + id: 'openaugi-process-recent', + name: 'Process recent activity', + callback: async () => { + await this.gatherAndProcessContext({ + commandType: 'distill', + defaultSourceMode: 'recent-activity', + defaultDepth: 1 + }); + } + }); + + this.addCommand({ + id: 'openaugi-save-context', + name: 'Save context', + callback: async () => { + await this.gatherAndProcessContext({ + commandType: 'save-raw', + defaultSourceMode: 'linked-notes', + defaultDepth: 1, + skipPreview: false + }); } }); @@ -82,21 +116,34 @@ export default class OpenAugiPlugin extends Plugin { this.addSettingTab(new OpenAugiSettingTab(this.app, this)); } + /** + * Get the configured model (custom override or default) + */ + private getConfiguredModel(): string { + return this.settings.customModelOverride.trim() || this.settings.defaultModel; + } + /** * Initialize services with current settings */ private initializeServices(): void { - this.openAIService = new OpenAIService(this.settings.apiKey); + this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel()); this.fileService = new FileService( - this.app, - this.settings.summaryFolder, - this.settings.notesFolder + this.app, + this.settings.summaryFolder, + this.settings.notesFolder, + this.settings.publishedFolder ); this.distillService = new DistillService( this.app, this.openAIService, this.settings ); + this.contextGatheringService = new ContextGatheringService( + this.app, + this.distillService, + this.settings + ); } /** @@ -136,8 +183,8 @@ export default class OpenAugiPlugin extends Plugin { // Display character and token count new Notice(`Processing transcript: ${file.basename}\nCharacters: ${content.length}\nEst. Tokens: ${estimateTokens(content)}`); - // Update openAIService with latest API key - this.openAIService = new OpenAIService(this.settings.apiKey); + // Update openAIService with latest API key and model + this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel()); // Parse transcript const parsedData = await this.openAIService.parseTranscript(content); @@ -197,8 +244,8 @@ export default class OpenAugiPlugin extends Plugin { return; } - // Update services with latest API key - this.openAIService = new OpenAIService(this.settings.apiKey); + // Update services with latest API key and model + this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel()); this.distillService = new DistillService( this.app, this.openAIService, @@ -278,35 +325,214 @@ export default class OpenAugiPlugin extends Plugin { this.initializeServices(); } + /** - * Distill recent activity based on user configuration + * Main orchestration method for unified context gathering and processing + * @param options Options specifying the command type and defaults */ - private async distillRecentActivity(): Promise { - // Show configuration modal - const modal = new RecentActivityModal( + private async gatherAndProcessContext(options: CommandOptions): Promise { + // Step 1: Show context gathering configuration modal + const configModal = new ContextGatheringModal( this.app, - this.settings.recentActivityDefaults, - async (config: RecentActivityConfig) => { - // After recent activity config, show prompt selection - const promptModal = new PromptSelectionModal( - this.app, - this.settings.promptsFolder, - async (promptConfig: PromptSelectionConfig) => { - await this.executeRecentActivityDistill(config, promptConfig); - } - ); - promptModal.open(); - } + this.settings, + this.contextGatheringService, + async (config: ContextGatheringConfig) => { + await this.executeContextGathering(config, options); + }, + options.defaultSourceMode, + options.defaultDepth ); - modal.open(); + configModal.open(); } /** - * Execute the recent activity distillation with the given configuration - * @param config The configuration for recent activity distillation - * @param promptConfig The prompt configuration from the modal + * Execute context gathering with the given configuration + * @param config Context gathering configuration + * @param options Command options */ - private async executeRecentActivityDistill(config: RecentActivityConfig, promptConfig: PromptSelectionConfig): Promise { + private async executeContextGathering( + config: ContextGatheringConfig, + options: CommandOptions + ): Promise { + try { + this.loadingIndicator?.show('Discovering notes...'); + + // Gather context + const gatheredContext = await this.contextGatheringService.gatherContext(config); + + this.loadingIndicator?.hide(); + + // Check if any notes were discovered + if (gatheredContext.notes.length === 0) { + new Notice('No notes discovered with the given configuration'); + return; + } + + // Step 2: Show checkbox selection modal + const selectionModal = new ContextSelectionModal( + this.app, + gatheredContext.notes, + async (selectedNotes: DiscoveredNote[]) => { + await this.showContextPreview(gatheredContext, selectedNotes, options); + } + ); + selectionModal.open(); + } catch (error) { + this.loadingIndicator?.hide(); + console.error('Failed to gather context:', error); + new Notice('Failed to gather context: ' + error.message); + } + } + + /** + * Show context preview with save/process options + * @param context Original gathered context + * @param selectedNotes User-selected notes + * @param options Command options + */ + private async showContextPreview( + context: GatheredContext, + selectedNotes: DiscoveredNote[], + options: CommandOptions + ): Promise { + try { + // Check if any notes selected + if (selectedNotes.length === 0) { + new Notice('No notes selected'); + return; + } + + this.loadingIndicator?.show('Aggregating content...'); + + // Re-aggregate with only selected notes + const files = selectedNotes.map(n => n.file); + const aggregated = await this.distillService.aggregateContent( + files, + context.config.filterRecentSectionsOnly ? context.config.journalSectionDays : undefined + ); + + // Update context with selected notes + const finalContext: GatheredContext = { + ...context, + notes: selectedNotes, + aggregatedContent: aggregated.content, + totalCharacters: aggregated.content.length, + totalNotes: selectedNotes.length + }; + + this.loadingIndicator?.hide(); + + // Determine button label based on command type + const processButtonLabel = options.commandType === 'save-raw' + ? 'Save Context' + : 'Process with AI'; + + // Step 3: Show preview modal + const previewModal = new ContextPreviewModal( + this.app, + finalContext, + async () => await this.saveRawContext(finalContext), + async () => { + if (options.commandType === 'save-raw') { + await this.saveRawContext(finalContext); + } else { + await this.processContextWithAI(finalContext, options); + } + }, + processButtonLabel + ); + previewModal.open(); + } catch (error) { + this.loadingIndicator?.hide(); + console.error('Failed to preview context:', error); + new Notice('Failed to preview context: ' + error.message); + } + } + + /** + * Save raw context to a note without AI processing + * @param context The gathered context to save + */ + private async saveRawContext(context: GatheredContext): Promise { + try { + this.loadingIndicator?.show('Saving context...'); + + // Ensure OpenAugi folder exists + if (!await this.app.vault.adapter.exists('OpenAugi')) { + await this.app.vault.createFolder('OpenAugi'); + } + + // Generate filename + const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const timeString = new Date().toISOString().split('T')[1].substring(0, 8).replace(/:/g, '-'); + const filename = `Context ${timestamp} ${timeString}`; + const path = `OpenAugi/${filename}.md`; + + // Build content + let content = `# Gathered Context\n\n`; + content += `**Source**: ${context.config.sourceMode}\n`; + content += `**Notes**: ${context.totalNotes}\n`; + content += `**Characters**: ${context.totalCharacters.toLocaleString()}\n`; + content += `**Timestamp**: ${context.timestamp}\n`; + + if (context.config.sourceMode === 'linked-notes') { + content += `**Link Depth**: ${context.config.linkDepth}\n`; + } + + content += `\n## Included Notes\n`; + content += context.notes.map(n => `- [[${n.file.basename}]]`).join('\n'); + content += `\n\n---\n\n`; + content += context.aggregatedContent; + + // Save file + await createFileWithCollisionHandling(this.app.vault, path, content); + + this.loadingIndicator?.hide(); + + new Notice(`Context saved to: ${filename}`); + + // Open the file + await this.openFileInNewTab(path); + } catch (error) { + this.loadingIndicator?.hide(); + console.error('Failed to save raw context:', error); + new Notice('Failed to save context: ' + error.message); + } + } + + /** + * Process context with AI (distill or publish) + * @param context The gathered context + * @param options Command options + */ + private async processContextWithAI( + context: GatheredContext, + options: CommandOptions + ): Promise { + // Show prompt selection modal with processing type option + const promptModal = new PromptSelectionModal( + this.app, + this.settings.promptsFolder, + async (promptConfig: PromptSelectionConfig) => { + await this.executeProcessing(context, promptConfig, options); + }, + true, // Show processing type selector + 'distill' // Default to distill + ); + promptModal.open(); + } + + /** + * Execute AI processing with the selected prompt and processing type + * @param context The gathered context + * @param promptConfig Prompt selection configuration + * @param options Command options + */ + private async executeProcessing( + context: GatheredContext, + promptConfig: PromptSelectionConfig, + options: CommandOptions + ): Promise { try { // Check API key if (!this.settings.apiKey) { @@ -314,90 +540,6 @@ export default class OpenAugiPlugin extends Plugin { return; } - // Show loading indicator - this.loadingIndicator?.show('Discovering recent activity...'); - - // Update services with latest API key - this.openAIService = new OpenAIService(this.settings.apiKey); - this.distillService = new DistillService( - this.app, - this.openAIService, - this.settings - ); - - // Use selected notes if provided, otherwise get all recent notes - let recentFiles: TFile[]; - if (config.selectedNotes && config.selectedNotes.length > 0) { - recentFiles = config.selectedNotes; - } else { - // Fallback to getting all recent notes (shouldn't happen with new UI) - recentFiles = await this.distillService.getRecentlyModifiedNotes( - config.daysBack, - config.excludeFolders, - config.useDateRange ? config.fromDate : undefined, - config.useDateRange ? config.toDate : undefined - ); - } - - if (recentFiles.length === 0 && !config.rootNote) { - this.loadingIndicator?.hide(); - new Notice(`No notes selected for processing`); - return; - } - - // Prepare files list including root note if provided - let allFiles = [...recentFiles]; - if (config.rootNote && !recentFiles.some(f => f.path === config.rootNote!.path)) { - allFiles = [config.rootNote, ...recentFiles]; - } - - // Show notice about discovered files - const message = config.rootNote - ? `Processing ${recentFiles.length} selected notes plus root note: ${config.rootNote.basename}` - : `Processing ${recentFiles.length} selected notes`; - new Notice(message); - - // Update loading message - this.loadingIndicator?.show('Processing recent activity...'); - - // Use appropriate time window for filtering - const timeWindow = config.filterJournalSections ? config.daysBack : 0; - - // Create a synthetic root file for the distillation - let timeWindowDesc: string; - if (config.useDateRange && config.fromDate && config.toDate) { - timeWindowDesc = `from ${config.fromDate} to ${config.toDate}`; - } else { - timeWindowDesc = `in the last ${config.daysBack} days`; - } - - const syntheticRootContent = `# Recent Activity Summary - -This is an automated summary of notes modified ${timeWindowDesc}. -${config.rootNote ? `\nUsing [[${config.rootNote.basename}]] as context root.` : ''} - -## Recently Modified Notes: -${allFiles.map(f => `- [[${f.basename}]]`).join('\n')}`; - - // Ensure OpenAugi folder exists - if (!await this.app.vault.adapter.exists('OpenAugi')) { - await this.app.vault.createFolder('OpenAugi'); - } - - // Create a temporary root file - const tempRootPath = `OpenAugi/temp-recent-activity-${Date.now()}.md`; - await createFileWithCollisionHandling(this.app.vault, tempRootPath, syntheticRootContent); - const tempRootFile = this.app.vault.getAbstractFileByPath(tempRootPath) as TFile; - - // Aggregate content with time filtering - const { content: aggregatedContent, sourceNotes } = await this.distillService.aggregateContent( - allFiles, - timeWindow - ); - - // Combine with synthetic root content - const combinedContent = `# Recent Activity: ${timeWindowDesc}\n\n${syntheticRootContent}\n\n${aggregatedContent}`; - // Read custom prompt if selected let customPrompt: string | undefined; if (promptConfig.useCustomPrompt && promptConfig.selectedPrompt) { @@ -409,46 +551,105 @@ ${allFiles.map(f => `- [[${f.basename}]]`).join('\n')}`; } } - // Distill the recent activity - const distilledData = await this.distillService.distillFromRootNote( - tempRootFile, - combinedContent, - sourceNotes, - undefined, // No time window needed here since we already filtered + const processingType = promptConfig.processingType || 'distill'; + + if (processingType === 'publish') { + await this.executePublish(context, customPrompt, promptConfig.selectedPrompt?.basename || 'default'); + } else { + await this.executeDistill(context, customPrompt); + } + } catch (error) { + this.loadingIndicator?.hide(); + console.error('Failed to process context:', error); + new Notice('Failed to process context: ' + error.message); + } + } + + /** + * Execute distill processing + * @param context The gathered context + * @param customPrompt Optional custom prompt + */ + private async executeDistill( + context: GatheredContext, + customPrompt?: string + ): Promise { + try { + this.loadingIndicator?.show('Distilling content...'); + + // Update services with latest API key and model + this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel()); + this.distillService = new DistillService( + this.app, + this.openAIService, + this.settings + ); + + // Call distill API + const distilledData = await this.openAIService.distillContent( + context.aggregatedContent, customPrompt ); - // Update the distilled data to reflect recent activity - const summaryTimeDesc = config.useDateRange && config.fromDate && config.toDate - ? `${config.fromDate} to ${config.toDate}` - : `Last ${config.daysBack} Days`; - distilledData.summary = `## Recent Activity Summary (${summaryTimeDesc})\n\n${distilledData.summary}`; - - // Remove the temp file from source notes before writing - distilledData.sourceNotes = distilledData.sourceNotes?.filter( - note => !note.includes('temp-recent-activity') - ); + // Add source notes + distilledData.sourceNotes = context.notes.map(n => n.file.basename); - // Write result to files - const summaryPath = await this.fileService.writeDistilledFiles(tempRootFile, distilledData); + // Write files using first note as synthetic root + const syntheticRoot = context.notes[0].file; + const summaryPath = await this.fileService.writeDistilledFiles(syntheticRoot, distilledData); - // Clean up temporary file - await this.app.vault.delete(tempRootFile); - - // Hide loading indicator this.loadingIndicator?.hide(); - // Show success message - new Notice(`Successfully distilled recent activity\nCreated ${distilledData.notes.length} atomic notes`); + new Notice(`Successfully distilled! Created ${distilledData.notes.length} atomic notes`); - // Open the summary file in a new tab + // Open the summary file await this.openFileInNewTab(summaryPath); } catch (error) { - // Hide loading indicator this.loadingIndicator?.hide(); - - console.error('Failed to distill recent activity:', error); - new Notice('Failed to distill recent activity. Check console for details.'); + throw error; + } + } + + /** + * Execute publish processing + * @param context The gathered context + * @param customPrompt Optional custom prompt + * @param promptName Name of the prompt used + */ + private async executePublish( + context: GatheredContext, + customPrompt: string | undefined, + promptName: string + ): Promise { + try { + this.loadingIndicator?.show('Publishing content...'); + + // Update services with latest API key and model + this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel()); + + // Call publish API + const publishedContent = await this.openAIService.publishContent( + context.aggregatedContent, + customPrompt + ); + + // Write published post + const sourceNotes = context.notes.map(n => n.file.basename); + const publishedPath = await this.fileService.writePublishedPost( + publishedContent, + sourceNotes, + promptName + ); + + this.loadingIndicator?.hide(); + + new Notice('Successfully published blog post!'); + + // Open the published file + await this.openFileInNewTab(publishedPath); + } catch (error) { + this.loadingIndicator?.hide(); + throw error; } } } \ No newline at end of file diff --git a/src/services/context-gathering-service.ts b/src/services/context-gathering-service.ts new file mode 100644 index 0000000..b4953c7 --- /dev/null +++ b/src/services/context-gathering-service.ts @@ -0,0 +1,283 @@ +import { App, TFile } from 'obsidian'; +import { DistillService } from './distill-service'; +import { OpenAugiSettings } from '../types/settings'; +import { + ContextGatheringConfig, + DiscoveredNote, + GatheredContext +} from '../types/context'; + +/** + * Service for unified context gathering across all commands + * Handles link traversal, recent activity discovery, and content aggregation + */ +export class ContextGatheringService { + private app: App; + private distillService: DistillService; + private settings: OpenAugiSettings; + + constructor(app: App, distillService: DistillService, settings: OpenAugiSettings) { + this.app = app; + this.distillService = distillService; + this.settings = settings; + } + + /** + * Main entry point: Gather context based on configuration + * @param config The configuration specifying how to gather context + * @returns Complete gathered context with metadata + */ + async gatherContext(config: ContextGatheringConfig): Promise { + let discoveredNotes: DiscoveredNote[]; + + if (config.sourceMode === 'linked-notes') { + if (!config.rootNote) { + throw new Error('Root note required for linked-notes mode'); + } + discoveredNotes = await this.discoverLinkedNotes( + config.rootNote, + config.linkDepth, + config.maxCharacters + ); + } else { + if (!config.timeWindow) { + throw new Error('Time window required for recent-activity mode'); + } + discoveredNotes = await this.discoverRecentNotes( + config.timeWindow, + config.excludeFolders + ); + } + + // Apply folder filtering + discoveredNotes = this.applyFolderFilters(discoveredNotes, config.excludeFolders); + + // Aggregate content from included notes only + const includedNotes = discoveredNotes.filter(n => n.included); + + const aggregatedContent = await this.aggregateContent( + includedNotes, + config.filterRecentSectionsOnly ? config.journalSectionDays : undefined + ); + + return { + notes: discoveredNotes, + aggregatedContent: aggregatedContent.content, + totalCharacters: aggregatedContent.content.length, + totalNotes: includedNotes.length, + config: config, + timestamp: new Date().toISOString() + }; + } + + /** + * Discover notes by traversing links up to specified depth using BFS + * @param rootNote The starting note + * @param maxDepth Maximum depth to traverse (1-3) + * @param maxCharacters Stop when this many characters have been gathered + * @returns Array of discovered notes with metadata + */ + private async discoverLinkedNotes( + rootNote: TFile, + maxDepth: number, + maxCharacters: number + ): Promise { + const discovered = new Map(); + const queue: Array<{ file: TFile; depth: number; via: string }> = []; + + // Start with root note + const rootContent = await this.app.vault.read(rootNote); + discovered.set(rootNote.path, { + file: rootNote, + depth: 0, + discoveredVia: 'root', + estimatedChars: rootContent.length, + included: true + }); + + // Get direct links from root + const rootLinks = await this.distillService.getLinkedNotes(rootNote); + for (const link of rootLinks) { + queue.push({ file: link, depth: 1, via: rootNote.basename }); + } + + // BFS traversal + let totalChars = rootContent.length; + + while (queue.length > 0) { + const { file, depth, via } = queue.shift()!; + + // Skip if already discovered + if (discovered.has(file.path)) { + continue; + } + + // Read content to check size + const content = await this.app.vault.read(file); + const chars = content.length; + + // Check if adding this note would exceed character limit + if (totalChars + chars > maxCharacters) { + // Mark as discovered but not included due to size limit + discovered.set(file.path, { + file, + depth, + discoveredVia: `linked from [[${via}]]`, + estimatedChars: chars, + included: false // Excluded due to size limit + }); + continue; + } + + // Add to discovered and include it + discovered.set(file.path, { + file, + depth, + discoveredVia: `linked from [[${via}]]`, + estimatedChars: chars, + included: true + }); + + totalChars += chars; + + // If we haven't reached max depth, get links from this note + if (depth < maxDepth) { + try { + const linkedNotes = await this.distillService.getLinkedNotes(file); + for (const linkedNote of linkedNotes) { + // Don't queue if already discovered + if (!discovered.has(linkedNote.path)) { + queue.push({ file: linkedNote, depth: depth + 1, via: file.basename }); + } + } + } catch (error) { + console.warn(`Failed to get linked notes from ${file.path}:`, error); + // Continue with other notes even if one fails + } + } + } + + // Convert to array and sort by depth, then by name + return Array.from(discovered.values()).sort((a, b) => { + if (a.depth !== b.depth) return a.depth - b.depth; + return a.file.basename.localeCompare(b.file.basename); + }); + } + + /** + * Discover notes by recent activity + * @param timeWindow Time window configuration + * @param excludeFolders Folders to exclude + * @returns Array of discovered notes + */ + private async discoverRecentNotes( + timeWindow: { mode: string; daysBack?: number; fromDate?: string; toDate?: string }, + excludeFolders: string[] + ): Promise { + const recentFiles = await this.distillService.getRecentlyModifiedNotes( + timeWindow.daysBack || 7, + excludeFolders, + timeWindow.fromDate, + timeWindow.toDate + ); + + const discovered: DiscoveredNote[] = []; + + for (const file of recentFiles) { + const content = await this.app.vault.read(file); + discovered.push({ + file, + depth: 0, // No depth concept for recent activity + discoveredVia: 'recent activity', + estimatedChars: content.length, + included: true + }); + } + + return discovered; + } + + /** + * Apply folder filtering to discovered notes + * @param notes Notes to filter + * @param excludeFolders Folders to exclude + * @returns Filtered notes with updated included property + */ + private applyFolderFilters( + notes: DiscoveredNote[], + excludeFolders: string[] + ): DiscoveredNote[] { + return notes.map(note => { + const isExcluded = excludeFolders.some(folder => + note.file.path.startsWith(folder + '/') || + note.file.path.includes('/' + folder + '/') + ); + + if (isExcluded && note.included) { + return { ...note, included: false }; + } + + return note; + }); + } + + /** + * Aggregate content from notes + * @param notes Notes to aggregate + * @param filterJournalDays Optional days back for journal section filtering + * @returns Aggregated content and source note names + */ + private async aggregateContent( + notes: DiscoveredNote[], + filterJournalDays?: number + ): Promise<{ content: string; sourceNotes: string[] }> { + // Reuse existing aggregateContent from DistillService + const files = notes.map(n => n.file); + return await this.distillService.aggregateContent(files, filterJournalDays); + } + + /** + * Estimate size before gathering (quick estimation for UI) + * @param rootNote Root note for linked-notes mode + * @param mode Source mode + * @param depth Link depth for linked-notes mode + * @returns Estimated note count and character count + */ + async estimateSize( + rootNote: TFile | undefined, + mode: 'linked-notes' | 'recent-activity', + depth?: number + ): Promise<{ noteCount: number; estimatedChars: number }> { + if (mode === 'linked-notes') { + if (!rootNote) { + return { noteCount: 0, estimatedChars: 0 }; + } + + try { + const links = await this.distillService.getLinkedNotes(rootNote); + // Rough estimate: 5000 chars per note average + // For depth > 1, multiply by depth factor + const depthFactor = depth || 1; + const estimatedNoteCount = (links.length * depthFactor) + 1; // +1 for root + const estimated = estimatedNoteCount * 5000; + return { noteCount: estimatedNoteCount, estimatedChars: estimated }; + } catch (error) { + console.warn('Failed to estimate size:', error); + return { noteCount: 0, estimatedChars: 0 }; + } + } else { + // For recent activity, get actual recent files + try { + const recentFiles = await this.distillService.getRecentlyModifiedNotes( + 7, // Default estimate + this.settings.recentActivityDefaults.excludeFolders + ); + const estimated = recentFiles.length * 5000; + return { noteCount: recentFiles.length, estimatedChars: estimated }; + } catch (error) { + console.warn('Failed to estimate size:', error); + return { noteCount: 0, estimatedChars: 0 }; + } + } + } +} diff --git a/src/services/distill-service.ts b/src/services/distill-service.ts index 7cace02..1968fd7 100644 --- a/src/services/distill-service.ts +++ b/src/services/distill-service.ts @@ -469,7 +469,7 @@ ${content} .replace(/MM/g, '\\d{2}') // Replace MM with month pattern .replace(/DD/g, '\\d{2}'); // Replace DD with day pattern - return new RegExp(`^${pattern}\\s*$`, 'm'); + return new RegExp(`^${pattern}`, 'm'); } /** diff --git a/src/services/file-service.ts b/src/services/file-service.ts index 33c8800..f6f60b3 100644 --- a/src/services/file-service.ts +++ b/src/services/file-service.ts @@ -9,12 +9,14 @@ export class FileService { private vault: Vault; private summaryFolder: string; private notesFolder: string; + private publishedFolder: string; private backlinkMapper: BacklinkMapper; - constructor(app: App, summaryFolder: string, notesFolder: string) { + constructor(app: App, summaryFolder: string, notesFolder: string, publishedFolder: string) { this.vault = app.vault; this.summaryFolder = summaryFolder; this.notesFolder = notesFolder; + this.publishedFolder = publishedFolder; this.backlinkMapper = new BacklinkMapper(); } @@ -55,9 +57,10 @@ export class FileService { async ensureDirectoriesExist(): Promise { const dirs = [ this.summaryFolder, - this.notesFolder + this.notesFolder, + this.publishedFolder ]; - + for (const dir of dirs) { const exists = await this.vault.adapter.exists(dir); if (!exists) { @@ -218,4 +221,74 @@ export class FileService { return summaryPath; } + + /** + * Write published post to file + * @param content The published blog post content + * @param sourceNotes Array of source note names + * @param promptName Name of the prompt used (or "default") + * @returns Path to the created file + */ + async writePublishedPost( + content: string, + sourceNotes: string[], + promptName: string = 'default' + ): Promise { + // Ensure published directory exists + await this.ensureDirectoriesExist(); + + // Generate filename with timestamp + const now = new Date(); + const timestamp = now.toISOString().split('T')[0]; // YYYY-MM-DD + + // Try to extract a title from the first heading in the content + const titleMatch = content.match(/^#\s+(.+)$/m); + let titlePart = 'Published Post'; + if (titleMatch && titleMatch[1]) { + titlePart = sanitizeFilename(titleMatch[1]); + // Truncate if too long + if (titlePart.length > 50) { + titlePart = titlePart.substring(0, 50) + '...'; + } + } else if (sourceNotes.length > 0) { + // Fallback to first source note name + titlePart = sanitizeFilename(sourceNotes[0]); + if (titlePart.length > 30) { + titlePart = titlePart.substring(0, 30) + '...'; + } + } + + const filename = `${titlePart} - Published ${timestamp}`; + + // Create frontmatter + const frontmatter = `--- +type: published-post +published_date: ${now.toISOString()} +prompt_used: ${promptName} +status: draft +source_notes: ${sourceNotes.map(n => `[[${n}]]`).join(', ')} +--- + +`; + + // Create footer + const footer = ` + +--- + +*Generated from notes using OpenAugi. Source notes: ${sourceNotes.map(n => `[[${n}]]`).join(', ')}* +`; + + // Combine everything + const fullContent = frontmatter + content + footer; + + // Write file + const filePath = await createFileWithCollisionHandling( + this.vault, + `${this.publishedFolder}/${filename}.md`, + fullContent + ); + + return filePath; + } } \ No newline at end of file diff --git a/src/services/openai-service.ts b/src/services/openai-service.ts index 7c88e5e..029e766 100644 --- a/src/services/openai-service.ts +++ b/src/services/openai-service.ts @@ -1,4 +1,4 @@ -import { TranscriptResponse, DistillResponse } from '../types/transcript'; +import { TranscriptResponse, DistillResponse, PublishResponse } from '../types/transcript'; /** * A simple tokeinzer to estimate the number of tokens @@ -15,9 +15,11 @@ function estimateTokens(text: string): number { */ export class OpenAIService { private apiKey: string; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model: string) { this.apiKey = apiKey; + this.model = model; } /** @@ -130,10 +132,9 @@ export class OpenAIService { 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ - model: 'gpt-4.1-2025-04-14', + model: this.model, messages: [{ role: 'user', content: prompt }], - temperature: 0.2, - max_tokens: 32768, + max_completion_tokens: 32768, response_format: { type: "json_schema", json_schema: { @@ -283,10 +284,9 @@ export class OpenAIService { 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ - model: 'gpt-4.1-2025-04-14', + model: this.model, messages: [{ role: 'user', content: prompt }], - temperature: 0.2, - max_tokens: 32768, + max_completion_tokens: 32768, response_format: { type: "json_schema", json_schema: { @@ -360,4 +360,121 @@ export class OpenAIService { throw error; } } + + /** + * Get the default publishing prompt + * @returns The default prompt for publishing content + */ + private getDefaultPublishPrompt(): string { + return `You are helping transform raw notes into a polished, publishable blog post. + +Take these notes and create ONE cohesive blog post that: + +PRESERVE: +- The author's unique voice and personality +- Direct, conversational tone +- Creative language and specific phrases +- The core insights and ideas + +ADD: +- Why this matters to the reader +- Context where needed (but don't over-explain) +- Natural transitions between ideas +- A clear narrative arc or structure + +FORMAT: +- Short paragraphs (2-4 sentences) +- Use headers/subheaders to organize sections +- Bold key phrases sparingly for emphasis +- Conversational but polished + +TONE: +- Write like you're explaining to a curious friend +- Be direct and honest +- Don't be overly formal or academic +- Let personality shine through + +OUTPUT: +Return a single markdown blog post, ready to publish.`; + } + + /** + * Get the publishing prompt for the OpenAI API + * @param content The aggregated content from notes + * @param customPrompt Optional custom prompt to replace the default + * @returns The formatted prompt + */ + private getPublishPrompt(content: string, customPrompt?: string): string { + // Extract any custom context from the content + const customContext = this.extractCustomContext(content); + + let prompt: string; + + if (customPrompt) { + // Use the custom prompt as the main instructions + prompt = customPrompt; + } else { + // Use the default publishing prompt + prompt = this.getDefaultPublishPrompt(); + } + + // Add custom context if available (this is from the context: section in notes) + if (customContext) { + prompt += `\n\n${customContext}`; + } + + // Add content to publish + prompt += `\n\n# Content to Transform:\n${content}`; + + return prompt; + } + + /** + * Call the OpenAI API to publish content as a single blog post + * @param content The aggregated content from notes + * @param customPrompt Optional custom prompt to replace the default + * @returns Published content as plain markdown + */ + async publishContent(content: string, customPrompt?: string): Promise { + if (!this.apiKey) { + throw new Error('OpenAI API key not set'); + } + + const prompt = this.getPublishPrompt(content, customPrompt); + + 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: this.model, + messages: [{ role: 'user', content: prompt }], + max_completion_tokens: 32768 + }) + }); + + 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(); + + // Check for API refusal + if (responseData.choices[0].message.refusal) { + throw new Error(`API refusal: ${responseData.choices[0].message.refusal}`); + } + + // Get the plain text content + const publishedContent = responseData.choices[0].message.content; + + return publishedContent; + } catch (error) { + console.error('Error calling OpenAI API for publishing:', error); + throw error; + } + } } \ No newline at end of file diff --git a/src/types/context.ts b/src/types/context.ts new file mode 100644 index 0000000..f65df4d --- /dev/null +++ b/src/types/context.ts @@ -0,0 +1,79 @@ +import { TFile } from 'obsidian'; + +/** + * Configuration for context gathering + */ +export interface ContextGatheringConfig { + // Source mode + sourceMode: 'linked-notes' | 'recent-activity'; + rootNote?: TFile; // Required for linked-notes mode + + // Link traversal settings (for linked-notes mode) + linkDepth: number; // 1-3 + maxCharacters: number; // Default: 100000 + + // Time filtering (for recent-activity mode) + timeWindow?: { + mode: 'days-back' | 'date-range'; + daysBack?: number; + fromDate?: string; + toDate?: string; + }; + + // Folder filtering + excludeFolders: string[]; + + // Section filtering for journal-style notes + filterRecentSectionsOnly: boolean; // Uses date header logic from settings + dateHeaderFormat: string; // e.g., "### YYYY-MM-DD" + + // For recent activity: how many days back to filter journal sections + journalSectionDays?: number; +} + +/** + * Represents a discovered note with metadata about how it was found + */ +export interface DiscoveredNote { + file: TFile; + depth: number; // 0 = root, 1 = direct link, 2 = second level, etc. + discoveredVia: string; // "root" | "linked from [[Note]]" | "recent activity" + estimatedChars: number; + included: boolean; // User can toggle in checkbox modal +} + +/** + * The complete gathered context with all metadata + */ +export interface GatheredContext { + notes: DiscoveredNote[]; + aggregatedContent: string; + totalCharacters: number; + totalNotes: number; + config: ContextGatheringConfig; + timestamp: string; +} + +/** + * Options that customize the flow per command + */ +export interface CommandOptions { + commandType: 'distill' | 'publish' | 'save-raw'; + defaultSourceMode: 'linked-notes' | 'recent-activity'; + defaultDepth: number; + skipPreview?: boolean; // For "save raw" command +} + +/** + * Processing type for AI operations + */ +export type ProcessingType = 'distill' | 'publish'; + +/** + * Configuration for processing with AI + */ +export interface ProcessingConfig { + type: ProcessingType; + customPrompt?: string; + customPromptName?: string; +} diff --git a/src/types/settings.ts b/src/types/settings.ts index ad23145..ec3f4d4 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -7,21 +7,34 @@ export interface RecentActivitySettings { dateHeaderFormat: string; } +export interface ContextGatheringDefaults { + linkDepth: number; + maxCharacters: number; + filterRecentSectionsOnly: boolean; +} + export interface OpenAugiSettings { apiKey: string; + defaultModel: string; + customModelOverride: string; summaryFolder: string; notesFolder: string; promptsFolder: string; + publishedFolder: string; useDataviewIfAvailable: boolean; enableDistillLogging: boolean; recentActivityDefaults: RecentActivitySettings; + contextGatheringDefaults: ContextGatheringDefaults; } export const DEFAULT_SETTINGS: OpenAugiSettings = { apiKey: '', + defaultModel: 'gpt-5', + customModelOverride: '', summaryFolder: 'OpenAugi/Summaries', notesFolder: 'OpenAugi/Notes', promptsFolder: 'OpenAugi/Prompts', + publishedFolder: 'OpenAugi/Published', useDataviewIfAvailable: true, enableDistillLogging: false, recentActivityDefaults: { @@ -29,5 +42,10 @@ export const DEFAULT_SETTINGS: OpenAugiSettings = { excludeFolders: ['Templates', 'Archive', 'OpenAugi'], filterJournalSections: true, dateHeaderFormat: '### YYYY-MM-DD' + }, + contextGatheringDefaults: { + linkDepth: 1, + maxCharacters: 100000, + filterRecentSectionsOnly: true } }; \ No newline at end of file diff --git a/src/types/transcript.ts b/src/types/transcript.ts index 0731442..63bdce2 100644 --- a/src/types/transcript.ts +++ b/src/types/transcript.ts @@ -13,4 +13,9 @@ export interface TranscriptResponse extends BaseResponse {} export interface DistillResponse extends BaseResponse { sourceNotes: string[]; // List of source note names that were distilled +} + +export interface PublishResponse { + content: string; // The full blog post markdown + sourceNotes: string[]; // List of source note names that were used } \ No newline at end of file diff --git a/src/ui/context-gathering-modal.ts b/src/ui/context-gathering-modal.ts new file mode 100644 index 0000000..ab1f16a --- /dev/null +++ b/src/ui/context-gathering-modal.ts @@ -0,0 +1,324 @@ +import { App, Modal, Setting } from 'obsidian'; +import { ContextGatheringConfig } from '../types/context'; +import { OpenAugiSettings } from '../types/settings'; +import { ContextGatheringService } from '../services/context-gathering-service'; + +export class ContextGatheringModal extends Modal { + private config: ContextGatheringConfig; + private onSubmit: (config: ContextGatheringConfig) => void; + private settings: OpenAugiSettings; + private contextService: ContextGatheringService; + private estimateEl: HTMLElement; + private modeSpecificContainer: HTMLElement; + + constructor( + app: App, + settings: OpenAugiSettings, + contextService: ContextGatheringService, + onSubmit: (config: ContextGatheringConfig) => void, + defaultSourceMode: 'linked-notes' | 'recent-activity' = 'linked-notes', + defaultDepth: number = 1 + ) { + super(app); + this.settings = settings; + this.contextService = contextService; + this.onSubmit = onSubmit; + + // Initialize with defaults + this.config = { + sourceMode: defaultSourceMode, + rootNote: app.workspace.getActiveFile() || undefined, + linkDepth: defaultDepth, + maxCharacters: settings.contextGatheringDefaults.maxCharacters, + excludeFolders: settings.recentActivityDefaults.excludeFolders, + filterRecentSectionsOnly: settings.contextGatheringDefaults.filterRecentSectionsOnly, + dateHeaderFormat: settings.recentActivityDefaults.dateHeaderFormat, + journalSectionDays: settings.recentActivityDefaults.daysBack + }; + } + + async onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('openaugi-context-modal'); + + contentEl.createEl('h2', { text: 'Gather Context' }); + + contentEl.createEl('p', { + text: 'Configure how to discover and gather notes for processing.', + cls: 'setting-item-description' + }); + + // Source mode selection + new Setting(contentEl) + .setName('Source') + .setDesc('How to discover notes') + .addDropdown(dropdown => { + dropdown + .addOption('linked-notes', 'Linked notes from current note') + .addOption('recent-activity', 'Recently modified notes') + .setValue(this.config.sourceMode) + .onChange(async (value: 'linked-notes' | 'recent-activity') => { + this.config.sourceMode = value; + await this.renderModeSpecificSettings(); + await this.updateEstimate(); + }); + }); + + // Container for mode-specific settings + this.modeSpecificContainer = contentEl.createDiv({ cls: 'mode-specific-settings' }); + await this.renderModeSpecificSettings(); + + // Folder filtering + new Setting(contentEl) + .setName('Exclude folders') + .setDesc('Comma-separated list of folders to exclude') + .addText(text => { + text + .setPlaceholder('Templates, Archive, OpenAugi') + .setValue(this.config.excludeFolders.join(', ')) + .onChange(async value => { + this.config.excludeFolders = value + .split(',') + .map(f => f.trim()) + .filter(f => f.length > 0); + await this.updateEstimate(); + }); + }); + + // Recent sections filtering + new Setting(contentEl) + .setName('Recent sections only') + .setDesc('For journal-style notes with date headers, only include recent sections') + .addToggle(toggle => { + toggle + .setValue(this.config.filterRecentSectionsOnly) + .onChange(async value => { + this.config.filterRecentSectionsOnly = value; + if (value) { + await this.showJournalDaysInput(); + } + }); + }); + + // Journal days input (conditional) + if (this.config.filterRecentSectionsOnly) { + await this.showJournalDaysInput(); + } + + // Estimate display + this.estimateEl = contentEl.createDiv({ cls: 'context-estimate' }); + this.estimateEl.style.padding = '10px'; + this.estimateEl.style.marginTop = '10px'; + this.estimateEl.style.backgroundColor = 'var(--background-secondary)'; + this.estimateEl.style.borderRadius = '5px'; + await this.updateEstimate(); + + // Action buttons + new Setting(contentEl) + .addButton(button => button + .setButtonText('Cancel') + .onClick(() => this.close()) + ) + .addButton(button => button + .setButtonText('Discover Notes') + .setCta() + .onClick(() => { + this.onSubmit(this.config); + this.close(); + }) + ); + } + + private async renderModeSpecificSettings() { + this.modeSpecificContainer.empty(); + + if (this.config.sourceMode === 'linked-notes') { + this.renderLinkedNotesSettings(); + } else { + this.renderRecentActivitySettings(); + } + } + + private renderLinkedNotesSettings() { + // Root note display + if (this.config.rootNote) { + new Setting(this.modeSpecificContainer) + .setName('Root note') + .setDesc('Starting note for link traversal') + .addText(text => { + text + .setValue(this.config.rootNote!.basename) + .setDisabled(true); + }); + } else { + const noNoteEl = this.modeSpecificContainer.createDiv(); + noNoteEl.style.padding = '10px'; + noNoteEl.style.color = 'var(--text-error)'; + noNoteEl.setText('⚠️ No active note. Please open a note first.'); + } + + // Link depth slider + new Setting(this.modeSpecificContainer) + .setName('Link depth') + .setDesc('How many levels of links to traverse (1-3)') + .addSlider(slider => { + slider + .setLimits(1, 3, 1) + .setValue(this.config.linkDepth) + .setDynamicTooltip() + .onChange(async value => { + this.config.linkDepth = value; + await this.updateEstimate(); + }); + }) + .addExtraButton(button => { + button.setIcon('info'); + button.setTooltip('Depth 1: direct links only\nDepth 2: links of links\nDepth 3: three levels deep'); + }); + + // Character limit + new Setting(this.modeSpecificContainer) + .setName('Max characters') + .setDesc('Stop gathering when this many characters collected') + .addText(text => { + text + .setPlaceholder('100000') + .setValue(String(this.config.maxCharacters)) + .onChange(async value => { + const num = parseInt(value); + if (!isNaN(num) && num > 0) { + this.config.maxCharacters = num; + await this.updateEstimate(); + } + }); + }); + } + + private renderRecentActivitySettings() { + // Initialize time window if not set + if (!this.config.timeWindow) { + this.config.timeWindow = { + mode: 'days-back', + daysBack: this.settings.recentActivityDefaults.daysBack + }; + } + + // Time window mode + new Setting(this.modeSpecificContainer) + .setName('Time window') + .setDesc('How to select recent notes') + .addDropdown(dropdown => { + dropdown + .addOption('days-back', 'Last N days') + .addOption('date-range', 'Specific date range') + .setValue(this.config.timeWindow!.mode) + .onChange(async (value: 'days-back' | 'date-range') => { + this.config.timeWindow!.mode = value; + await this.renderModeSpecificSettings(); + await this.updateEstimate(); + }); + }); + + // Days back or date range inputs + if (this.config.timeWindow.mode === 'days-back') { + new Setting(this.modeSpecificContainer) + .setName('Days back') + .setDesc('Number of days to look back') + .addText(text => { + text + .setPlaceholder('7') + .setValue(String(this.config.timeWindow!.daysBack || 7)) + .onChange(async value => { + const days = parseInt(value); + if (!isNaN(days) && days > 0) { + this.config.timeWindow!.daysBack = days; + await this.updateEstimate(); + } + }); + }); + } else { + // Date range inputs + new Setting(this.modeSpecificContainer) + .setName('From date') + .setDesc('Start date (YYYY-MM-DD)') + .addText(text => { + text + .setPlaceholder('YYYY-MM-DD') + .setValue(this.config.timeWindow!.fromDate || '') + .onChange(async value => { + this.config.timeWindow!.fromDate = value; + await this.updateEstimate(); + }); + }); + + new Setting(this.modeSpecificContainer) + .setName('To date') + .setDesc('End date (YYYY-MM-DD)') + .addText(text => { + text + .setPlaceholder('YYYY-MM-DD') + .setValue(this.config.timeWindow!.toDate || '') + .onChange(async value => { + this.config.timeWindow!.toDate = value; + await this.updateEstimate(); + }); + }); + } + } + + private async showJournalDaysInput() { + // Check if input already exists + const existingInput = this.contentEl.querySelector('.journal-days-setting'); + if (existingInput) { + return; + } + + new Setting(this.contentEl) + .setName('Journal sections days back') + .setDesc('For journal filtering, how many days back to include sections') + .setClass('journal-days-setting') + .addText(text => { + text + .setPlaceholder('7') + .setValue(String(this.config.journalSectionDays || 7)) + .onChange(value => { + const days = parseInt(value); + if (!isNaN(days) && days > 0) { + this.config.journalSectionDays = days; + } + }); + }); + } + + private async updateEstimate() { + if (!this.estimateEl) { + return; + } + + if (!this.config.rootNote && this.config.sourceMode === 'linked-notes') { + this.estimateEl.setText('⚠️ No active note selected'); + return; + } + + try { + const estimate = await this.contextService.estimateSize( + this.config.rootNote, + this.config.sourceMode, + this.config.linkDepth + ); + + this.estimateEl.setText( + `📊 Estimated: ${estimate.noteCount} notes, ~${estimate.estimatedChars.toLocaleString()} characters` + ); + } catch (error) { + console.warn('Failed to estimate size:', error); + this.estimateEl.setText('⚠️ Unable to estimate size'); + } + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/ui/context-preview-modal.ts b/src/ui/context-preview-modal.ts new file mode 100644 index 0000000..d797243 --- /dev/null +++ b/src/ui/context-preview-modal.ts @@ -0,0 +1,155 @@ +import { App, Modal, Setting } from 'obsidian'; +import { GatheredContext } from '../types/context'; + +export class ContextPreviewModal extends Modal { + private context: GatheredContext; + private onSaveRaw: () => void; + private onProcess: () => void; + private processButtonLabel: string; + + constructor( + app: App, + context: GatheredContext, + onSaveRaw: () => void, + onProcess: () => void, + processButtonLabel: string = 'Process with AI' + ) { + super(app); + this.context = context; + this.onSaveRaw = onSaveRaw; + this.onProcess = onProcess; + this.processButtonLabel = processButtonLabel; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('openaugi-preview-modal'); + + contentEl.createEl('h2', { text: 'Context Preview' }); + + contentEl.createEl('p', { + text: 'Review the gathered context before processing or saving.', + cls: 'setting-item-description' + }); + + // Summary stats + const statsEl = contentEl.createDiv({ cls: 'context-stats' }); + statsEl.style.padding = '15px'; + statsEl.style.marginBottom = '15px'; + statsEl.style.backgroundColor = 'var(--background-secondary)'; + statsEl.style.borderRadius = '5px'; + + const statsTitle = statsEl.createEl('h3', { text: '📊 Summary' }); + statsTitle.style.marginTop = '0'; + statsTitle.style.marginBottom = '10px'; + + statsEl.createEl('p', { + text: `Notes: ${this.context.totalNotes} notes` + }); + statsEl.createEl('p', { + text: `Characters: ${this.context.totalCharacters.toLocaleString()}` + }); + statsEl.createEl('p', { + text: `Estimated tokens: ~${Math.ceil(this.context.totalCharacters / 4).toLocaleString()}` + }); + statsEl.createEl('p', { + text: `Source: ${this.context.config.sourceMode === 'linked-notes' ? 'Linked notes' : 'Recent activity'}` + }); + + if (this.context.config.sourceMode === 'linked-notes') { + statsEl.createEl('p', { + text: `Link depth: ${this.context.config.linkDepth}` + }); + } + + // List of included notes + const notesListEl = contentEl.createDiv({ cls: 'notes-list' }); + notesListEl.style.marginBottom = '15px'; + + notesListEl.createEl('h3', { text: '📝 Included Notes' }); + + const listEl = notesListEl.createEl('ul'); + listEl.style.maxHeight = '150px'; + listEl.style.overflowY = 'auto'; + listEl.style.padding = '10px'; + listEl.style.margin = '0'; + listEl.style.backgroundColor = 'var(--background-primary-alt)'; + listEl.style.borderRadius = '5px'; + listEl.style.listStyle = 'none'; + + this.context.notes.forEach(note => { + const itemEl = listEl.createEl('li'); + itemEl.style.padding = '5px'; + itemEl.style.borderBottom = '1px solid var(--background-modifier-border)'; + + const titleEl = itemEl.createEl('span'); + titleEl.setText(note.file.basename); + titleEl.style.fontWeight = '500'; + + if (note.depth > 0) { + const depthBadge = itemEl.createEl('span'); + depthBadge.setText(` (L${note.depth})`); + depthBadge.style.fontSize = '0.85em'; + depthBadge.style.color = 'var(--text-muted)'; + depthBadge.style.marginLeft = '5px'; + } + + const sizeEl = itemEl.createEl('span'); + sizeEl.setText(` · ${(note.estimatedChars / 1000).toFixed(1)}k chars`); + sizeEl.style.fontSize = '0.85em'; + sizeEl.style.color = 'var(--text-muted)'; + sizeEl.style.marginLeft = '5px'; + }); + + // Content preview (first 1000 chars) + const previewEl = contentEl.createDiv({ cls: 'content-preview' }); + previewEl.style.marginBottom = '20px'; + + previewEl.createEl('h3', { text: '👁️ Content Preview' }); + + const preText = previewEl.createEl('pre'); + preText.style.maxHeight = '200px'; + preText.style.overflowY = 'auto'; + preText.style.padding = '10px'; + preText.style.backgroundColor = 'var(--background-primary-alt)'; + preText.style.border = '1px solid var(--background-modifier-border)'; + preText.style.borderRadius = '5px'; + preText.style.fontSize = '0.9em'; + preText.style.whiteSpace = 'pre-wrap'; + preText.style.wordWrap = 'break-word'; + + const preview = this.context.aggregatedContent.substring(0, 1000); + const hasMore = this.context.aggregatedContent.length > 1000; + preText.setText(preview + (hasMore ? '\n\n...(truncated)' : '')); + + // Action buttons + new Setting(contentEl) + .addButton(button => button + .setButtonText('Back') + .onClick(() => this.close()) + ) + .addButton(button => button + .setButtonText('Save Raw Context') + .setTooltip('Save the gathered context as a note without AI processing') + .onClick(() => { + this.onSaveRaw(); + this.close(); + }) + ) + .addButton(button => button + .setButtonText(this.processButtonLabel) + .setCta() + .setTooltip('Continue to process this context with AI') + .onClick(() => { + this.onProcess(); + this.close(); + }) + ); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/ui/context-selection-modal.ts b/src/ui/context-selection-modal.ts new file mode 100644 index 0000000..0eb5b9e --- /dev/null +++ b/src/ui/context-selection-modal.ts @@ -0,0 +1,227 @@ +import { App, Modal, Setting } from 'obsidian'; +import { DiscoveredNote } from '../types/context'; + +export class ContextSelectionModal extends Modal { + private discoveredNotes: DiscoveredNote[]; + private onSubmit: (selectedNotes: DiscoveredNote[]) => void; + private checkboxStates: Map; + private summaryEl: HTMLElement; + + constructor( + app: App, + discoveredNotes: DiscoveredNote[], + onSubmit: (selectedNotes: DiscoveredNote[]) => void + ) { + super(app); + this.discoveredNotes = discoveredNotes; + this.onSubmit = onSubmit; + this.checkboxStates = new Map(); + + // Initialize checkbox states from included property + discoveredNotes.forEach(note => { + this.checkboxStates.set(note.file.path, note.included); + }); + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('openaugi-selection-modal'); + + contentEl.createEl('h2', { text: 'Select Notes to Include' }); + + contentEl.createEl('p', { + text: 'Review and select which notes to include in the gathered context.', + cls: 'setting-item-description' + }); + + // Summary stats + this.summaryEl = contentEl.createDiv({ cls: 'selection-summary' }); + this.summaryEl.style.padding = '10px'; + this.summaryEl.style.marginBottom = '15px'; + this.summaryEl.style.backgroundColor = 'var(--background-secondary)'; + this.summaryEl.style.borderRadius = '5px'; + this.updateSummary(); + + // Select all / Deselect all buttons + new Setting(contentEl) + .setName('Quick actions') + .addButton(button => button + .setButtonText('Select All') + .onClick(() => { + this.discoveredNotes.forEach(note => { + this.checkboxStates.set(note.file.path, true); + }); + this.renderNoteList(); + }) + ) + .addButton(button => button + .setButtonText('Deselect All') + .onClick(() => { + this.discoveredNotes.forEach(note => { + this.checkboxStates.set(note.file.path, false); + }); + this.renderNoteList(); + }) + ); + + // Scrollable list of checkboxes + const listContainer = contentEl.createDiv({ cls: 'note-list-container' }); + listContainer.style.maxHeight = '400px'; + listContainer.style.overflowY = 'auto'; + listContainer.style.border = '1px solid var(--background-modifier-border)'; + listContainer.style.padding = '10px'; + listContainer.style.marginBottom = '20px'; + listContainer.style.borderRadius = '5px'; + + this.renderNoteListInContainer(listContainer); + + // Action buttons + const totalSelected = Array.from(this.checkboxStates.values()).filter(v => v).length; + + new Setting(contentEl) + .addButton(button => button + .setButtonText('Back') + .onClick(() => this.close()) + ) + .addButton(button => button + .setButtonText('Continue') + .setCta() + .setDisabled(totalSelected === 0) + .onClick(() => { + // Update included property based on checkboxes + this.discoveredNotes.forEach(note => { + note.included = this.checkboxStates.get(note.file.path) || false; + }); + this.onSubmit(this.discoveredNotes.filter(n => n.included)); + this.close(); + }) + ); + } + + private renderNoteList() { + // Find and re-render the list container + const listContainer = this.contentEl.querySelector('.note-list-container'); + if (listContainer) { + listContainer.empty(); + this.renderNoteListInContainer(listContainer as HTMLElement); + } + this.updateSummary(); + } + + private renderNoteListInContainer(listContainer: HTMLElement) { + // Group by depth for linked notes + const byDepth = new Map(); + this.discoveredNotes.forEach(note => { + if (!byDepth.has(note.depth)) { + byDepth.set(note.depth, []); + } + byDepth.get(note.depth)!.push(note); + }); + + // Render notes grouped by depth + const sortedDepths = Array.from(byDepth.keys()).sort((a, b) => a - b); + + sortedDepths.forEach(depth => { + const notes = byDepth.get(depth)!; + + // Depth header + if (sortedDepths.length > 1 && depth > 0) { + const depthHeader = listContainer.createEl('div', { + cls: 'depth-header', + text: `📁 Level ${depth}` + }); + depthHeader.style.fontWeight = 'bold'; + depthHeader.style.marginTop = depth > 0 ? '15px' : '0'; + depthHeader.style.marginBottom = '5px'; + depthHeader.style.color = 'var(--text-muted)'; + } else if (depth === 0 && sortedDepths.length > 1) { + const depthHeader = listContainer.createEl('div', { + cls: 'depth-header', + text: '📄 Root Note' + }); + depthHeader.style.fontWeight = 'bold'; + depthHeader.style.marginBottom = '5px'; + depthHeader.style.color = 'var(--text-muted)'; + } + + // Notes at this depth + notes.forEach(note => { + const noteEl = listContainer.createDiv({ cls: 'note-item' }); + noteEl.style.display = 'flex'; + noteEl.style.alignItems = 'center'; + noteEl.style.padding = '8px'; + noteEl.style.marginLeft = `${depth * 20}px`; // Indent by depth + noteEl.style.borderRadius = '3px'; + noteEl.style.cursor = 'pointer'; + + // Hover effect + noteEl.addEventListener('mouseenter', () => { + noteEl.style.backgroundColor = 'var(--background-secondary-alt)'; + }); + noteEl.addEventListener('mouseleave', () => { + noteEl.style.backgroundColor = 'transparent'; + }); + + const checkbox = noteEl.createEl('input', { type: 'checkbox' }); + checkbox.checked = this.checkboxStates.get(note.file.path) || false; + checkbox.style.marginRight = '10px'; + checkbox.style.cursor = 'pointer'; + checkbox.addEventListener('change', () => { + this.checkboxStates.set(note.file.path, checkbox.checked); + this.updateSummary(); + }); + + // Make the whole row clickable + noteEl.addEventListener('click', (e) => { + if (e.target !== checkbox) { + checkbox.checked = !checkbox.checked; + this.checkboxStates.set(note.file.path, checkbox.checked); + this.updateSummary(); + } + }); + + const contentDiv = noteEl.createDiv(); + contentDiv.style.flex = '1'; + contentDiv.style.display = 'flex'; + contentDiv.style.flexDirection = 'column'; + contentDiv.style.gap = '2px'; + + const titleEl = contentDiv.createEl('span'); + titleEl.setText(note.file.basename); + titleEl.style.fontWeight = '500'; + + const metaEl = contentDiv.createEl('span'); + metaEl.style.fontSize = '0.85em'; + metaEl.style.color = 'var(--text-muted)'; + const sizeKb = (note.estimatedChars / 1000).toFixed(1); + metaEl.setText(`${sizeKb}k chars · ${note.discoveredVia}`); + }); + }); + + // Show message if no notes + if (this.discoveredNotes.length === 0) { + const emptyEl = listContainer.createEl('div'); + emptyEl.style.textAlign = 'center'; + emptyEl.style.padding = '20px'; + emptyEl.style.color = 'var(--text-muted)'; + emptyEl.setText('No notes discovered'); + } + } + + private updateSummary() { + const totalSelected = Array.from(this.checkboxStates.values()).filter(v => v).length; + const totalChars = this.discoveredNotes + .filter(n => this.checkboxStates.get(n.file.path)) + .reduce((sum, n) => sum + n.estimatedChars, 0); + + this.summaryEl.setText( + `✓ Selected: ${totalSelected} of ${this.discoveredNotes.length} notes (${totalChars.toLocaleString()} characters, ~${Math.ceil(totalChars / 4).toLocaleString()} tokens)` + ); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/ui/prompt-selection-modal.ts b/src/ui/prompt-selection-modal.ts index 64ae0ed..5259569 100644 --- a/src/ui/prompt-selection-modal.ts +++ b/src/ui/prompt-selection-modal.ts @@ -1,8 +1,10 @@ import { App, Modal, Setting, TFile } from 'obsidian'; +import { ProcessingType } from '../types/context'; export interface PromptSelectionConfig { selectedPrompt?: TFile; useCustomPrompt: boolean; + processingType?: ProcessingType; // 'distill' or 'publish' } export class PromptSelectionModal extends Modal { @@ -10,29 +12,52 @@ export class PromptSelectionModal extends Modal { private onSubmit: (config: PromptSelectionConfig) => void; private promptsFolder: string; private availablePrompts: TFile[] = []; + private showProcessingType: boolean; constructor( - app: App, + app: App, promptsFolder: string, - onSubmit: (config: PromptSelectionConfig) => void + onSubmit: (config: PromptSelectionConfig) => void, + showProcessingType: boolean = false, + defaultProcessingType: ProcessingType = 'distill' ) { super(app); this.promptsFolder = promptsFolder; - this.config = { useCustomPrompt: false }; + this.config = { + useCustomPrompt: false, + processingType: defaultProcessingType + }; this.onSubmit = onSubmit; + this.showProcessingType = showProcessingType; } async onOpen() { const { contentEl } = this; contentEl.empty(); - contentEl.createEl('h2', { text: 'Select Processing Lens' }); - - contentEl.createEl('p', { - text: 'Choose a custom prompt to guide how the content is processed, or use the default prompt.', - cls: 'openaugi-prompt-description' + contentEl.createEl('h2', { text: 'Process Context' }); + + contentEl.createEl('p', { + text: 'Configure how to process the gathered context with AI.', + cls: 'setting-item-description' }); + // Processing type selection (if enabled) + if (this.showProcessingType) { + new Setting(contentEl) + .setName('Output format') + .setDesc('How to process the context') + .addDropdown(dropdown => { + dropdown + .addOption('distill', 'Distill to atomic notes') + .addOption('publish', 'Publish as single post') + .setValue(this.config.processingType || 'distill') + .onChange((value: ProcessingType) => { + this.config.processingType = value; + }); + }); + } + // Load available prompts await this.loadAvailablePrompts(); diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts index ab52725..1e3d219 100644 --- a/src/ui/settings-tab.ts +++ b/src/ui/settings-tab.ts @@ -34,6 +34,32 @@ export class OpenAugiSettingTab extends PluginSettingTab { } }) ); + + new Setting(containerEl) + .setName('OpenAI Model') + .setDesc('Select the OpenAI model to use for processing') + .addDropdown(dropdown => dropdown + .addOption('gpt-5', 'GPT-5') + .addOption('gpt-5-mini', 'GPT-5 Mini') + .addOption('gpt-5-nano', 'GPT-5 Nano') + .setValue(this.plugin.settings.defaultModel) + .onChange(async (value) => { + this.plugin.settings.defaultModel = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Custom Model Override (Optional)') + .setDesc('Specify any OpenAI model name to override the selection above. Leave empty to use the selected model.') + .addText(text => text + .setPlaceholder('e.g., gpt-4o-2024-11-20') + .setValue(this.plugin.settings.customModelOverride) + .onChange(async (value) => { + this.plugin.settings.customModelOverride = value; + await this.plugin.saveSettings(); + }) + ); new Setting(containerEl) .setName('Summaries folder') @@ -86,7 +112,7 @@ export class OpenAugiSettingTab extends PluginSettingTab { text .setPlaceholder('OpenAugi/Prompts') .setValue(this.plugin.settings.promptsFolder); - + // Save only when input loses focus text.inputEl.addEventListener('blur', async () => { const value = text.getValue(); @@ -95,7 +121,29 @@ export class OpenAugiSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); } }); - + + return text; + }); + + new Setting(containerEl) + .setName('Published folder') + .setDesc('Folder path where published blog posts will be saved') + .addText(text => { + text + .setPlaceholder('OpenAugi/Published') + .setValue(this.plugin.settings.publishedFolder); + + // Save only when input loses focus + text.inputEl.addEventListener('blur', async () => { + const value = text.getValue(); + if (value !== this.plugin.settings.publishedFolder) { + this.plugin.settings.publishedFolder = value; + await this.plugin.saveSettings(); + // Ensure directories exist after folder path changes + await this.plugin.fileService.ensureDirectoriesExist(); + } + }); + return text; }); @@ -175,6 +223,51 @@ export class OpenAugiSettingTab extends PluginSettingTab { }) ); + // Context Gathering Settings Header + containerEl.createEl('h3', { text: 'Context Gathering Settings' }); + + new Setting(containerEl) + .setName('Default link depth') + .setDesc('Default depth for link traversal (1-3)') + .addSlider(slider => slider + .setLimits(1, 3, 1) + .setValue(this.plugin.settings.contextGatheringDefaults.linkDepth) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.contextGatheringDefaults.linkDepth = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Default max characters') + .setDesc('Default maximum characters to gather') + .addText(text => text + .setPlaceholder('100000') + .setValue(String(this.plugin.settings.contextGatheringDefaults.maxCharacters)) + .onChange(async (value) => { + const num = parseInt(value); + if (!isNaN(num) && num > 0) { + this.plugin.settings.contextGatheringDefaults.maxCharacters = num; + await this.plugin.saveSettings(); + } + }) + ); + + new Setting(containerEl) + .setName('Filter recent sections by default') + .setDesc('For journal-style notes, only include recent sections by default') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.contextGatheringDefaults.filterRecentSectionsOnly) + .onChange(async (value) => { + this.plugin.settings.contextGatheringDefaults.filterRecentSectionsOnly = value; + await this.plugin.saveSettings(); + }) + ); + + // Advanced Settings Header + containerEl.createEl('h3', { text: 'Advanced Settings' }); + new Setting(containerEl) .setName('Enable distill logging') .setDesc('Log the full input context sent to AI when distilling notes. Logs are saved to OpenAugi/Logs folder.')