Merge pull request #3 from bitsofchris/custom-prompts

distill recent notes, file name collision, interactive context selection
This commit is contained in:
Chris Lettieri 2025-07-28 07:55:03 -04:00 committed by GitHub
commit 3857b1ab07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 4426 additions and 58 deletions

144
CLAUDE.md Normal file
View file

@ -0,0 +1,144 @@
# OpenAugi Obsidian Plugin - Technical Overview
## Project Purpose
OpenAugi is an Obsidian plugin that transforms voice notes and linked notes into organized, atomic notes using AI (GPT-4). It helps users process unstructured thoughts into a structured "second brain" by breaking down content into self-contained ideas.
## Architecture Overview
### Core Technologies
- **Language**: TypeScript
- **Build Tool**: esbuild
- **Target Platform**: Obsidian (Electron-based)
- **AI Service**: OpenAI GPT-4.1-2025-04-14
- **Node Version**: 18+
### Project Structure
```
/
├── src/
│ ├── main.ts # Plugin entry point, command registration
│ ├── services/
│ │ ├── openai.service.ts # AI processing logic
│ │ ├── file.service.ts # File operations, output management
│ │ └── distill.service.ts # Linked note extraction, content aggregation
│ ├── ui/
│ │ └── settings.ts # Settings tab UI component
│ └── utils/
│ └── filename.utils.ts # Filename sanitization, backlink mapping
├── manifest.json # Obsidian plugin metadata
├── package.json # Dependencies and scripts
├── tsconfig.json # TypeScript configuration
└── esbuild.config.mjs # Build configuration
```
## Key Features
### 1. Voice Transcript Parsing
- Processes voice transcripts into atomic notes (one idea per note)
- Extracts actionable tasks and creates summaries
- Supports "auggie" voice commands for special behaviors
- Estimates token usage before processing
### 2. Linked Notes Distillation
- Analyzes a root note and all its linked notes
- Supports both standard Obsidian links and Dataview queries
- Deduplicates and merges overlapping ideas
- Creates comprehensive summaries with source attribution
### 3. Custom Context Instructions
- Users can add `context:` sections to notes for focused extraction
- Context instructions guide AI processing behavior
## Development Guidelines
### Build Commands
```bash
# Development build with hot reload
npm run dev
# Production build
npm run build
# Type checking
npm run typecheck
```
### Code Standards
- TypeScript with strict mode enabled
- ESLint configuration for code quality
- No external runtime dependencies (only Obsidian API)
### Testing
Currently no automated tests. Manual testing through Obsidian's developer console.
## API Integration
### OpenAI Service
- Model: GPT-4.1-2025-04-14
- Temperature: 0.7 for parsing, 0.3 for distilling
- Structured output using JSON schema
- Token estimation before API calls
### File Operations
- Creates atomic notes in configurable folders
- Generates summaries with backlinks
- Handles special characters in filenames
- Maintains backlink mappings for navigation
## Configuration
### User Settings
- `openaiApiKey`: Required for AI processing
- `summaryFolderPath`: Default "OpenAugi/Summaries"
- `notesFolderPath`: Default "OpenAugi/Notes"
- `useDataview`: Enable/disable Dataview integration
### Build Configuration
- Target: ES2018/ES6
- Platform: Browser (Electron)
- External: Obsidian modules
- Sourcemaps enabled for development
## Output Structure
### Summary Files
- Format: `[original-name] - summary.md` or `[original-name] - distilled.md`
- Contains: Summary, atomic note links, extracted tasks
- For distilled notes: Shows source note references
### Atomic Notes
- Self-contained ideas with context
- Includes relevant backlinks
- Organized by timestamp or topic
## Common Development Tasks
### Adding New Features
1. Extend services in `/src/services/`
2. Update command registration in `main.ts`
3. Add settings if needed in `settings.ts`
### Debugging
- Use Obsidian's developer console (Ctrl+Shift+I)
- Check console for error messages
- Enable verbose logging in development
### Publishing
1. Update version in `manifest.json` and `package.json`
2. Build production bundle: `npm run build`
3. Create release with `main.js`, `manifest.json`, and `styles.css`
## Important Considerations
- Always handle API errors gracefully
- Respect rate limits and token usage
- Sanitize filenames to prevent filesystem issues
- Maintain backwards compatibility with existing notes
- Test with various note structures and edge cases
## Future Enhancements
- Support for additional AI models
- Batch processing optimization
- Enhanced Dataview query support
- Customizable output templates
- Integration with other Obsidian plugins

289
README.md
View file

@ -61,14 +61,166 @@ This command analyzes a set of linked notes and synthesizes them into a coherent
2. Open this root note
3. Hit `CMD+P` (or `CTRL+P` on Windows/Linux) to open the command palette
4. Run `OpenAugi: Distill Linked Notes`
5. **NEW**: Select a custom prompt "lens" or use the default prompt
The plugin will:
- Show a prompt selection modal where you can choose a processing lens
- Gather all linked notes
- Analyze their content together
- Analyze their content together using your selected lens
- Create new atomic notes that synthesize and organize the information
- Generate a summary that connects the key concepts
- Extract any actionable tasks found across the notes
### 3. Distill Recent Activity
This command automatically discovers and distills notes that have been recently modified or have date prefixes in their filenames, perfect for daily/weekly reviews.
**Usage:**
1. Hit `CMD+P` (or `CTRL+P` on Windows/Linux) to open the command palette
2. Run `OpenAugi: Distill Recent Activity`
3. Configure your preferences in the modal:
- **Time Window Selection**:
- **Default Mode**: "Days to look back" - specify how many days of activity to include (default: 7)
- **Date Range Mode**: Toggle "Use specific date range" to select exact start and end dates
- **Filter journal sections**: For journal-style notes with date headers, only include recent sections
- **Root note**: Optionally specify a note to provide additional context
- **Exclude folders**: Skip folders like Templates or Archive
4. Click "Select Notes" to preview and choose which notes to include:
- View all discovered notes with their modification dates
- Check/uncheck individual notes to customize your selection
- Use "Select All" toggle for bulk selection
5. Optional: Click "Save as Collection" to create a persistent checkbox list for future use
6. Click "Distill" to process
7. Select a custom prompt "lens" or use the default prompt
**The plugin will:**
- Show discovered notes in an interactive checkbox list
- Process only the notes you've selected
- For journal-style notes with date headers (e.g., `### 2024-01-20`), extract only recent sections
- Synthesize the activity into organized atomic notes using your selected lens
- Create atomic notes in a timestamped session folder (e.g., `OpenAugi/Notes/Recent Activity 2024-01-20 14-30-52/`)
- Generate a summary with a descriptive name based on content
**Date Range Selection:**
Toggle between two modes for selecting your time window:
- **"Last N days"**: Quick selection for recent activity (1 day, 7 days, etc.)
- **Specific date range**: Choose exact start and end dates using date pickers
- Perfect for reviewing specific periods like "last week" or "this month"
- Includes all notes modified or dated within the range
**Note Selection Interface:**
The new selection interface lets you:
- Preview all notes that match your criteria before processing
- See each note's folder location and modification time
- Exclude specific notes by unchecking them
- Save your selection as a collection for future reference
**Save as Collection:**
Create a persistent record of your note selection:
- Saves to `OpenAugi/Collections/` folder
- Preserves checked/unchecked states
- Can be used later with "Distill Linked Notes" command
- Perfect for creating curated sets of notes for repeated analysis
**Date-Based Note Discovery:**
Notes with filenames beginning with `YYYY-MM-DD` format are automatically included if their date falls within your specified time window, regardless of when they were last modified. This is perfect for:
- Meeting notes dated by when they occurred
- Daily logs or journal entries
- Event-based documentation
- Any notes you want to organize by their content date rather than modification time
## Custom Prompt Lenses
**NEW**: OpenAugi now supports custom prompt templates that act as "lenses" to focus processing on specific aspects of your notes.
### How Custom Prompts Work
Custom prompts allow you to guide OpenAugi's AI processing with specific perspectives or focus areas. When you run distillation commands, you can select from your custom prompts to process notes through different "lenses" - extracting different types of insights from the same content.
### Using Custom Prompts
1. When you run "Distill Linked Notes" or "Distill Recent Activity", you'll see a prompt selection modal
2. Choose from any prompt in your prompts folder (default: `OpenAugi/Prompts`)
3. The selected prompt replaces the default processing instructions while keeping the structured output format
4. Or select "Use default prompt" for general-purpose processing
### Understanding the Prompt System
#### Default Prompt
When no custom prompt is selected, OpenAugi uses its built-in default prompt that:
- Acts as an "expert knowledge curator"
- Creates atomic notes focusing on distinct concepts and ideas
- Deduplicates and merges overlapping information
- Extracts genuinely actionable tasks
- Generates comprehensive summaries highlighting connections
#### How Custom Prompts Replace the Default
When you select a custom prompt:
1. **Your custom prompt replaces** the default instruction section
2. **The system always adds**:
- Any custom context from your notes (if present)
- JSON output structure requirements
- The actual note content to process
This means your custom prompt changes HOW the content is analyzed, but not the OUTPUT format.
### Creating Your Own Prompts
To create a custom prompt:
1. Create a new markdown file in your prompts folder
2. Write your instructions following this structure:
- Start with a brief description of the lens perspective
- Include sections for:
- Creating atomic notes (with your specific focus)
- Extracting tasks (if relevant to your lens)
- Creating a summary (with your desired emphasis)
3. Save the file with a descriptive name (e.g., "Technical Documentation.md")
#### Template Structure
```markdown
You are an expert [your role] helping users [your purpose].
# Instructions
[Your specific focus and approach]
### 1. Create Atomic Notes
- [Your specific criteria for what makes a good atomic note]
- [How to identify relevant concepts for your lens]
- [Any special formatting or emphasis]
### 2. Extract Tasks
- [What counts as a task in your context]
- [How to format and prioritize tasks]
### 3. Create a Summary
- [What to emphasize in the summary]
- [How to structure the summary for your use case]
```
### Example Prompts Included
- **Research Focus**: Extracts academic insights, methodologies, and research questions
- **Project Management**: Focuses on deliverables, timelines, and actionable tasks
- **Personal Reflection**: Captures personal insights, emotions, and growth moments
### Tips for Writing Effective Prompts
- Be specific about what types of information to extract
- Maintain the structure of atomic notes, tasks, and summary
- Use clear, directive language
- Keep the focus narrow for best results
- Remember that Obsidian backlinks should still be used to connect ideas
- Test your prompt with various content types to ensure consistency
### Important Notes
- The JSON output structure is always preserved regardless of the prompt
- Custom prompts only affect the instructional content, not the response format
- Test your prompts with different types of notes to ensure they work as expected
- Custom prompts work in addition to (not instead of) any custom context in your notes
- If no prompt files exist in your prompts folder, only the default option will be available
## Custom Context
You can guide how OpenAugi processes your content by adding a custom context section to your notes:
@ -92,23 +244,138 @@ Add this section to your root note before running the distill command.
The custom context allows you to narrow the focus of processing to extract specific types of information relevant to your current needs.
## Use Cases
### Daily/Weekly Reviews
Use "Distill Recent Activity" to automatically summarize your work:
- Set to 1 day for daily reviews
- Set to 7 days for weekly reviews
- Automatically captures all your recent thoughts and work
- Perfect for identifying patterns and progress
### Project Summaries
Use "Distill Linked Notes" with a project hub note:
- Create a note that links to all project-related notes
- Run distillation to get a comprehensive project overview
- Extract all tasks and decisions across the project
### Research Synthesis
Combine both commands for research workflows:
- Use "Distill Recent Activity" to review recent research notes
- Use "Distill Linked Notes" on topic-specific collections
- Add custom context to focus on findings, methodologies, or insights
### Journal Processing
Take advantage of journal-style note support:
- Keep daily journal entries with date headers
- Use "Distill Recent Activity" to extract recent insights
- Only relevant date sections are processed, keeping context focused
## Requirements
Note: this requires an OpenAI API key to work.
Your content is sent directly to OpenAI for processing using the best model for this task. The cost to use this plugin depends on the API credits consumed. For me ~5 minutes of voice note is about 2-3 cents of processing.
## Configuration Settings
OpenAugi provides several configuration options in Settings → OpenAugi:
### Basic Settings
- **OpenAI API Key**: Your API key for processing (required)
- **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`)
- **Use Dataview**: Enable processing of dataview queries in distillation
### Recent Activity Settings
Configure defaults for the "Distill Recent Activity" command:
- **Default Days to Look Back**: How many days of activity to include by default (default: 7)
- **Filter Journal Sections**: When enabled, only includes recent sections from journal-style notes
- **Date Header Format**: Customize the format for date headers in journal notes (default: `### YYYY-MM-DD`)
- Examples: `## DD/MM/YYYY`, `#### YYYY.MM.DD`, `### [YYYY-MM-DD]`
- Must include YYYY (year), MM (month), and DD (day) placeholders
- **Exclude Folders**: Comma-separated list of folders to skip (default: `Templates, Archive, OpenAugi`)
### Advanced Settings
- **Enable Distill Logging**: Logs the full context sent to AI for debugging (saved to `OpenAugi/Logs`)
## Journal-Style Notes Support
OpenAugi has special support for journal-style notes that use date headers. When processing recent activity:
1. **Automatic Detection**: Notes with date headers matching your configured format are automatically detected
2. **Smart Filtering**: Only sections with dates within your specified time window are included
3. **Flexible Formats**: Configure your preferred date header format in settings
Example journal note:
```markdown
# My Journal
### 2024-01-20
Today's thoughts and activities...
### 2024-01-19
Yesterday's reflections...
### 2024-01-10
Older content that may be filtered out...
```
When using "Distill Recent Activity" with a 7-day window, only the recent sections would be processed.
## Output Structure
OpenAugi creates two types of files:
1. **Summary files** - Placed in your designated summary folder, containing:
- A concise summary of the content
- Links to all atomic notes
- Tasks extracted from the content
2. **Atomic notes** - Placed in your designated notes folder, each containing:
- A single, self-contained idea
- Context and supporting details
- Backlinks to related notes where relevant
OpenAugi creates organized outputs for better note management:
### Summary Files
Placed in your designated summary folder (`OpenAugi/Summaries` by default):
- **Transcript summaries**: `[original-name] - summary.md`
- **Distilled notes**: `[root-note-name] - distilled.md`
- **Recent activity**: `Recent Activity YYYY-MM-DD - [first-note-title].md`
Each summary contains:
- A concise overview of the processed content
- Links to all generated atomic notes
- Extracted tasks with context
- Source note references (for distillations)
### Atomic Notes
Organized in session-specific folders within your notes folder (`OpenAugi/Notes` by default):
- **Transcript sessions**: `Transcript YYYY-MM-DD HH-mm-ss/`
- **Distill sessions**: `Distill [RootNoteName] YYYY-MM-DD HH-mm-ss/`
- **Recent activity sessions**: `Recent Activity YYYY-MM-DD HH-mm-ss/`
Each atomic note contains:
- A single, self-contained idea
- Relevant context and supporting details
- Backlinks to related concepts
- Clear, descriptive titles
### Collection Notes
Saved note selections for future processing (`OpenAugi/Collections` by default):
- **Format**: `Recent Activity YYYY-MM-DD HH-mm-ss.md`
- Contains checkbox lists of selected notes
- Preserves selection criteria and configuration
- Can be processed later using "Distill Linked Notes"
## Advanced Features
### Checkbox-Based Collections
OpenAugi recognizes and processes checkbox-style note collections:
```markdown
- [x] [[Note to include]]
- [ ] [[Note to exclude]]
- [x] [[Another included note]]
```
When running "Distill Linked Notes" on such a collection, only checked items are processed.
### Session Folders
All atomic notes from a single processing session are kept together in timestamped folders, making it easy to:
- Review all outputs from a specific session
- Move or archive related notes together
- Track the evolution of your ideas over time
- Avoid mixing notes from different contexts
# Get involved, let's build augmented intelligence

2406
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -16,11 +16,9 @@
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"esbuild": "^0.17.3",
"obsidian": "^1.8.7",
"tslib": "2.4.0",
"typescript": "^4.7.4"
},
"dependencies": {
}
}

View file

@ -5,7 +5,9 @@ import { FileService } from './services/file-service';
import { DistillService } from './services/distill-service';
import { OpenAugiSettingTab } from './ui/settings-tab';
import { LoadingIndicator } from './ui/loading-indicator';
import { sanitizeFilename } from './utils/filename-utils';
import { sanitizeFilename, createFileWithCollisionHandling } from './utils/filename-utils';
import { RecentActivityModal, RecentActivityConfig } from './ui/recent-activity-modal';
import { PromptSelectionModal, PromptSelectionConfig } from './ui/prompt-selection-modal';
/**
* A simple tokeinzer to estimate the number of tokens
@ -67,6 +69,15 @@ export default class OpenAugiPlugin extends Plugin {
}
});
// Add command to distill recent activity
this.addCommand({
id: 'distill-recent-activity',
name: 'Distill recent activity',
callback: async () => {
await this.distillRecentActivity();
}
});
// Add settings tab
this.addSettingTab(new OpenAugiSettingTab(this.app, this));
}
@ -158,6 +169,23 @@ export default class OpenAugiPlugin extends Plugin {
* @param rootFile The root file containing links to distill
*/
private async distillLinkedNotes(rootFile: TFile): Promise<void> {
// Show prompt selection modal
const modal = new PromptSelectionModal(
this.app,
this.settings.promptsFolder,
async (config: PromptSelectionConfig) => {
await this.executeDistillLinkedNotes(rootFile, config);
}
);
modal.open();
}
/**
* Execute the distillation of linked notes with the selected prompt configuration
* @param rootFile The root note file
* @param promptConfig The prompt configuration from the modal
*/
private async executeDistillLinkedNotes(rootFile: TFile, promptConfig: PromptSelectionConfig): Promise<void> {
try {
// Show loading indicator
this.loadingIndicator?.show('Distilling linked notes');
@ -177,9 +205,6 @@ export default class OpenAugiPlugin extends Plugin {
this.settings
);
// Get root content for initial notice
const rootContent = await this.app.vault.read(rootFile);
// Get linked files
let linkedFiles = await this.distillService.getLinkedNotes(rootFile);
@ -200,21 +225,27 @@ export default class OpenAugiPlugin extends Plugin {
return;
}
// Aggregate linked content
const { content: linkedContent, sourceNotes } = await this.distillService.aggregateContent(linkedFiles);
// Show notice about linked files
new Notice(`Found ${linkedFiles.length} linked notes to process.`);
// Combine content
const combinedContent = `# Root Note: ${rootFile.basename}\n\n${rootContent}\n\n${linkedContent}`;
const combinedTokens = estimateTokens(combinedContent);
// 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');
}
}
// Show combined content notice
new Notice(`Combined content from ${linkedFiles.length} linked notes\nTotal characters: ${combinedContent.length}\nEst. total tokens: ${combinedTokens}`);
// Distill content from linked notes
// Let the distill service handle all the content aggregation (no time filtering)
const distilledData = await this.distillService.distillFromRootNote(
rootFile,
combinedContent,
sourceNotes
rootFile,
undefined,
undefined,
0, // No time filtering for regular distill command
customPrompt
);
// Write result to files
@ -246,4 +277,178 @@ export default class OpenAugiPlugin extends Plugin {
// Reinitialize services with new settings
this.initializeServices();
}
/**
* Distill recent activity based on user configuration
*/
private async distillRecentActivity(): Promise<void> {
// Show configuration modal
const modal = new RecentActivityModal(
this.app,
this.settings.recentActivityDefaults,
async (config: RecentActivityConfig) => {
// After recent activity config, show prompt selection
const promptModal = new PromptSelectionModal(
this.app,
this.settings.promptsFolder,
async (promptConfig: PromptSelectionConfig) => {
await this.executeRecentActivityDistill(config, promptConfig);
}
);
promptModal.open();
}
);
modal.open();
}
/**
* Execute the recent activity distillation with the given configuration
* @param config The configuration for recent activity distillation
* @param promptConfig The prompt configuration from the modal
*/
private async executeRecentActivityDistill(config: RecentActivityConfig, promptConfig: PromptSelectionConfig): Promise<void> {
try {
// Check API key
if (!this.settings.apiKey) {
new Notice('Please set your OpenAI API key in the settings');
return;
}
// Show loading indicator
this.loadingIndicator?.show('Discovering recent activity...');
// Update services with latest API key
this.openAIService = new OpenAIService(this.settings.apiKey);
this.distillService = new DistillService(
this.app,
this.openAIService,
this.settings
);
// Use selected notes if provided, otherwise get all recent notes
let recentFiles: TFile[];
if (config.selectedNotes && config.selectedNotes.length > 0) {
recentFiles = config.selectedNotes;
} else {
// Fallback to getting all recent notes (shouldn't happen with new UI)
recentFiles = await this.distillService.getRecentlyModifiedNotes(
config.daysBack,
config.excludeFolders,
config.useDateRange ? config.fromDate : undefined,
config.useDateRange ? config.toDate : undefined
);
}
if (recentFiles.length === 0 && !config.rootNote) {
this.loadingIndicator?.hide();
new Notice(`No notes selected for processing`);
return;
}
// Prepare files list including root note if provided
let allFiles = [...recentFiles];
if (config.rootNote && !recentFiles.some(f => f.path === config.rootNote!.path)) {
allFiles = [config.rootNote, ...recentFiles];
}
// Show notice about discovered files
const message = config.rootNote
? `Processing ${recentFiles.length} selected notes plus root note: ${config.rootNote.basename}`
: `Processing ${recentFiles.length} selected notes`;
new Notice(message);
// Update loading message
this.loadingIndicator?.show('Processing recent activity...');
// Use appropriate time window for filtering
const timeWindow = config.filterJournalSections ? config.daysBack : 0;
// Create a synthetic root file for the distillation
let timeWindowDesc: string;
if (config.useDateRange && config.fromDate && config.toDate) {
timeWindowDesc = `from ${config.fromDate} to ${config.toDate}`;
} else {
timeWindowDesc = `in the last ${config.daysBack} days`;
}
const syntheticRootContent = `# Recent Activity Summary
This is an automated summary of notes modified ${timeWindowDesc}.
${config.rootNote ? `\nUsing [[${config.rootNote.basename}]] as context root.` : ''}
## Recently Modified Notes:
${allFiles.map(f => `- [[${f.basename}]]`).join('\n')}`;
// Ensure OpenAugi folder exists
if (!await this.app.vault.adapter.exists('OpenAugi')) {
await this.app.vault.createFolder('OpenAugi');
}
// Create a temporary root file
const tempRootPath = `OpenAugi/temp-recent-activity-${Date.now()}.md`;
await createFileWithCollisionHandling(this.app.vault, tempRootPath, syntheticRootContent);
const tempRootFile = this.app.vault.getAbstractFileByPath(tempRootPath) as TFile;
// Aggregate content with time filtering
const { content: aggregatedContent, sourceNotes } = await this.distillService.aggregateContent(
allFiles,
timeWindow
);
// Combine with synthetic root content
const combinedContent = `# Recent Activity: ${timeWindowDesc}\n\n${syntheticRootContent}\n\n${aggregatedContent}`;
// Read custom prompt if selected
let customPrompt: string | undefined;
if (promptConfig.useCustomPrompt && promptConfig.selectedPrompt) {
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');
}
}
// Distill the recent activity
const distilledData = await this.distillService.distillFromRootNote(
tempRootFile,
combinedContent,
sourceNotes,
undefined, // No time window needed here since we already filtered
customPrompt
);
// Update the distilled data to reflect recent activity
const summaryTimeDesc = config.useDateRange && config.fromDate && config.toDate
? `${config.fromDate} to ${config.toDate}`
: `Last ${config.daysBack} Days`;
distilledData.summary = `## Recent Activity Summary (${summaryTimeDesc})\n\n${distilledData.summary}`;
// Remove the temp file from source notes before writing
distilledData.sourceNotes = distilledData.sourceNotes?.filter(
note => !note.includes('temp-recent-activity')
);
// Write result to files
const summaryPath = await this.fileService.writeDistilledFiles(tempRootFile, distilledData);
// Clean up temporary file
await this.app.vault.delete(tempRootFile);
// Hide loading indicator
this.loadingIndicator?.hide();
// Show success message
new Notice(`Successfully distilled recent activity\nCreated ${distilledData.notes.length} atomic notes`);
// Open the summary file in a new tab
await this.openFileInNewTab(summaryPath);
} catch (error) {
// Hide loading indicator
this.loadingIndicator?.hide();
console.error('Failed to distill recent activity:', error);
new Notice('Failed to distill recent activity. Check console for details.');
}
}
}

View file

@ -1,4 +1,5 @@
import { App, TFile, MetadataCache, Component } from 'obsidian';
import { createFileWithCollisionHandling } from '../utils/filename-utils';
import { OpenAIService } from './openai-service';
import { DistillResponse } from '../types/transcript';
import { OpenAugiSettings } from '../types/settings';
@ -27,6 +28,142 @@ export class DistillService {
this.settings = settings;
}
/**
* Log the distill input context to a file for debugging
* @param content The full content being sent to AI
* @param rootFileName The name of the root file being processed
*/
private async logDistillContext(content: string, rootFileName: string): Promise<void> {
// Only log if enabled in settings
if (!this.settings.enableDistillLogging) {
return;
}
try {
// Create log folder if it doesn't exist
const logFolderPath = 'OpenAugi/Logs';
if (!await this.app.vault.adapter.exists(logFolderPath)) {
await this.app.vault.createFolder(logFolderPath);
}
// Create timestamp for log file
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFileName = `distill-log-${rootFileName}-${timestamp}.md`;
const logFilePath = `${logFolderPath}/${logFileName}`;
// Format log content
const logContent = `# Distill Context Log
**Root File**: ${rootFileName}
**Timestamp**: ${new Date().toISOString()}
**Total Characters**: ${content.length}
**Estimated Tokens**: ${estimateTokens(content)}
---
## Full Input Context:
${content}
---
*End of log*`;
// Write log file
await createFileWithCollisionHandling(this.app.vault, logFilePath, logContent);
console.log(`Distill context logged to: ${logFilePath}`);
} catch (error) {
console.error('Failed to log distill context:', error);
}
}
/**
* Get recently modified notes based on specified criteria
* @param daysBack Number of days to look back
* @param excludeFolders Folders to exclude from search
* @param fromDate Optional start date for date range mode
* @param toDate Optional end date for date range mode
* @returns Array of recently modified files
*/
async getRecentlyModifiedNotes(
daysBack: number,
excludeFolders: string[],
fromDate?: string,
toDate?: string
): Promise<TFile[]> {
let startTime: number;
let endTime: number;
let cutoffDate: Date;
if (fromDate && toDate) {
// Use date range
const from = new Date(fromDate);
from.setHours(0, 0, 0, 0);
startTime = from.getTime();
cutoffDate = from;
const to = new Date(toDate);
to.setHours(23, 59, 59, 999);
endTime = to.getTime();
} else {
// Use days back
endTime = Date.now();
startTime = endTime - (daysBack * 24 * 60 * 60 * 1000);
cutoffDate = new Date(startTime);
}
const recentFiles: TFile[] = [];
const files = this.app.vault.getMarkdownFiles();
for (const file of files) {
// Check if file is in excluded folder
const isExcluded = excludeFolders.some(folder =>
file.path.startsWith(folder + '/') || file.path.includes('/' + folder + '/')
);
if (isExcluded) {
continue;
}
let includeFile = false;
// First, check if filename starts with a date
const filenameDate = this.extractDateFromFilename(file.basename);
if (filenameDate) {
const fileTime = filenameDate.getTime();
if (fileTime >= startTime && fileTime <= endTime) {
includeFile = true;
}
}
// If not included by filename date, check modification time
if (!includeFile) {
const stats = await this.app.vault.adapter.stat(file.path);
if (stats && stats.mtime >= startTime && stats.mtime <= endTime) {
includeFile = true;
}
}
if (includeFile) {
recentFiles.push(file);
}
}
// Sort by effective date (filename date or modification time), most recent first
const filesWithDates = await Promise.all(
recentFiles.map(async (file) => {
const filenameDate = this.extractDateFromFilename(file.basename);
const stats = await this.app.vault.adapter.stat(file.path);
const effectiveTime = filenameDate ? filenameDate.getTime() : (stats?.mtime || 0);
return { file, effectiveTime };
})
);
filesWithDates.sort((a, b) => b.effectiveTime - a.effectiveTime);
return filesWithDates.map(item => item.file);
}
/**
* Check if a string contains a dataview query
* @param content The content to check
@ -216,10 +353,40 @@ export class DistillService {
async getLinkedNotes(file: TFile): Promise<TFile[]> {
let linkedFiles: TFile[] = [];
// First check if content contains dataview query and settings allow using dataview
if (this.settings.useDataviewIfAvailable) {
const fileContent = await this.app.vault.read(file);
// Read file content once
const fileContent = await this.app.vault.read(file);
// Check if this is a collection note with checkboxes
const hasCheckboxLinks = /- \[[ x]\] \[\[.*?\]\]/.test(fileContent);
if (hasCheckboxLinks) {
// Extract only checked links from checkbox format
const checkboxRegex = /- \[x\] \[\[(.*?)\]\]/gi;
let match;
while ((match = checkboxRegex.exec(fileContent)) !== null) {
let linkPath = match[1];
// Handle aliased links: [[path|alias]] -> path
if (linkPath.includes("|")) {
linkPath = linkPath.split("|")[0];
}
// Resolve the file
const linkedFile = this.app.metadataCache.getFirstLinkpathDest(linkPath, file.path);
if (linkedFile && linkedFile instanceof TFile) {
if (!linkedFiles.some(existingFile => existingFile.path === linkedFile.path)) {
linkedFiles.push(linkedFile);
}
}
}
// For collection notes, only use checked items
return linkedFiles;
}
// Check if content contains dataview query and settings allow using dataview
if (this.settings.useDataviewIfAvailable) {
if (this.containsDataviewQuery(fileContent)) {
const queries = this.extractDataviewQueries(fileContent);
@ -289,12 +456,207 @@ export class DistillService {
return uniqueFiles;
}
/**
* Convert date format string to regex pattern
* @param format The date format string (e.g., "### YYYY-MM-DD")
* @returns Regex pattern for matching date headers
*/
private dateFormatToRegex(format: string): RegExp {
// Escape special regex characters except for the date placeholders
let pattern = format
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
.replace(/YYYY/g, '\\d{4}') // Replace YYYY with year pattern
.replace(/MM/g, '\\d{2}') // Replace MM with month pattern
.replace(/DD/g, '\\d{2}'); // Replace DD with day pattern
return new RegExp(`^${pattern}\\s*$`, 'm');
}
/**
* Extract date from header using the configured format
* @param header The header line to parse
* @param format The date format string
* @returns Date object or null if not a valid date header
*/
private extractDateFromHeader(header: string, format: string): Date | null {
const regex = this.dateFormatToRegex(format);
if (!regex.test(header)) {
return null;
}
// Find positions of date components in format
const yearPos = format.indexOf('YYYY');
const monthPos = format.indexOf('MM');
const dayPos = format.indexOf('DD');
if (yearPos === -1 || monthPos === -1 || dayPos === -1) {
return null;
}
// Extract date components from the header at the same positions
const year = header.substr(yearPos, 4);
const month = header.substr(monthPos, 2);
const day = header.substr(dayPos, 2);
// Validate extracted values are numbers
if (!/^\d{4}$/.test(year) || !/^\d{2}$/.test(month) || !/^\d{2}$/.test(day)) {
return null;
}
const date = new Date(`${year}-${month}-${day}T00:00:00`);
return isNaN(date.getTime()) ? null : date;
}
/**
* Extract date from filename if it starts with YYYY-MM-DD format
* @param filename The filename to parse
* @returns Date object or null if filename doesn't start with a date
*/
private extractDateFromFilename(filename: string): Date | null {
// Match YYYY-MM-DD at the start of the filename
const dateMatch = filename.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (dateMatch) {
const year = parseInt(dateMatch[1]);
const month = parseInt(dateMatch[2]);
const day = parseInt(dateMatch[3]);
// Validate the date components
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
const date = new Date(year, month - 1, day);
// Check if the date is valid
if (!isNaN(date.getTime())) {
return date;
}
}
}
return null;
}
/**
* Check if a note is journal-style (contains headers with configured date format)
* @param content The content to check
* @returns True if the note contains date headers
*/
private isJournalStyleNote(content: string): boolean {
const dateFormat = this.settings.recentActivityDefaults.dateHeaderFormat;
// Skip journal parsing if dateHeaderFormat is empty
if (!dateFormat || dateFormat.trim() === '') {
return false;
}
const dateHeaderRegex = this.dateFormatToRegex(dateFormat);
return dateHeaderRegex.test(content);
}
/**
* Parse a date from a header
* @param header The header line to parse
* @returns Date object or null if not a valid date header
*/
private parseDateFromHeader(header: string): Date | null {
const dateFormat = this.settings.recentActivityDefaults.dateHeaderFormat;
// Skip journal parsing if dateHeaderFormat is empty
if (!dateFormat || dateFormat.trim() === '') {
return null;
}
return this.extractDateFromHeader(header, dateFormat);
}
/**
* Extract sections from content based on date headers
* @param content The content to parse
* @returns Array of sections with their dates
*/
private getDateSections(content: string): Array<{date: Date | null, content: string}> {
const lines = content.split('\n');
const sections: Array<{date: Date | null, content: string}> = [];
let currentSection: string[] = [];
let currentDate: Date | null = null;
let hasFoundFirstDate = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const parsedDate = this.parseDateFromHeader(line);
if (parsedDate) {
// Found a date header
if (!hasFoundFirstDate) {
// First date header - save any content before it as undated
if (currentSection.length > 0) {
sections.push({
date: null,
content: currentSection.join('\n').trim()
});
}
hasFoundFirstDate = true;
} else if (currentSection.length > 0) {
// Save the previous section
sections.push({
date: currentDate,
content: currentSection.join('\n').trim()
});
}
// Start new section with the date header
currentDate = parsedDate;
currentSection = [line];
} else {
// Regular content line
currentSection.push(line);
}
}
// Don't forget the last section
if (currentSection.length > 0) {
sections.push({
date: currentDate,
content: currentSection.join('\n').trim()
});
}
return sections;
}
/**
* Extract content from a note within a specific date range
* @param content The full note content
* @param daysBack Number of days to look back from today
* @returns Filtered content within the date range
*/
private extractContentByDateRange(content: string, daysBack: number): string {
if (!this.isJournalStyleNote(content)) {
// Not a journal-style note, return full content
return content;
}
const sections = this.getDateSections(content);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysBack);
cutoffDate.setHours(0, 0, 0, 0);
const filteredSections: string[] = [];
for (const section of sections) {
if (section.date === null) {
// Include undated content (header/intro)
filteredSections.push(section.content);
} else if (section.date >= cutoffDate) {
// Include sections within the date range
filteredSections.push(section.content);
}
}
return filteredSections.join('\n\n');
}
/**
* Aggregate content from a set of files
* @param files Array of files to aggregate content from
* @param timeWindowDays Optional time window in days for filtering journal content
* @returns Aggregated content as string along with source file names
*/
async aggregateContent(files: TFile[]): Promise<{content: string, sourceNotes: string[]}> {
async aggregateContent(files: TFile[], timeWindowDays?: number): Promise<{content: string, sourceNotes: string[]}> {
let aggregatedContent = "";
const sourceNotes: string[] = [];
@ -307,7 +669,18 @@ export class DistillService {
continue;
}
const content = await this.app.vault.read(file);
let content = await this.app.vault.read(file);
// Apply time filtering if specified
if (timeWindowDays !== undefined && timeWindowDays > 0) {
content = this.extractContentByDateRange(content, timeWindowDays);
// Skip this file if no content remains after filtering
if (!content.trim()) {
continue;
}
}
aggregatedContent += `\n\n# Note: ${file.basename}\n\n${content}`;
sourceNotes.push(file.basename);
@ -325,12 +698,16 @@ export class DistillService {
* @param rootFile The root note file to distill from
* @param preparedContent Optional preprocessed combined content
* @param preparedSourceNotes Optional preprocessed source notes
* @param timeWindowDays Optional time window in days for filtering journal content
* @param customPrompt Optional custom prompt to use for processing
* @returns Distilled content as a DistillResponse
*/
async distillFromRootNote(
rootFile: TFile,
preparedContent?: string,
preparedSourceNotes?: string[]
preparedSourceNotes?: string[],
timeWindowDays?: number,
customPrompt?: string
): Promise<DistillResponse> {
let combinedContent: string;
let sourceNotes: string[];
@ -344,10 +721,15 @@ export class DistillService {
const linkedFiles = await this.getLinkedNotes(rootFile);
// Get content from root note
const rootContent = await this.app.vault.read(rootFile);
let rootContent = await this.app.vault.read(rootFile);
// Aggregate content from all linked notes
const aggregatedResult = await this.aggregateContent(linkedFiles);
// Apply time filtering to root note if specified
if (timeWindowDays !== undefined && timeWindowDays > 0) {
rootContent = this.extractContentByDateRange(rootContent, timeWindowDays);
}
// Aggregate content from all linked notes with time filtering
const aggregatedResult = await this.aggregateContent(linkedFiles, timeWindowDays);
const linkedContent = aggregatedResult.content;
sourceNotes = aggregatedResult.sourceNotes;
@ -355,8 +737,11 @@ export class DistillService {
combinedContent = `# Root Note: ${rootFile.basename}\n\n${rootContent}\n\n${linkedContent}`;
}
// Send to OpenAI for distillation
const distilledContent = await this.openAIService.distillContent(combinedContent);
// Log the combined content before sending to AI
await this.logDistillContext(combinedContent, rootFile.basename);
// Send to OpenAI for distillation with optional custom prompt
const distilledContent = await this.openAIService.distillContent(combinedContent, customPrompt);
// Add source notes to the response
distilledContent.sourceNotes = [rootFile.basename, ...sourceNotes];

View file

@ -1,5 +1,5 @@
import { App, TFile, Vault } from 'obsidian';
import { sanitizeFilename, BacklinkMapper } from '../utils/filename-utils';
import { sanitizeFilename, BacklinkMapper, createFileWithCollisionHandling } from '../utils/filename-utils';
import { TranscriptResponse, DistillResponse } from '../types/transcript';
/**
@ -18,6 +18,37 @@ export class FileService {
this.backlinkMapper = new BacklinkMapper();
}
/**
* Generate a session folder name based on type and timestamp
* @param type Type of distillation: 'transcript', 'distill', or 'recent'
* @param rootName Optional root note name for context
* @returns Formatted folder name
*/
private generateSessionFolderName(type: 'transcript' | 'distill' | 'recent', rootName?: string): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/T/, ' ')
.replace(/\..+/, '')
.replace(/:/g, '-');
switch (type) {
case 'transcript':
return `Transcript ${timestamp}`;
case 'distill':
if (rootName) {
const sanitizedRoot = sanitizeFilename(rootName);
// Truncate root name if too long
const truncatedRoot = sanitizedRoot.length > 30
? sanitizedRoot.substring(0, 30) + '...'
: sanitizedRoot;
return `Distill ${truncatedRoot} ${timestamp}`;
}
return `Distill ${timestamp}`;
case 'recent':
return `Recent Activity ${timestamp}`;
}
}
/**
* Ensure output directories exist
*/
@ -44,6 +75,14 @@ export class FileService {
// Ensure directories exist
await this.ensureDirectoriesExist();
// Create session folder for atomic notes
const sessionFolder = this.generateSessionFolderName('transcript');
const sessionPath = `${this.notesFolder}/${sessionFolder}`;
const sessionExists = await this.vault.adapter.exists(sessionPath);
if (!sessionExists) {
await this.vault.createFolder(sessionPath);
}
// Sanitize filename
const sanitizedFilename = sanitizeFilename(filename);
@ -66,15 +105,23 @@ export class FileService {
}
// Output Summary with tasks
await this.vault.create(`${this.summaryFolder}/${sanitizedFilename} - summary.md`, summaryContent);
await createFileWithCollisionHandling(
this.vault,
`${this.summaryFolder}/${sanitizedFilename} - summary.md`,
summaryContent
);
// Output Notes
// Output Notes to session folder
for (const note of data.notes) {
// Sanitize note title for filename
const sanitizedTitle = sanitizeFilename(note.title);
// Process content to ensure backlinks use sanitized filenames
const processedContent = this.backlinkMapper.processBacklinks(note.content);
await this.vault.create(`${this.notesFolder}/${sanitizedTitle}.md`, processedContent);
await createFileWithCollisionHandling(
this.vault,
`${sessionPath}/${sanitizedTitle}.md`,
processedContent
);
}
}
@ -87,8 +134,41 @@ export class FileService {
// Ensure directories exist
await this.ensureDirectoriesExist();
// Sanitize filename
const sanitizedFilename = sanitizeFilename(rootFile.basename);
// Determine session type based on rootFile
const isRecentActivity = rootFile.basename.includes('temp-recent-activity');
const sessionType = isRecentActivity ? 'recent' : 'distill';
const sessionFolder = this.generateSessionFolderName(
sessionType,
isRecentActivity ? undefined : rootFile.basename
);
const sessionPath = `${this.notesFolder}/${sessionFolder}`;
// Create session folder for atomic notes
const sessionExists = await this.vault.adapter.exists(sessionPath);
if (!sessionExists) {
await this.vault.createFolder(sessionPath);
}
// Generate appropriate filename based on content
let summaryFilename: string;
if (isRecentActivity) {
// For recent activity, try to extract a meaningful title from the first atomic note
// or use a descriptive name
if (data.notes.length > 0) {
const firstNoteTitle = sanitizeFilename(data.notes[0].title);
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
summaryFilename = `Recent Activity ${timestamp} - ${firstNoteTitle}`;
} else {
const timestamp = new Date().toISOString()
.replace(/T/, ' ')
.replace(/\..+/, '')
.replace(/:/g, '-');
summaryFilename = `Recent Activity Summary ${timestamp}`;
}
} else {
// For regular distillation, use the root file name
summaryFilename = `${sanitizeFilename(rootFile.basename)} - distilled`;
}
// Register all note titles for backlink processing
this.backlinkMapper = new BacklinkMapper(); // Reset the mapper
@ -117,16 +197,23 @@ export class FileService {
}
// Output Summary
const summaryPath = `${this.summaryFolder}/${sanitizedFilename} - distilled.md`;
await this.vault.create(summaryPath, summaryContent);
const summaryPath = await createFileWithCollisionHandling(
this.vault,
`${this.summaryFolder}/${summaryFilename}.md`,
summaryContent
);
// Output Notes
// Output Notes to session folder
for (const note of data.notes) {
// Sanitize note title for filename
const sanitizedTitle = sanitizeFilename(note.title);
// Process content to ensure backlinks use sanitized filenames
const processedContent = this.backlinkMapper.processBacklinks(note.content);
await this.vault.create(`${this.notesFolder}/${sanitizedTitle}.md`, processedContent);
await createFileWithCollisionHandling(
this.vault,
`${sessionPath}/${sanitizedTitle}.md`,
processedContent
);
}
return summaryPath;

View file

@ -208,14 +208,21 @@ export class OpenAIService {
/**
* Get the distillation prompt for the OpenAI API
* @param content The aggregated content from linked notes
* @param customPrompt Optional custom prompt to replace the default instructions
* @returns The formatted prompt
*/
private getDistillPrompt(content: string): string {
private getDistillPrompt(content: string, customPrompt?: string): string {
// Extract any custom context from the content (which should include the root note)
const customContext = this.extractCustomContext(content);
// Base prompt
let prompt = `
let prompt: string;
if (customPrompt) {
// Use the custom prompt as the main instructions
prompt = customPrompt;
} else {
// Use the default prompt
prompt = `
You are an expert knowledge curator helping users distill and organize information from their notes. Your task is to analyze multiple related notes and create a coherent set of atomic notes and a summary.
# Instructions
@ -242,8 +249,9 @@ export class OpenAIService {
- Highlight connections between ideas
- Use \`[[Backlinks]]\` to connect to relevant atomic notes
`;
}
// Add custom context if available
// Add custom context if available (this is from the context: section in notes)
if (customContext) {
prompt += `\n\n${customContext}`;
}
@ -257,14 +265,15 @@ export class OpenAIService {
/**
* Call the OpenAI API to distill content from linked notes
* @param content The aggregated content from linked notes
* @param customPrompt Optional custom prompt to replace the default instructions
* @returns Distilled content data
*/
async distillContent(content: string): Promise<DistillResponse> {
async distillContent(content: string, customPrompt?: string): Promise<DistillResponse> {
if (!this.apiKey) {
throw new Error('OpenAI API key not set');
}
const prompt = this.getDistillPrompt(content);
const prompt = this.getDistillPrompt(content, customPrompt);
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {

View file

@ -1,15 +1,33 @@
import { App } from 'obsidian';
export interface RecentActivitySettings {
daysBack: number;
excludeFolders: string[];
filterJournalSections: boolean;
dateHeaderFormat: string;
}
export interface OpenAugiSettings {
apiKey: string;
summaryFolder: string;
notesFolder: string;
promptsFolder: string;
useDataviewIfAvailable: boolean;
enableDistillLogging: boolean;
recentActivityDefaults: RecentActivitySettings;
}
export const DEFAULT_SETTINGS: OpenAugiSettings = {
apiKey: '',
summaryFolder: 'OpenAugi/Summaries',
notesFolder: 'OpenAugi/Notes',
useDataviewIfAvailable: true
promptsFolder: 'OpenAugi/Prompts',
useDataviewIfAvailable: true,
enableDistillLogging: false,
recentActivityDefaults: {
daysBack: 7,
excludeFolders: ['Templates', 'Archive', 'OpenAugi'],
filterJournalSections: true,
dateHeaderFormat: '### YYYY-MM-DD'
}
};

View file

@ -0,0 +1,138 @@
import { App, Modal, Setting, TFile } from 'obsidian';
export interface PromptSelectionConfig {
selectedPrompt?: TFile;
useCustomPrompt: boolean;
}
export class PromptSelectionModal extends Modal {
private config: PromptSelectionConfig;
private onSubmit: (config: PromptSelectionConfig) => void;
private promptsFolder: string;
private availablePrompts: TFile[] = [];
constructor(
app: App,
promptsFolder: string,
onSubmit: (config: PromptSelectionConfig) => void
) {
super(app);
this.promptsFolder = promptsFolder;
this.config = { useCustomPrompt: false };
this.onSubmit = onSubmit;
}
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'
});
// Load available prompts
await this.loadAvailablePrompts();
if (this.availablePrompts.length === 0) {
contentEl.createEl('p', {
text: `No prompt files found in "${this.promptsFolder}". Create markdown files in this folder to use as custom prompts.`,
cls: 'openaugi-prompt-warning'
});
} else {
// Show preview of selected prompt
const previewEl = contentEl.createDiv({ cls: 'openaugi-prompt-preview' });
previewEl.style.display = 'none';
// Create dropdown for prompt selection
new Setting(contentEl)
.setName('Custom prompt')
.setDesc('Select a prompt template to customize processing')
.addDropdown(dropdown => {
dropdown.addOption('', 'Use default prompt');
this.availablePrompts.forEach(prompt => {
dropdown.addOption(prompt.path, prompt.basename);
});
dropdown.onChange(async value => {
if (value) {
this.config.useCustomPrompt = true;
this.config.selectedPrompt = this.availablePrompts.find(p => p.path === value);
previewEl.style.display = 'block';
await this.updatePreview(previewEl);
} else {
this.config.useCustomPrompt = false;
this.config.selectedPrompt = undefined;
previewEl.style.display = 'none';
}
});
});
}
// Action buttons
new Setting(contentEl)
.addButton(button => button
.setButtonText('Cancel')
.onClick(() => this.close())
)
.addButton(button => button
.setButtonText('Continue')
.setCta()
.onClick(() => {
this.onSubmit(this.config);
this.close();
})
);
}
private async loadAvailablePrompts() {
try {
// Check if prompts folder exists
if (!await this.app.vault.adapter.exists(this.promptsFolder)) {
return;
}
// Get all markdown files in the prompts folder
const files = this.app.vault.getMarkdownFiles();
this.availablePrompts = files.filter(file =>
file.path.startsWith(this.promptsFolder + '/') ||
file.path === this.promptsFolder
);
// Sort by name
this.availablePrompts.sort((a, b) => a.basename.localeCompare(b.basename));
} catch (error) {
console.error('Error loading prompts:', error);
}
}
private async updatePreview(previewEl: HTMLElement) {
previewEl.empty();
if (this.config.selectedPrompt) {
try {
const content = await this.app.vault.read(this.config.selectedPrompt);
const preview = content.length > 300 ? content.substring(0, 300) + '...' : content;
previewEl.createEl('h4', { text: 'Preview:' });
previewEl.createEl('pre', {
text: preview,
cls: 'openaugi-prompt-preview-content'
});
} catch (error) {
previewEl.createEl('p', {
text: 'Unable to preview prompt',
cls: 'openaugi-prompt-error'
});
}
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -0,0 +1,544 @@
import { App, Modal, Setting, TFile, ToggleComponent, Notice } from 'obsidian';
import { RecentActivitySettings } from '../types/settings';
import { createFileWithCollisionHandling } from '../utils/filename-utils';
export interface RecentActivityConfig extends RecentActivitySettings {
rootNote?: TFile;
selectedNotes?: TFile[];
useDateRange?: boolean;
fromDate?: string;
toDate?: string;
}
interface NoteSelection {
file: TFile;
selected: boolean;
modifiedTime: number;
}
export class RecentActivityModal extends Modal {
private config: RecentActivityConfig;
private onSubmit: (config: RecentActivityConfig) => void;
private noteSelections: NoteSelection[] = [];
private notesListEl: HTMLElement | null = null;
private previewButton: HTMLElement | null = null;
private daysBackSetting: Setting | null = null;
private fromDateSetting: Setting | null = null;
private toDateSetting: Setting | null = null;
constructor(
app: App,
defaultConfig: RecentActivitySettings,
onSubmit: (config: RecentActivityConfig) => void
) {
super(app);
this.config = { ...defaultConfig };
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h2', { text: 'Configure Recent Activity Distillation' });
// Date range toggle
new Setting(contentEl)
.setName('Use specific date range')
.setDesc('Toggle between "last N days" and specific date range')
.addToggle(toggle => toggle
.setValue(this.config.useDateRange || false)
.onChange(async value => {
this.config.useDateRange = value;
this.refreshDateInputs();
await this.updateNotesList();
})
);
// Container for date inputs
const dateInputsContainer = contentEl.createDiv('date-inputs-container');
// Days back setting (shown when not using date range)
const daysBackSetting = new Setting(dateInputsContainer)
.setName('Days to look back')
.setDesc('Include notes modified within this many days')
.addText(text => text
.setPlaceholder('7')
.setValue(String(this.config.daysBack))
.onChange(async value => {
const days = parseInt(value);
if (!isNaN(days) && days > 0) {
this.config.daysBack = days;
await this.updateNotesList();
}
})
);
// Date range inputs (shown when using date range)
const fromDateSetting = new Setting(dateInputsContainer)
.setName('From date')
.setDesc('Start date for the range (YYYY-MM-DD)')
.addText(text => {
// Set default from date to 7 days ago
if (!this.config.fromDate) {
const defaultFrom = new Date();
defaultFrom.setDate(defaultFrom.getDate() - 7);
this.config.fromDate = defaultFrom.toISOString().split('T')[0];
}
text
.setPlaceholder('YYYY-MM-DD')
.setValue(this.config.fromDate)
.onChange(async value => {
if (this.isValidDate(value)) {
this.config.fromDate = value;
await this.updateNotesList();
}
});
// Add date input type for better UX
text.inputEl.type = 'date';
});
const toDateSetting = new Setting(dateInputsContainer)
.setName('To date')
.setDesc('End date for the range (YYYY-MM-DD)')
.addText(text => {
// Set default to date to today
if (!this.config.toDate) {
this.config.toDate = new Date().toISOString().split('T')[0];
}
text
.setPlaceholder('YYYY-MM-DD')
.setValue(this.config.toDate)
.onChange(async value => {
if (this.isValidDate(value)) {
this.config.toDate = value;
await this.updateNotesList();
}
});
// Add date input type for better UX
text.inputEl.type = 'date';
});
// Store references for show/hide
this.daysBackSetting = daysBackSetting;
this.fromDateSetting = fromDateSetting;
this.toDateSetting = toDateSetting;
// Initial visibility
this.refreshDateInputs();
new Setting(contentEl)
.setName('Filter journal sections by date')
.setDesc('For notes with date headers, only include sections within the time window')
.addToggle(toggle => toggle
.setValue(this.config.filterJournalSections)
.onChange(value => {
this.config.filterJournalSections = value;
})
);
new Setting(contentEl)
.setName('Root note for context (optional)')
.setDesc('Enter the name of a note to provide additional context. Leave empty to skip.')
.addText(text => text
.setPlaceholder('Note name (without .md)')
.setValue(this.config.rootNote?.basename || '')
.onChange(value => {
if (value) {
// Try to find the file
const files = this.app.vault.getMarkdownFiles();
const matchingFile = files.find(f =>
f.basename.toLowerCase() === value.toLowerCase() ||
f.path.toLowerCase() === value.toLowerCase() ||
f.path.toLowerCase() === value.toLowerCase() + '.md'
);
if (matchingFile) {
this.config.rootNote = matchingFile;
}
} else {
this.config.rootNote = undefined;
}
})
);
new Setting(contentEl)
.setName('Exclude folders')
.setDesc('Comma-separated list of folders to exclude (e.g., Templates, Archive)')
.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.updateNotesList();
})
);
// Preview button to show/refresh the notes list
const previewSetting = new Setting(contentEl)
.setName('Select notes to include')
.setDesc('Choose which notes to include in the distillation')
.addButton(button => {
this.previewButton = button.buttonEl;
button
.setButtonText('Select Notes')
.onClick(async () => {
if (this.notesListEl && this.notesListEl.style.display !== 'none') {
// Hide the list
this.notesListEl.style.display = 'none';
button.setButtonText('Select Notes');
} else {
// Show/update the list
await this.updateNotesList();
if (this.notesListEl) {
this.notesListEl.style.display = 'block';
}
button.setButtonText('Hide Selection');
}
});
});
// Container for the notes list (initially hidden)
this.notesListEl = contentEl.createDiv('recent-notes-list');
this.notesListEl.style.display = 'none';
this.notesListEl.style.maxHeight = '300px';
this.notesListEl.style.overflowY = 'auto';
this.notesListEl.style.border = '1px solid var(--background-modifier-border)';
this.notesListEl.style.borderRadius = '4px';
this.notesListEl.style.padding = '10px';
this.notesListEl.style.marginBottom = '20px';
// Action buttons
new Setting(contentEl)
.addButton(button => button
.setButtonText('Save as Collection')
.onClick(async () => {
await this.saveAsCollection();
})
)
.addButton(button => button
.setButtonText('Cancel')
.onClick(() => this.close())
)
.addButton(button => button
.setButtonText('Distill')
.setCta()
.onClick(() => {
// Only include selected notes
this.config.selectedNotes = this.noteSelections
.filter(ns => ns.selected)
.map(ns => ns.file);
this.onSubmit(this.config);
this.close();
})
);
}
private async updateNotesList(): Promise<void> {
if (!this.notesListEl) return;
// Clear existing content
this.notesListEl.empty();
// Show loading
this.notesListEl.createEl('div', {
text: 'Loading recent notes...',
cls: 'loading-text'
});
try {
// Get recent notes using the distill service logic
const files = await this.getRecentlyModifiedNotes(
this.config.daysBack,
this.config.excludeFolders
);
// Clear loading text
this.notesListEl.empty();
if (files.length === 0) {
this.notesListEl.createEl('div', {
text: 'No notes found in the specified time range.',
cls: 'no-notes-text'
});
return;
}
// Create header with select all
const headerEl = this.notesListEl.createDiv('notes-list-header');
headerEl.style.marginBottom = '10px';
headerEl.style.paddingBottom = '10px';
headerEl.style.borderBottom = '1px solid var(--background-modifier-border)';
// Create descriptive header text
let headerText: string;
if (this.config.useDateRange && this.config.fromDate && this.config.toDate) {
headerText = `Found ${files.length} notes (${this.config.fromDate} to ${this.config.toDate})`;
} else {
headerText = `Found ${files.length} notes (last ${this.config.daysBack} days)`;
}
const selectAllSetting = new Setting(headerEl)
.setName(headerText)
.addToggle(toggle => toggle
.setValue(true)
.onChange(value => {
// Update all selections
this.noteSelections.forEach(ns => ns.selected = value);
// Update all checkboxes
this.notesListEl?.querySelectorAll('.note-checkbox input[type="checkbox"]')
.forEach((checkbox: HTMLInputElement) => {
checkbox.checked = value;
});
})
);
// Initialize note selections
this.noteSelections = await Promise.all(files.map(async file => {
const stats = await this.app.vault.adapter.stat(file.path);
return {
file,
selected: true,
modifiedTime: stats?.mtime || 0
};
}));
// Sort by modification time (most recent first)
this.noteSelections.sort((a, b) => b.modifiedTime - a.modifiedTime);
// Create individual note items
const listEl = this.notesListEl.createDiv('notes-items');
this.noteSelections.forEach((noteSelection, index) => {
const itemEl = listEl.createDiv('note-item');
itemEl.style.marginBottom = '8px';
const itemSetting = new Setting(itemEl)
.setClass('note-item-setting')
.setName(noteSelection.file.basename)
.setDesc(this.formatNoteInfo(noteSelection))
.addToggle(toggle => {
toggle
.setValue(noteSelection.selected)
.onChange(value => {
noteSelection.selected = value;
// Update select all toggle if needed
const allSelected = this.noteSelections.every(ns => ns.selected);
const selectAllToggle = headerEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (selectAllToggle) {
selectAllToggle.checked = allSelected;
}
});
toggle.toggleEl.addClass('note-checkbox');
});
});
} catch (error) {
console.error('Error loading recent notes:', error);
this.notesListEl.empty();
this.notesListEl.createEl('div', {
text: 'Error loading notes. Check console for details.',
cls: 'error-text'
});
}
}
private formatNoteInfo(noteSelection: NoteSelection): string {
const date = new Date(noteSelection.modifiedTime);
const dateStr = date.toLocaleDateString();
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const folder = noteSelection.file.parent?.path || 'Root';
return `${folder} • Modified: ${dateStr} ${timeStr}`;
}
private refreshDateInputs(): void {
if (!this.daysBackSetting || !this.fromDateSetting || !this.toDateSetting) return;
if (this.config.useDateRange) {
// Hide days back, show date range
this.daysBackSetting.settingEl.style.display = 'none';
this.fromDateSetting.settingEl.style.display = '';
this.toDateSetting.settingEl.style.display = '';
} else {
// Show days back, hide date range
this.daysBackSetting.settingEl.style.display = '';
this.fromDateSetting.settingEl.style.display = 'none';
this.toDateSetting.settingEl.style.display = 'none';
}
}
private isValidDate(dateStr: string): boolean {
const date = new Date(dateStr);
return !isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}$/.test(dateStr);
}
private async getRecentlyModifiedNotes(daysBack: number, excludeFolders: string[]): Promise<TFile[]> {
let startTime: number;
let endTime: number;
if (this.config.useDateRange && this.config.fromDate && this.config.toDate) {
// Use date range
const fromDate = new Date(this.config.fromDate);
fromDate.setHours(0, 0, 0, 0);
startTime = fromDate.getTime();
const toDate = new Date(this.config.toDate);
toDate.setHours(23, 59, 59, 999);
endTime = toDate.getTime();
} else {
// Use days back
endTime = Date.now();
startTime = endTime - (daysBack * 24 * 60 * 60 * 1000);
}
const recentFiles: TFile[] = [];
const files = this.app.vault.getMarkdownFiles();
for (const file of files) {
// Check if file is in excluded folder
const isExcluded = excludeFolders.some(folder =>
file.path.startsWith(folder + '/') || file.path.includes('/' + folder + '/')
);
if (isExcluded) {
continue;
}
let includeFile = false;
// Check if filename starts with a date (YYYY-MM-DD format)
const dateMatch = file.basename.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (dateMatch) {
const year = parseInt(dateMatch[1]);
const month = parseInt(dateMatch[2]);
const day = parseInt(dateMatch[3]);
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
const fileDate = new Date(year, month - 1, day);
const fileTime = fileDate.getTime();
if (!isNaN(fileTime) && fileTime >= startTime && fileTime <= endTime) {
includeFile = true;
}
}
}
// If not included by filename date, check modification time
if (!includeFile) {
const stats = await this.app.vault.adapter.stat(file.path);
if (stats && stats.mtime >= startTime && stats.mtime <= endTime) {
includeFile = true;
}
}
if (includeFile) {
recentFiles.push(file);
}
}
return recentFiles;
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
/**
* Save the current selection as a collection note
*/
private async saveAsCollection(): Promise<void> {
if (this.noteSelections.length === 0) {
new Notice('No notes to save. Please show and select notes first.');
return;
}
// Generate collection content
const now = new Date();
const dateStr = now.toLocaleDateString();
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
let timeWindowDesc: string;
if (this.config.useDateRange && this.config.fromDate && this.config.toDate) {
timeWindowDesc = `Date Range: ${this.config.fromDate} to ${this.config.toDate}`;
} else {
timeWindowDesc = `Last ${this.config.daysBack} days`;
}
let content = `# Recent Activity Collection
Created: ${dateStr} ${timeStr}
Time Window: ${timeWindowDesc}
${this.config.rootNote ? `Context Root: [[${this.config.rootNote.basename}]]` : ''}
## Notes
`;
// Add notes with checkboxes
const selectedCount = this.noteSelections.filter(ns => ns.selected).length;
content += `Selected ${selectedCount} of ${this.noteSelections.length} notes:\n\n`;
for (const noteSelection of this.noteSelections) {
const checkbox = noteSelection.selected ? '[x]' : '[ ]';
const date = new Date(noteSelection.modifiedTime);
const modifiedStr = date.toLocaleDateString();
content += `- ${checkbox} [[${noteSelection.file.basename}]] - Modified: ${modifiedStr}\n`;
}
// Add instructions
content += `\n## Instructions
This collection was generated from recent activity. You can:
1. Check/uncheck notes to adjust the selection
2. Run "Distill Linked Notes" on this note to process the checked items
3. Add additional notes manually using standard Obsidian links
## Configuration
The following settings were used:
- Time window: ${timeWindowDesc}
- Filter journal sections: ${this.config.filterJournalSections}
- Excluded folders: ${this.config.excludeFolders.join(', ')}
`;
// Create the collection file
try {
const collectionsFolder = 'OpenAugi/Collections';
// Ensure collections folder exists
if (!await this.app.vault.adapter.exists(collectionsFolder)) {
await this.app.vault.createFolder(collectionsFolder);
}
const timestamp = now.toISOString()
.replace(/T/, ' ')
.replace(/\..+/, '')
.replace(/:/g, '-');
const filename = `Recent Activity ${timestamp}.md`;
const filepath = `${collectionsFolder}/${filename}`;
const createdPath = await createFileWithCollisionHandling(
this.app.vault,
filepath,
content
);
new Notice(`Collection saved to ${createdPath}`);
// Open the created file
const file = this.app.vault.getAbstractFileByPath(createdPath);
if (file instanceof TFile) {
await this.app.workspace.getLeaf().openFile(file);
}
} catch (error) {
console.error('Failed to save collection:', error);
new Notice('Failed to save collection. Check console for details.');
}
}
}

View file

@ -63,6 +63,18 @@ export class OpenAugiSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName('Prompts folder')
.setDesc('Folder path where custom prompt templates are stored')
.addText(text => text
.setPlaceholder('OpenAugi/Prompts')
.setValue(this.plugin.settings.promptsFolder)
.onChange(async (value) => {
this.plugin.settings.promptsFolder = value;
await this.plugin.saveSettings();
})
);
// Check if Dataview plugin is installed
// @ts-ignore - Dataview API is not typed
const dataviewPluginInstalled = this.app.plugins.plugins["dataview"] !== undefined;
@ -80,5 +92,74 @@ export class OpenAugiSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
})
);
// Recent Activity Settings Header
containerEl.createEl('h3', { text: 'Recent Activity Settings' });
new Setting(containerEl)
.setName('Default days to look back')
.setDesc('Default number of days for the "Distill recent activity" command')
.addText(text => text
.setPlaceholder('7')
.setValue(String(this.plugin.settings.recentActivityDefaults.daysBack))
.onChange(async (value) => {
const days = parseInt(value);
if (!isNaN(days) && days > 0) {
this.plugin.settings.recentActivityDefaults.daysBack = days;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName('Filter journal sections by date')
.setDesc('For notes with date headers, only include sections within the time window')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.recentActivityDefaults.filterJournalSections)
.onChange(async (value) => {
this.plugin.settings.recentActivityDefaults.filterJournalSections = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Date header format')
.setDesc('Markdown format for date headers in journal notes. Use YYYY for year, MM for month, DD for day. Leave empty to disable journal parsing.')
.addText(text => text
.setValue(this.plugin.settings.recentActivityDefaults.dateHeaderFormat)
.onChange(async (value) => {
// Allow empty value to disable journal parsing
if (value === '' || (value.includes('YYYY') && value.includes('MM') && value.includes('DD'))) {
this.plugin.settings.recentActivityDefaults.dateHeaderFormat = value;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName('Exclude folders')
.setDesc('Comma-separated list of folders to exclude from recent activity (e.g., Templates, Archive)')
.addText(text => text
.setPlaceholder('Templates, Archive, OpenAugi')
.setValue(this.plugin.settings.recentActivityDefaults.excludeFolders.join(', '))
.onChange(async (value) => {
this.plugin.settings.recentActivityDefaults.excludeFolders = value
.split(',')
.map(f => f.trim())
.filter(f => f.length > 0);
await this.plugin.saveSettings();
})
);
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.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableDistillLogging)
.onChange(async (value) => {
this.plugin.settings.enableDistillLogging = value;
await this.plugin.saveSettings();
})
);
}
}

View file

@ -1,3 +1,5 @@
import { Vault } from 'obsidian';
/**
* Sanitizes a filename to remove characters that aren't allowed in filenames
* @param filename The filename to sanitize
@ -7,6 +9,49 @@ export function sanitizeFilename(filename: string): string {
return filename.replace(/[\\/:*?"<>|]/g, ' - ');
}
/**
* Creates a file with collision handling - appends numbers if file exists
* @param vault The Obsidian vault instance
* @param basePath The desired file path
* @param content The file content
* @returns The actual path where the file was created
*/
export async function createFileWithCollisionHandling(
vault: Vault,
basePath: string,
content: string
): Promise<string> {
let finalPath = basePath;
let counter = 1;
// Try the original path first
if (!await vault.adapter.exists(finalPath)) {
await vault.create(finalPath, content);
return finalPath;
}
// Extract base name and extension
const pathMatch = basePath.match(/^(.*)(\.[^.]+)$/);
const baseWithoutExt = pathMatch ? pathMatch[1] : basePath;
const extension = pathMatch ? pathMatch[2] : '';
// Try appending numbers
while (counter < 100) { // Prevent infinite loop
finalPath = `${baseWithoutExt}-${counter}${extension}`;
if (!await vault.adapter.exists(finalPath)) {
await vault.create(finalPath, content);
return finalPath;
}
counter++;
}
// If still can't find unique name, use timestamp
const timestamp = Date.now();
finalPath = `${baseWithoutExt}-${timestamp}${extension}`;
await vault.create(finalPath, content);
return finalPath;
}
/**
* Maps between note titles and their sanitized filenames
* This helps ensure backlinks point to the correct files

View file

@ -47,4 +47,45 @@
.openaugi-dot.active {
opacity: 1;
}
/* Prompt Selection Modal Styles */
.openaugi-prompt-description {
color: var(--text-muted);
margin-bottom: 1em;
}
.openaugi-prompt-warning {
color: var(--text-warning);
background-color: var(--background-modifier-warning);
padding: 0.5em;
border-radius: 4px;
margin: 1em 0;
}
.openaugi-prompt-preview {
margin-top: 1em;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 1em;
background-color: var(--background-secondary);
}
.openaugi-prompt-preview h4 {
margin-top: 0;
color: var(--text-muted);
font-size: 0.9em;
}
.openaugi-prompt-preview-content {
font-size: 0.85em;
line-height: 1.4;
color: var(--text-normal);
white-space: pre-wrap;
overflow-wrap: break-word;
}
.openaugi-prompt-error {
color: var(--text-error);
font-style: italic;
}