mirror of
https://github.com/bitsofchris/openaugi-obsidian-plugin.git
synced 2026-07-22 12:40:27 +00:00
update context gathering flow
This commit is contained in:
parent
bcf62e4ab5
commit
aeb30ecb90
13 changed files with 1881 additions and 19 deletions
119
README.md
119
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.
|
||||
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
383
src/main.ts
383
src/main.ts
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
283
src/services/context-gathering-service.ts
Normal file
283
src/services/context-gathering-service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
79
src/types/context.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
324
src/ui/context-gathering-modal.ts
Normal file
324
src/ui/context-gathering-modal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
155
src/ui/context-preview-modal.ts
Normal file
155
src/ui/context-preview-modal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
227
src/ui/context-selection-modal.ts
Normal file
227
src/ui/context-selection-modal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
Loading…
Reference in a new issue