distill recent notes, file name collision

This commit is contained in:
Chris Lettieri 2025-07-26 10:30:24 -04:00
parent 9d4ea7df84
commit 6085173752
11 changed files with 3329 additions and 33 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

View file

@ -69,6 +69,25 @@ The plugin will:
- Generate a summary that connects the key concepts
- Extract any actionable tasks found across the notes
### 3. Distill Recent Activity
This command automatically discovers and distills notes that have been recently modified, 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:
- **Days to look back**: How many days of activity to include (default: 7)
- **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
The plugin will:
- Automatically find all notes modified within your specified time window
- For journal-style notes with date headers (e.g., `### 2024-01-20`), extract only recent sections
- Synthesize the recent activity into organized atomic notes
- Create a comprehensive summary of your recent work
## Custom Context
You can guide how OpenAugi processes your content by adding a custom context section to your notes:
@ -92,11 +111,85 @@ 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`)
- **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:
@ -104,6 +197,7 @@ OpenAugi creates two types of files:
- A concise summary of the content
- Links to all atomic notes
- Tasks extracted from the content
- For distilled notes: Links to source notes
2. **Atomic notes** - Placed in your designated notes folder, each containing:
- A single, self-contained idea

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,8 @@ 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';
/**
* A simple tokeinzer to estimate the number of tokens
@ -67,6 +68,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));
}
@ -177,9 +187,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 +207,15 @@ 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);
// 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
);
// Write result to files
@ -246,4 +247,133 @@ 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) => {
await this.executeRecentActivityDistill(config);
}
);
modal.open();
}
/**
* Execute the recent activity distillation with the given configuration
* @param config The configuration for recent activity distillation
*/
private async executeRecentActivityDistill(config: RecentActivityConfig): 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
);
// Get recently modified notes
const recentFiles = await this.distillService.getRecentlyModifiedNotes(
config.daysBack,
config.excludeFolders
);
if (recentFiles.length === 0 && !config.rootNote) {
this.loadingIndicator?.hide();
new Notice(`No notes modified in the last ${config.daysBack} days`);
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
? `Found ${recentFiles.length} recent notes plus root note: ${config.rootNote.basename}`
: `Found ${recentFiles.length} notes modified in the last ${config.daysBack} days`;
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
const syntheticRootContent = `# Recent Activity Summary
This is an automated summary of notes modified in the last ${config.daysBack} days.
${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: Last ${config.daysBack} Days\n\n${syntheticRootContent}\n\n${aggregatedContent}`;
// Distill the recent activity
const distilledData = await this.distillService.distillFromRootNote(
tempRootFile,
combinedContent,
sourceNotes
);
// Update the distilled data to reflect recent activity
distilledData.summary = `## Recent Activity Summary (Last ${config.daysBack} Days)\n\n${distilledData.summary}`;
// 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,93 @@ 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
* @returns Array of recently modified files
*/
async getRecentlyModifiedNotes(daysBack: number, excludeFolders: string[]): Promise<TFile[]> {
const cutoffTime = Date.now() - (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;
}
// Check modification time
const stats = await this.app.vault.adapter.stat(file.path);
if (stats && stats.mtime >= cutoffTime) {
recentFiles.push(file);
}
}
// Sort by modification time, most recent first
recentFiles.sort((a, b) => {
const aStats = this.app.vault.adapter.stat(a.path);
const bStats = this.app.vault.adapter.stat(b.path);
return (bStats as any).mtime - (aStats as any).mtime;
});
return recentFiles;
}
/**
* Check if a string contains a dataview query
* @param content The content to check
@ -289,12 +377,172 @@ 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;
}
/**
* 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;
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;
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 +555,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 +584,14 @@ 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
* @returns Distilled content as a DistillResponse
*/
async distillFromRootNote(
rootFile: TFile,
preparedContent?: string,
preparedSourceNotes?: string[]
preparedSourceNotes?: string[],
timeWindowDays?: number
): Promise<DistillResponse> {
let combinedContent: string;
let sourceNotes: string[];
@ -344,10 +605,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,6 +621,9 @@ export class DistillService {
combinedContent = `# Root Note: ${rootFile.basename}\n\n${rootContent}\n\n${linkedContent}`;
}
// Log the combined content before sending to AI
await this.logDistillContext(combinedContent, rootFile.basename);
// Send to OpenAI for distillation
const distilledContent = await this.openAIService.distillContent(combinedContent);

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';
/**
@ -66,7 +66,11 @@ 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
for (const note of data.notes) {
@ -74,7 +78,11 @@ export class FileService {
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,
`${this.notesFolder}/${sanitizedTitle}.md`,
processedContent
);
}
}
@ -117,8 +125,11 @@ 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}/${sanitizedFilename} - distilled.md`,
summaryContent
);
// Output Notes
for (const note of data.notes) {
@ -126,7 +137,11 @@ export class FileService {
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,
`${this.notesFolder}/${sanitizedTitle}.md`,
processedContent
);
}
return summaryPath;

View file

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

View file

@ -0,0 +1,110 @@
import { App, Modal, Setting, TFile } from 'obsidian';
import { RecentActivitySettings } from '../types/settings';
export interface RecentActivityConfig extends RecentActivitySettings {
rootNote?: TFile;
}
export class RecentActivityModal extends Modal {
private config: RecentActivityConfig;
private onSubmit: (config: RecentActivityConfig) => void;
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' });
new Setting(contentEl)
.setName('Days to look back')
.setDesc('Include notes modified within this many days')
.addText(text => text
.setPlaceholder('7')
.setValue(String(this.config.daysBack))
.onChange(value => {
const days = parseInt(value);
if (!isNaN(days) && days > 0) {
this.config.daysBack = days;
}
})
);
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(value => {
this.config.excludeFolders = value
.split(',')
.map(f => f.trim())
.filter(f => f.length > 0);
})
);
new Setting(contentEl)
.addButton(button => button
.setButtonText('Cancel')
.onClick(() => this.close())
)
.addButton(button => button
.setButtonText('Distill')
.setCta()
.onClick(() => {
this.onSubmit(this.config);
this.close();
})
);
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -80,5 +80,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.')
.addText(text => text
.setPlaceholder('### YYYY-MM-DD')
.setValue(this.plugin.settings.recentActivityDefaults.dateHeaderFormat)
.onChange(async (value) => {
if (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