update context gathering flow

This commit is contained in:
Chris Lettieri 2025-10-13 14:22:29 -04:00
parent bcf62e4ab5
commit aeb30ecb90
13 changed files with 1881 additions and 19 deletions

119
README.md
View file

@ -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.
@ -285,8 +392,16 @@ 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:

View file

@ -3,11 +3,16 @@ 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 +29,7 @@ export default class OpenAugiPlugin extends Plugin {
openAIService: OpenAIService;
fileService: FileService;
distillService: DistillService;
contextGatheringService: ContextGatheringService;
loadingIndicator: LoadingIndicator;
async onload() {
@ -78,6 +84,44 @@ export default class OpenAugiPlugin extends Plugin {
}
});
// Add new unified context gathering commands
this.addCommand({
id: 'openaugi-process-notes',
name: 'Process notes',
callback: async () => {
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
});
}
});
// Add settings tab
this.addSettingTab(new OpenAugiSettingTab(this.app, this));
}
@ -88,15 +132,21 @@ export default class OpenAugiPlugin extends Plugin {
private initializeServices(): void {
this.openAIService = new OpenAIService(this.settings.apiKey);
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
);
}
/**
@ -451,4 +501,331 @@ ${allFiles.map(f => `- [[${f.basename}]]`).join('\n')}`;
new Notice('Failed to distill recent activity. Check console for details.');
}
}
/**
* Main orchestration method for unified context gathering and processing
* @param options Options specifying the command type and defaults
*/
private async gatherAndProcessContext(options: CommandOptions): Promise<void> {
// Step 1: Show context gathering configuration modal
const configModal = new ContextGatheringModal(
this.app,
this.settings,
this.contextGatheringService,
async (config: ContextGatheringConfig) => {
await this.executeContextGathering(config, options);
},
options.defaultSourceMode,
options.defaultDepth
);
configModal.open();
}
/**
* Execute context gathering with the given configuration
* @param config Context gathering configuration
* @param options Command options
*/
private async executeContextGathering(
config: ContextGatheringConfig,
options: CommandOptions
): Promise<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
try {
// Check API key
if (!this.settings.apiKey) {
new Notice('Please set your OpenAI API key in the settings');
return;
}
// Read custom prompt if selected
let customPrompt: string | undefined;
if (promptConfig.useCustomPrompt && promptConfig.selectedPrompt) {
try {
customPrompt = await this.app.vault.read(promptConfig.selectedPrompt);
} catch (error) {
console.error('Failed to read custom prompt:', error);
new Notice('Failed to read custom prompt, using default');
}
}
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<void> {
try {
this.loadingIndicator?.show('Distilling content...');
// Update services with latest API key
this.openAIService = new OpenAIService(this.settings.apiKey);
this.distillService = new DistillService(
this.app,
this.openAIService,
this.settings
);
// Call distill API
const distilledData = await this.openAIService.distillContent(
context.aggregatedContent,
customPrompt
);
// Add source notes
distilledData.sourceNotes = context.notes.map(n => n.file.basename);
// Write files using first note as synthetic root
const syntheticRoot = context.notes[0].file;
const summaryPath = await this.fileService.writeDistilledFiles(syntheticRoot, distilledData);
this.loadingIndicator?.hide();
new Notice(`Successfully distilled! Created ${distilledData.notes.length} atomic notes`);
// Open the summary file
await this.openFileInNewTab(summaryPath);
} catch (error) {
this.loadingIndicator?.hide();
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<void> {
try {
this.loadingIndicator?.show('Publishing content...');
// Update services with latest API key
this.openAIService = new OpenAIService(this.settings.apiKey);
// 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;
}
}
}

View file

@ -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<GatheredContext> {
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<DiscoveredNote[]> {
const discovered = new Map<string, DiscoveredNote>();
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<DiscoveredNote[]> {
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 };
}
}
}
}

View file

@ -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<void> {
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<string> {
// 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;
}
}

View file

@ -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
@ -360,4 +360,122 @@ 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<string> {
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: 'gpt-4.1-2025-04-14',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7, // Higher temperature for more creative output
max_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;
}
}
}

79
src/types/context.ts Normal file
View file

@ -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;
}

View file

@ -7,14 +7,22 @@ export interface RecentActivitySettings {
dateHeaderFormat: string;
}
export interface ContextGatheringDefaults {
linkDepth: number;
maxCharacters: number;
filterRecentSectionsOnly: boolean;
}
export interface OpenAugiSettings {
apiKey: string;
summaryFolder: string;
notesFolder: string;
promptsFolder: string;
publishedFolder: string;
useDataviewIfAvailable: boolean;
enableDistillLogging: boolean;
recentActivityDefaults: RecentActivitySettings;
contextGatheringDefaults: ContextGatheringDefaults;
}
export const DEFAULT_SETTINGS: OpenAugiSettings = {
@ -22,6 +30,7 @@ export const DEFAULT_SETTINGS: OpenAugiSettings = {
summaryFolder: 'OpenAugi/Summaries',
notesFolder: 'OpenAugi/Notes',
promptsFolder: 'OpenAugi/Prompts',
publishedFolder: 'OpenAugi/Published',
useDataviewIfAvailable: true,
enableDistillLogging: false,
recentActivityDefaults: {
@ -29,5 +38,10 @@ export const DEFAULT_SETTINGS: OpenAugiSettings = {
excludeFolders: ['Templates', 'Archive', 'OpenAugi'],
filterJournalSections: true,
dateHeaderFormat: '### YYYY-MM-DD'
},
contextGatheringDefaults: {
linkDepth: 1,
maxCharacters: 100000,
filterRecentSectionsOnly: true
}
};

View file

@ -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
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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<string, boolean>;
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<number, DiscoveredNote[]>();
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();
}
}

View file

@ -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();

View file

@ -86,7 +86,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 +95,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 +197,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.')