mirror of
https://github.com/bitsofchris/openaugi-obsidian-plugin.git
synced 2026-07-22 12:40:27 +00:00
Compare commits
60 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc62c4d591 | ||
|
|
09892d2ef3 | ||
|
|
cac28bd98d | ||
|
|
6ff04f0f33 | ||
|
|
cbc0c1b471 | ||
|
|
521399efd3 | ||
|
|
6e9ed65d40 | ||
|
|
117b33eb95 | ||
|
|
3b73ce2295 | ||
|
|
41e96f86c9 | ||
|
|
33ea6eb397 | ||
|
|
8072306a89 | ||
|
|
850c270434 | ||
|
|
b7d4a3b225 | ||
|
|
9498668076 | ||
|
|
a69b52281d | ||
|
|
2d3c5d75ec | ||
|
|
f23b331902 | ||
|
|
8dd3310151 | ||
|
|
9580d24974 | ||
|
|
00086fe2fd | ||
|
|
3b49d555fe | ||
|
|
55465575aa | ||
|
|
ae9f90acad | ||
|
|
7660c798b5 | ||
|
|
f0a8816653 | ||
|
|
13f1aa4f86 | ||
|
|
cb0d4089a4 | ||
|
|
4123d93c4e | ||
|
|
5777473171 | ||
|
|
250d6bb207 | ||
|
|
513b007a6a | ||
|
|
f4c842253a | ||
|
|
5b05510041 | ||
|
|
535373f0cb | ||
|
|
ca59ca6eb9 | ||
|
|
b2ce0fc9d8 | ||
|
|
34639232f0 | ||
|
|
ea97ae7882 | ||
|
|
9f521a62dc | ||
|
|
7fa66ccf59 | ||
|
|
0a4a278ad7 | ||
|
|
b2c38579a7 | ||
|
|
8415c0c944 | ||
|
|
f15cc5a7e8 | ||
|
|
3db73cfba2 | ||
|
|
aeb30ecb90 | ||
|
|
bcf62e4ab5 | ||
|
|
e2dd6055c5 | ||
|
|
8a999a475c | ||
|
|
3857b1ab07 | ||
|
|
b8fd1bac19 | ||
|
|
ec004dd25f | ||
|
|
6085173752 | ||
|
|
9d4ea7df84 | ||
|
|
34eb234233 | ||
|
|
2b6f781c2a | ||
|
|
7ad0725f29 | ||
|
|
223acd9851 | ||
|
|
89285d033c |
59 changed files with 14206 additions and 86 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -22,3 +22,7 @@ data.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
|
# Test output artifacts
|
||||||
|
tests/vault-output/
|
||||||
|
PLAN.md
|
||||||
|
|
|
||||||
167
CLAUDE.md
Normal file
167
CLAUDE.md
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
# 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. It helps users process unstructured thoughts into a structured "second brain" by breaking down content into self-contained ideas.
|
||||||
|
|
||||||
|
The goal is to help humans process information faster.
|
||||||
|
|
||||||
|
Read the docs/CODEBASE_MAP.md to understand the project at a high level. Be sure to update this map as we make any siginficant changes.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
**Important:** `npm` is not on the default PATH in this environment. Source nvm first:
|
||||||
|
```bash
|
||||||
|
export PATH="$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ | head -1)/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run commands as normal:
|
||||||
|
```bash
|
||||||
|
# Development build with hot reload
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production build (includes typecheck)
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no standalone `typecheck` script — `npm run build` runs `tsc -noEmit -skipLibCheck` before bundling.
|
||||||
|
|
||||||
|
### Code Standards
|
||||||
|
- TypeScript with strict mode enabled
|
||||||
|
- ESLint configuration for code quality
|
||||||
|
- No external runtime dependencies (only Obsidian API)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Automated test suite using Vitest with a mock Obsidian API. See [docs/TESTING.md](docs/TESTING.md) for full details.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests cover: filename utils, OpenAI prompt building, link extraction, BFS traversal, content aggregation, file output, journal filtering, backlink discovery. New features should include test coverage.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
See [docs/PUBLISHING.md](docs/PUBLISHING.md) for the complete release process,
|
||||||
|
including community-store listing health and how to relist if de-listed.
|
||||||
|
|
||||||
|
**Just run the script** (it bumps all three version files, publishes, and verifies):
|
||||||
|
```bash
|
||||||
|
# write docs/release-notes/X.Y.Z.md first, then:
|
||||||
|
./scripts/release.sh X.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
**Non-negotiables** (the guard test `tests/version-consistency.test.ts` enforces these):
|
||||||
|
- Bump **all THREE** version files together: `manifest.json`, `package.json`, **and `versions.json`** (add `"X.Y.Z": "<minAppVersion>"`). Forgetting `versions.json` is the classic mistake.
|
||||||
|
- Tag == `manifest.version`, no `v` prefix.
|
||||||
|
- **Publish the draft immediately** after CI — never leave the repo advertising a version with no published release (that inconsistency can get the plugin auto-removed from the store).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
My local testing vault is in: /Users/chris/zk-for-testing
|
||||||
|
|
||||||
|
Add any notes to /Users/chris/Documents/DEV-TESTING/Test to capture edge cases when relevant.
|
||||||
643
CONTEXT_GATHERING.md
Normal file
643
CONTEXT_GATHERING.md
Normal file
|
|
@ -0,0 +1,643 @@
|
||||||
|
# Unified Context Gathering System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
OpenAugi's unified context gathering system is a powerful foundation that intelligently discovers, filters, and aggregates content from your notes before processing. Instead of having separate, rigid workflows for different commands, you now have **one flexible system** that lets you configure exactly what context to gather, review it before processing, and then choose how to process it.
|
||||||
|
|
||||||
|
Think of it as a three-stage pipeline:
|
||||||
|
1. **Gather**: Discover and collect notes based on your criteria
|
||||||
|
2. **Review**: Preview and refine your selection with checkboxes
|
||||||
|
3. **Process**: Choose to distill, publish, or just save the raw context
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
### The Problem It Solves
|
||||||
|
|
||||||
|
Previously, each command had its own way of gathering notes:
|
||||||
|
- "Distill linked notes" only looked at direct links (depth 1)
|
||||||
|
- "Distill recent activity" used time-based discovery
|
||||||
|
- No way to combine approaches or preview before processing
|
||||||
|
- No way to save raw context without AI processing
|
||||||
|
|
||||||
|
### The Solution
|
||||||
|
|
||||||
|
The unified system gives you:
|
||||||
|
- **Flexible discovery**: Link traversal up to 3 levels deep OR time-based discovery
|
||||||
|
- **Intelligent filtering**: Character limits, folder exclusions, journal section filtering
|
||||||
|
- **Checkbox review**: See exactly what will be included and toggle individual notes
|
||||||
|
- **Multiple outputs**: Distill to atomic notes, publish as blog post, or save raw context
|
||||||
|
- **Reusable architecture**: All commands use the same context gathering foundation
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### Source Modes
|
||||||
|
|
||||||
|
**Linked Notes Mode**
|
||||||
|
- Starts from current note
|
||||||
|
- Follows links breadth-first up to 3 levels deep
|
||||||
|
- Includes: `[[wikilinks]]`, embeds, dataview queries, checked checkboxes
|
||||||
|
- Perfect for: Processing related content, topic deep-dives, project summaries
|
||||||
|
|
||||||
|
**Recent Activity Mode**
|
||||||
|
- Discovers notes by modification time or filename dates
|
||||||
|
- Supports "last N days" or specific date range
|
||||||
|
- Perfect for: Daily/weekly reviews, periodic summaries, time-based synthesis
|
||||||
|
|
||||||
|
### Link Depth Traversal
|
||||||
|
|
||||||
|
The system uses **Breadth-First Search (BFS)** to traverse links:
|
||||||
|
|
||||||
|
```
|
||||||
|
Depth 0: Root note (your starting point)
|
||||||
|
Depth 1: Direct links from root
|
||||||
|
Depth 2: Links from depth 1 notes
|
||||||
|
Depth 3: Links from depth 2 notes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
You're on: [[Project Overview]]
|
||||||
|
├─ Depth 1: [[Meeting Notes]], [[Technical Specs]]
|
||||||
|
│ ├─ Depth 2: [[Action Items]], [[Architecture Decisions]]
|
||||||
|
│ │ └─ Depth 3: [[Implementation Details]], [[Risk Assessment]]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why BFS?**
|
||||||
|
- Captures breadth of related content first
|
||||||
|
- Prevents going too deep into narrow tangents
|
||||||
|
- More predictable and controllable than depth-first search
|
||||||
|
|
||||||
|
### Character Limits
|
||||||
|
|
||||||
|
Default: **100,000 characters** (~25,000 tokens)
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- System tracks total characters as it discovers notes
|
||||||
|
- When approaching limit, marks remaining notes as "discovered but not included"
|
||||||
|
- You can still manually include them via checkboxes (up to you!)
|
||||||
|
- Prevents token overflow and excessive API costs
|
||||||
|
|
||||||
|
### Journal Section Filtering
|
||||||
|
|
||||||
|
For journal-style notes with date headers (e.g., `### 2025-01-15`):
|
||||||
|
- **Enabled**: Only extracts sections within your time window
|
||||||
|
- **Disabled**: Includes entire note content
|
||||||
|
- Configurable date header format in settings
|
||||||
|
|
||||||
|
**Use case:** Your daily journal has 365 sections. You only want the last 7 days worth of entries, not the entire year.
|
||||||
|
|
||||||
|
## New Commands
|
||||||
|
|
||||||
|
### 1. OpenAugi: Process Notes
|
||||||
|
|
||||||
|
**Default behavior:**
|
||||||
|
- Source: Linked notes from current note
|
||||||
|
- Depth: 1 (direct links only)
|
||||||
|
- Shows processing type selector: Distill OR Publish
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Processing a curated set of notes
|
||||||
|
- Topic-focused synthesis
|
||||||
|
- Creating blog posts from research notes
|
||||||
|
|
||||||
|
**Example workflow:**
|
||||||
|
1. Create note: `2025 Q1 Learning.md`
|
||||||
|
2. Add links: `[[Book Notes]]`, `[[Course Notes]]`, `[[Project Insights]]`
|
||||||
|
3. Run command → adjust depth to 2 → review checkboxes → publish as blog post
|
||||||
|
|
||||||
|
### 2. OpenAugi: Process Recent Activity
|
||||||
|
|
||||||
|
**Default behavior:**
|
||||||
|
- Source: Recent activity (last 7 days)
|
||||||
|
- Depth: N/A (time-based, not link-based)
|
||||||
|
- Shows processing type selector: Distill OR Publish
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Weekly reviews
|
||||||
|
- Activity summaries
|
||||||
|
- Periodic reflection posts
|
||||||
|
|
||||||
|
**Example workflow:**
|
||||||
|
1. Run command
|
||||||
|
2. Change to "last 30 days" for monthly review
|
||||||
|
3. Exclude "Archive" and "Templates" folders
|
||||||
|
4. Select only the notes relevant to your review
|
||||||
|
5. Distill to atomic notes for your weekly review summary
|
||||||
|
|
||||||
|
### 3. OpenAugi: Save Context
|
||||||
|
|
||||||
|
**Default behavior:**
|
||||||
|
- Source: Linked notes from current note
|
||||||
|
- Depth: 1
|
||||||
|
- Skips AI processing, saves raw aggregated content
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Gathering research before manual synthesis
|
||||||
|
- Creating reference documents
|
||||||
|
- Debugging context gathering
|
||||||
|
- Exporting collections for external tools
|
||||||
|
|
||||||
|
**Example workflow:**
|
||||||
|
1. Create note with links to all project documentation
|
||||||
|
2. Run "Save Context" → depth 3 → deselect irrelevant notes
|
||||||
|
3. Get single markdown file with all content aggregated
|
||||||
|
4. Use for offline reading, sharing, or manual analysis
|
||||||
|
|
||||||
|
## The Complete Flow
|
||||||
|
|
||||||
|
### Step 1: Context Gathering Configuration
|
||||||
|
|
||||||
|
**Modal: "Gather Context"**
|
||||||
|
|
||||||
|
Configure HOW to discover notes:
|
||||||
|
|
||||||
|
**Source:**
|
||||||
|
- `Linked notes from current note` (follows links)
|
||||||
|
- `Recently modified notes` (time-based)
|
||||||
|
|
||||||
|
**For Linked Notes:**
|
||||||
|
- `Root note`: Shows current note (auto-filled)
|
||||||
|
- `Link depth`: Slider 1-3 levels
|
||||||
|
- `Max characters`: Default 100,000 (adjustable)
|
||||||
|
|
||||||
|
**For Recent Activity:**
|
||||||
|
- `Time window`: "Last N days" OR "Specific date range"
|
||||||
|
- `Days back`: 1, 7, 30, etc.
|
||||||
|
- `From/To dates`: YYYY-MM-DD format
|
||||||
|
|
||||||
|
**Filtering:**
|
||||||
|
- `Exclude folders`: Comma-separated list (e.g., "Templates, Archive")
|
||||||
|
- `Recent sections only`: Toggle for journal filtering
|
||||||
|
- `Journal sections days back`: How many days for section filtering
|
||||||
|
|
||||||
|
**Estimate display:**
|
||||||
|
Shows estimated note count and character count before discovery
|
||||||
|
|
||||||
|
**Action:** Click "Discover Notes" →
|
||||||
|
|
||||||
|
### Step 2: Checkbox Selection
|
||||||
|
|
||||||
|
**Modal: "Select Notes to Include"**
|
||||||
|
|
||||||
|
Review ALL discovered notes with full control:
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- ✅ Checkbox for each note (checked = include)
|
||||||
|
- Organized by depth level (for linked notes)
|
||||||
|
- Shows character count per note
|
||||||
|
- Shows discovery path ("root", "linked from [[X]]", "recent activity")
|
||||||
|
- `Select All` / `Deselect All` buttons
|
||||||
|
- Real-time summary: "Selected: 8 of 12 notes (24,532 characters, ~6,133 tokens)"
|
||||||
|
|
||||||
|
**Why this step matters:**
|
||||||
|
- You see exactly what was discovered
|
||||||
|
- Remove irrelevant notes before processing
|
||||||
|
- Verify the context makes sense
|
||||||
|
- Control costs by managing total size
|
||||||
|
|
||||||
|
**Action:** Click "Continue" →
|
||||||
|
|
||||||
|
### Step 3: Context Preview
|
||||||
|
|
||||||
|
**Modal: "Context Preview"**
|
||||||
|
|
||||||
|
Final review before processing:
|
||||||
|
|
||||||
|
**Displays:**
|
||||||
|
- 📊 Summary stats (notes, characters, tokens, source mode, link depth)
|
||||||
|
- 📝 List of all included notes
|
||||||
|
- 👁️ Preview of first 1000 characters of aggregated content
|
||||||
|
|
||||||
|
**Two paths:**
|
||||||
|
|
||||||
|
**Path A: Save Raw Context**
|
||||||
|
- Creates note: `Context YYYY-MM-DD HH-mm-ss.md`
|
||||||
|
- Saved to: `OpenAugi/` folder
|
||||||
|
- Contains: Metadata + full aggregated content
|
||||||
|
- No AI processing, no API cost
|
||||||
|
- Opens immediately for review
|
||||||
|
|
||||||
|
**Path B: Process with AI**
|
||||||
|
- Shows prompt selection modal →
|
||||||
|
|
||||||
|
### Step 4A: Save Raw Context (Path A)
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```markdown
|
||||||
|
# Gathered Context
|
||||||
|
|
||||||
|
**Source**: linked-notes
|
||||||
|
**Notes**: 8
|
||||||
|
**Characters**: 24,532
|
||||||
|
**Timestamp**: 2025-10-13T14:30:00Z
|
||||||
|
**Link Depth**: 2
|
||||||
|
|
||||||
|
## Included Notes
|
||||||
|
- [[Root Note]]
|
||||||
|
- [[Note 1]]
|
||||||
|
- [[Note 2]]
|
||||||
|
...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Full aggregated content here]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Manual synthesis and writing
|
||||||
|
- Debugging context issues
|
||||||
|
- Sharing context with team
|
||||||
|
- Backup before AI processing
|
||||||
|
|
||||||
|
### Step 4B: Process with AI (Path B)
|
||||||
|
|
||||||
|
**Modal: "Process Context"**
|
||||||
|
|
||||||
|
Choose processing type and prompt:
|
||||||
|
|
||||||
|
**Output format:**
|
||||||
|
- `Distill to atomic notes` (default)
|
||||||
|
- `Publish as single post` (new!)
|
||||||
|
|
||||||
|
**Custom prompt (optional):**
|
||||||
|
- Dropdown of prompts from `OpenAugi/Prompts/` folder
|
||||||
|
- Or use default prompt
|
||||||
|
|
||||||
|
**Action:** Click "Process" →
|
||||||
|
|
||||||
|
### Step 5: AI Processing & Output
|
||||||
|
|
||||||
|
**If Distill:**
|
||||||
|
- Creates atomic notes (one concept per note)
|
||||||
|
- Creates summary with links to atomic notes
|
||||||
|
- Extracts tasks
|
||||||
|
- Saved to: `OpenAugi/Notes/Distill [Name] YYYY-MM-DD/`
|
||||||
|
- Summary: `OpenAugi/Summaries/[Name] - distilled.md`
|
||||||
|
|
||||||
|
**If Publish:**
|
||||||
|
- Creates single polished blog post
|
||||||
|
- Conversational, reader-friendly tone
|
||||||
|
- Preserves your voice and personality
|
||||||
|
- Saved to: `OpenAugi/Published/[Title] - Published YYYY-MM-DD.md`
|
||||||
|
- Includes frontmatter with metadata
|
||||||
|
|
||||||
|
## Processing Types Explained
|
||||||
|
|
||||||
|
### Distill to Atomic Notes
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Analyzes content for distinct concepts and ideas
|
||||||
|
- Creates separate notes (one idea per note)
|
||||||
|
- Deduplicates and merges overlapping concepts
|
||||||
|
- Extracts actionable tasks
|
||||||
|
- Generates summary linking all atomic notes
|
||||||
|
|
||||||
|
**Output structure:**
|
||||||
|
```
|
||||||
|
OpenAugi/Notes/Distill MyProject 2025-10-13 14-30-52/
|
||||||
|
├─ Core Concept.md
|
||||||
|
├─ Key Decision.md
|
||||||
|
├─ Technical Approach.md
|
||||||
|
└─ Risk Consideration.md
|
||||||
|
|
||||||
|
OpenAugi/Summaries/MyProject - distilled.md
|
||||||
|
└─ [Summary with links to above notes + tasks]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Building knowledge base
|
||||||
|
- Organizing research
|
||||||
|
- Breaking down complex topics
|
||||||
|
- Creating reusable concept notes
|
||||||
|
|
||||||
|
### Publish as Single Post
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Transforms raw notes into ONE cohesive blog post
|
||||||
|
- Preserves your unique voice and personality
|
||||||
|
- Adds narrative structure and transitions
|
||||||
|
- Formats for readability (headers, paragraphs, emphasis)
|
||||||
|
- Creates conversational, direct tone
|
||||||
|
|
||||||
|
**Output structure:**
|
||||||
|
```
|
||||||
|
OpenAugi/Published/Building Second Brains - Published 2025-10-13.md
|
||||||
|
|
||||||
|
---
|
||||||
|
type: published-post
|
||||||
|
published_date: 2025-10-13T14:30:00Z
|
||||||
|
prompt_used: default
|
||||||
|
status: draft
|
||||||
|
source_notes: [[Note 1]], [[Note 2]], [[Note 3]]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Building a Second Brain That Actually Works
|
||||||
|
|
||||||
|
[Full polished blog post ready to copy-paste]
|
||||||
|
|
||||||
|
---
|
||||||
|
*Generated from notes using OpenAugi. Source notes: [[Note 1]], [[Note 2]]...*
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Creating shareable content
|
||||||
|
- Blog posts and articles
|
||||||
|
- Discord/forum posts
|
||||||
|
- Documentation
|
||||||
|
- Turning notes into publishable writing
|
||||||
|
|
||||||
|
**Publishing prompt focus:**
|
||||||
|
- PRESERVE: Your voice, tone, creative phrases, core insights
|
||||||
|
- ADD: Context, transitions, narrative arc, why it matters
|
||||||
|
- FORMAT: Short paragraphs, headers, emphasis, conversational
|
||||||
|
- TONE: Direct, honest, friend-to-friend explanation
|
||||||
|
|
||||||
|
## Configuration Settings
|
||||||
|
|
||||||
|
**Settings → OpenAugi → Context Gathering Settings**
|
||||||
|
|
||||||
|
### Default Link Depth
|
||||||
|
- Slider: 1-3
|
||||||
|
- Default: 1 (direct links only)
|
||||||
|
- What it does: Sets initial depth when opening context gathering modal
|
||||||
|
|
||||||
|
### Default Max Characters
|
||||||
|
- Input: Number
|
||||||
|
- Default: 100,000
|
||||||
|
- What it does: Character limit before stopping discovery
|
||||||
|
- Recommended: 50k-150k depending on your vault size
|
||||||
|
|
||||||
|
### Filter Recent Sections by Default
|
||||||
|
- Toggle: On/Off
|
||||||
|
- Default: On
|
||||||
|
- What it does: Enables journal section filtering in recent activity mode
|
||||||
|
- When to disable: If you want full note content always
|
||||||
|
|
||||||
|
### Published Folder
|
||||||
|
- Input: Path
|
||||||
|
- Default: `OpenAugi/Published`
|
||||||
|
- What it does: Where published blog posts are saved
|
||||||
|
|
||||||
|
## How I Intend to Use This
|
||||||
|
|
||||||
|
### Weekly Review & Publishing
|
||||||
|
|
||||||
|
**Every Sunday:**
|
||||||
|
1. Run "Process Recent Activity"
|
||||||
|
2. Set to "last 7 days"
|
||||||
|
3. Exclude "Archive", "Templates", "OpenAugi"
|
||||||
|
4. Enable "Recent sections only" (my daily journal is huge)
|
||||||
|
5. Review checkboxes - uncheck meeting notes, keep insights
|
||||||
|
6. Choose "Publish as single post"
|
||||||
|
7. Get weekly reflection blog post ready for my blog
|
||||||
|
|
||||||
|
**Why this works:**
|
||||||
|
- Automated discovery of my week's work
|
||||||
|
- Filter out noise (meetings, templates)
|
||||||
|
- Only recent journal sections (not entire year)
|
||||||
|
- Direct path from notes → publishable post
|
||||||
|
- Preserves my voice without over-editing
|
||||||
|
|
||||||
|
### Deep Research Synthesis
|
||||||
|
|
||||||
|
**When finishing a research project:**
|
||||||
|
1. Create note: `Project X - Research Hub.md`
|
||||||
|
2. Add links to all research notes, papers, meeting notes
|
||||||
|
3. Run "Process Notes"
|
||||||
|
4. Set depth to 3 (capture transitively related notes)
|
||||||
|
5. Max characters: 150k (large project)
|
||||||
|
6. Review checkboxes, keep only relevant notes
|
||||||
|
7. Choose "Distill to atomic notes"
|
||||||
|
8. Get organized knowledge base of research insights
|
||||||
|
|
||||||
|
**Why this works:**
|
||||||
|
- Depth 3 captures comprehensive context
|
||||||
|
- Checkbox review removes tangential notes
|
||||||
|
- Distillation creates reusable concept notes
|
||||||
|
- Summary shows connections I might have missed
|
||||||
|
|
||||||
|
### Content Pipeline
|
||||||
|
|
||||||
|
**For creating blog content:**
|
||||||
|
|
||||||
|
**Stage 1: Gather context**
|
||||||
|
1. Create `Blog Ideas - October.md`
|
||||||
|
2. Link to rough notes: `[[Idea 1]]`, `[[Idea 2]]`, etc.
|
||||||
|
3. Run "Save Context" (depth 2)
|
||||||
|
4. Review raw aggregated content
|
||||||
|
5. Manually edit/refine the context note
|
||||||
|
|
||||||
|
**Stage 2: Publish**
|
||||||
|
1. Run "Process Notes" on the refined context note
|
||||||
|
2. Depth 1 (just the context itself)
|
||||||
|
3. Choose "Publish as single post"
|
||||||
|
4. Get polished blog post
|
||||||
|
|
||||||
|
**Why this works:**
|
||||||
|
- Save context first lets me review before AI processing
|
||||||
|
- Manual refinement adds my editorial eye
|
||||||
|
- Publishing step transforms refined notes to blog post
|
||||||
|
- Two-stage process: gather → refine → publish
|
||||||
|
|
||||||
|
### Project Documentation
|
||||||
|
|
||||||
|
**Creating comprehensive project docs:**
|
||||||
|
1. Run "Process Notes" on `Project Overview.md`
|
||||||
|
2. Depth 3, max 200k characters
|
||||||
|
3. Include all technical specs, decisions, meeting notes
|
||||||
|
4. Save raw context first (for reference)
|
||||||
|
5. Then process with custom "Technical Documentation" prompt
|
||||||
|
6. Get structured technical docs in atomic note format
|
||||||
|
|
||||||
|
**Why this works:**
|
||||||
|
- Raw context saved for completeness
|
||||||
|
- Deep traversal ensures nothing missed
|
||||||
|
- Custom prompt focuses on technical aspects
|
||||||
|
- Atomic notes create navigable documentation structure
|
||||||
|
|
||||||
|
## Advanced Tips
|
||||||
|
|
||||||
|
### Combining Source Modes
|
||||||
|
|
||||||
|
**Strategy: Recent notes as seed, then traverse links**
|
||||||
|
1. First, run "Process Recent" to discover recent work
|
||||||
|
2. Save the collection (checkbox selection)
|
||||||
|
3. Then run "Process Notes" on that collection with depth 2
|
||||||
|
4. Captures recent work PLUS related context
|
||||||
|
|
||||||
|
### Managing Character Limits
|
||||||
|
|
||||||
|
**For large vaults:**
|
||||||
|
- Start with 50k character limit
|
||||||
|
- Review what gets excluded in checkbox modal
|
||||||
|
- If needed, increase limit and re-run
|
||||||
|
- Or: Process in batches (multiple depth-1 runs)
|
||||||
|
|
||||||
|
### Journal Section Filtering
|
||||||
|
|
||||||
|
**Best practices:**
|
||||||
|
- Set date header format to match your journals
|
||||||
|
- Enable "Recent sections only" by default
|
||||||
|
- Adjust "Journal sections days back" to match time window
|
||||||
|
- Example: 30-day review → 30-day journal sections
|
||||||
|
|
||||||
|
### Custom Prompts for Publishing
|
||||||
|
|
||||||
|
**Create publishing prompt variants:**
|
||||||
|
- `Technical Blog Post.md` - Focus on explaining technical concepts
|
||||||
|
- `Personal Reflection.md` - Emphasis on insights and growth
|
||||||
|
- `Tutorial Style.md` - Step-by-step instructional tone
|
||||||
|
|
||||||
|
**Store in:** `OpenAugi/Prompts/`
|
||||||
|
|
||||||
|
### Iterative Refinement
|
||||||
|
|
||||||
|
**Process:**
|
||||||
|
1. Save raw context first (see what you're working with)
|
||||||
|
2. Review, identify gaps or noise
|
||||||
|
3. Adjust gathering config (depth, exclusions)
|
||||||
|
4. Re-run with refined config
|
||||||
|
5. Process with AI when context is clean
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No notes discovered"
|
||||||
|
|
||||||
|
**Check:**
|
||||||
|
- Is current note open? (for linked notes mode)
|
||||||
|
- Do links exist in current note?
|
||||||
|
- Are folders excluded that contain your notes?
|
||||||
|
- For recent activity: Is date range correct?
|
||||||
|
|
||||||
|
### "Character limit exceeded" warning
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
- Increase max characters in config
|
||||||
|
- Use folder exclusions to filter large notes
|
||||||
|
- Reduce link depth
|
||||||
|
- Process in smaller batches
|
||||||
|
|
||||||
|
### "Journal section filtering not working"
|
||||||
|
|
||||||
|
**Check:**
|
||||||
|
- Date header format matches your notes exactly
|
||||||
|
- Format includes YYYY, MM, DD placeholders
|
||||||
|
- Headers are markdown headers (##, ###, etc.)
|
||||||
|
- Enable "Recent sections only" toggle
|
||||||
|
|
||||||
|
### Processing takes too long
|
||||||
|
|
||||||
|
**Optimize:**
|
||||||
|
- Reduce character count (smaller context)
|
||||||
|
- Use folder exclusions aggressively
|
||||||
|
- Start with depth 1, increase if needed
|
||||||
|
- Check for very large linked notes
|
||||||
|
|
||||||
|
### Output not what expected
|
||||||
|
|
||||||
|
**Try:**
|
||||||
|
- Review raw context first (save context command)
|
||||||
|
- Verify checkbox selections
|
||||||
|
- Use custom prompt for specific focus
|
||||||
|
- Add custom context in notes for guidance
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential additions to the system:
|
||||||
|
|
||||||
|
**Section-level selection:**
|
||||||
|
- Checkbox specific headings within notes
|
||||||
|
- Include only relevant sections
|
||||||
|
- More granular control than note-level
|
||||||
|
|
||||||
|
**Smart filtering:**
|
||||||
|
- Auto-exclude based on tags
|
||||||
|
- Include only notes with specific properties
|
||||||
|
- Filter by note type or template
|
||||||
|
|
||||||
|
**Preset configurations:**
|
||||||
|
- Save gathering configs as presets
|
||||||
|
- Quick-select "Weekly Review" config
|
||||||
|
- Share configs between vaults
|
||||||
|
|
||||||
|
**Graph-based discovery:**
|
||||||
|
- Visual graph of discovered notes
|
||||||
|
- Click to toggle inclusion
|
||||||
|
- See relationships before processing
|
||||||
|
|
||||||
|
**Batch operations:**
|
||||||
|
- Process multiple root notes at once
|
||||||
|
- Merge contexts from different sources
|
||||||
|
- Parallel processing workflows
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### BFS Algorithm
|
||||||
|
|
||||||
|
The link traversal uses breadth-first search:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Queue: [root]
|
||||||
|
Depth 0: Process root, add direct links to queue
|
||||||
|
Depth 1: Process direct links, add their links to queue
|
||||||
|
Depth 2: Process second-level links, add their links to queue
|
||||||
|
Depth 3: Process third-level links (stop, max depth reached)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- Explores broadly before deeply
|
||||||
|
- Guarantees shortest path to any note
|
||||||
|
- Respects character limits during traversal
|
||||||
|
- Marks overflow notes as "discovered but excluded"
|
||||||
|
|
||||||
|
### Character Counting
|
||||||
|
|
||||||
|
Tracks cumulative characters during discovery:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
totalChars = 0
|
||||||
|
for each discovered note:
|
||||||
|
contentSize = note.length
|
||||||
|
if totalChars + contentSize > maxChars:
|
||||||
|
mark note as excluded (overflow)
|
||||||
|
else:
|
||||||
|
include note
|
||||||
|
totalChars += contentSize
|
||||||
|
```
|
||||||
|
|
||||||
|
User can manually override exclusions in checkbox modal.
|
||||||
|
|
||||||
|
### Aggregation Format
|
||||||
|
|
||||||
|
Content aggregated as:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Note: [Note 1 Title]
|
||||||
|
|
||||||
|
[Note 1 full content]
|
||||||
|
|
||||||
|
# Note: [Note 2 Title]
|
||||||
|
|
||||||
|
[Note 2 full content]
|
||||||
|
```
|
||||||
|
|
||||||
|
This format:
|
||||||
|
- Preserves note boundaries
|
||||||
|
- Maintains context for AI
|
||||||
|
- Easy to read in raw format
|
||||||
|
- Works well for both distill and publish
|
||||||
|
|
||||||
|
## Comparison with Previous System
|
||||||
|
|
||||||
|
| Feature | Before | Now |
|
||||||
|
|---------|--------|-----|
|
||||||
|
| Link depth | 1 only | 1-3 configurable |
|
||||||
|
| Preview before processing | No | Yes (checkbox modal) |
|
||||||
|
| Character limits | None | Configurable |
|
||||||
|
| Save raw context | No | Yes |
|
||||||
|
| Publishing support | No | Yes |
|
||||||
|
| Source flexibility | Fixed per command | Choose in modal |
|
||||||
|
| Manual selection | Only for recent activity | All modes |
|
||||||
|
| Reusable architecture | No | Yes |
|
||||||
|
|
||||||
|
The new system is a **superset** of the old functionality - everything that worked before still works, but with more control and flexibility.
|
||||||
171
README.md
171
README.md
|
|
@ -1,45 +1,168 @@
|
||||||
# OpenAugi
|
# OpenAugi
|
||||||
## Voice to Self-Organizing Second Brain for Obsidian
|
## The Personal Intelligence Layer for Your Agents
|
||||||
|
|
||||||
Unlock the power of voice capture and go faster.
|
OpenAugi is a context engineering layer and personal agent harness for your data (currently as an Obsidian plugin). It sits between your knowledge and your AI agents — gathering the right context, dispatching tasks, and shortcutting the loop from idea to action.
|
||||||
|
|
||||||
Open Augi ("auggie") is an open source augmented intelligence plugin for Obsidian. It's designed for people who like to think out loud (like me).
|
Your vault is full of plans, decisions, research, and context. OpenAugi makes that context available to agents so they can actually do useful work.
|
||||||
|
|
||||||
Just capture your voice note, drop hints to Augi, and let Open Augi's agentic workflow process your note into a self-organizing second brain for you.
|
Works with the [OpenAugi MCP server](https://github.com/bitsofchris/openaugi) for semantic search, hub discovery, and structured access to your vault data. (MCP server coming soon.)
|
||||||
|
|
||||||
This is designed to run in a separate folder within your vault. Any agentic actions taken on existing notes, not created by Augi, will be sent to you for review.
|
Join the [Discord](https://discord.gg/d26BVBrnRP). Parent [repo](https://github.com/bitsofchris/openaugi).
|
||||||
|
|
||||||
Let Open Augi process and organize your thoughts so you can go further, faster.
|
---
|
||||||
|
|
||||||
Join the [Discord](https://discord.gg/d26BVBrnRP).
|
## What It Does
|
||||||
Parent [repo](https://github.com/bitsofchris/openaugi).
|
|
||||||
|
|
||||||
|
### 1. Context Engineering
|
||||||
|
|
||||||
## Example
|
Gather precisely the right context from your vault — not too much, not too little.
|
||||||
|
|
||||||
When taking a voice note say "auggie this is a task" or "auggie make a new note about X".
|
- **Link traversal** — Follow wikilinks up to 3 levels deep (breadth-first)
|
||||||
|
- **Backlink discovery** — Find notes that reference your notes, not just notes you link to
|
||||||
|
- **Journal filtering** — Extract only recent sections from date-headed journal notes
|
||||||
|
- **Character budgets** — Stay within token limits with configurable caps
|
||||||
|
- **Checkbox review** — Toggle individual notes on/off before processing
|
||||||
|
|
||||||
Import your voice transcript into Obsidian.
|
### 2. Agent Tasks
|
||||||
|
|
||||||
Hit `CMD+P` and run the `OpenAugi: Parse Transcript` command.
|
Queue work for your agents without leaving Obsidian. The **Augi** commands write task files to `OpenAugi/Tasks/` — the [OpenAugi task watcher](https://github.com/bitsofchris/openaugi) picks them up and runs an agent session for each.
|
||||||
|
|
||||||
This will create:
|
- **Augi: Run review pass** — Route new blocks, refresh views, surface Dashboard nominations
|
||||||
- atomic notes for every idea in your transcript
|
- **Augi: Process dashboard** — Execute your nomination answers only
|
||||||
- extract tasks
|
- **Augi: Distill selection** — Distill the current selection (or active note) through the distill lens
|
||||||
- summarize the entire voice note
|
- **File-based trigger** — Pure vault API, no shell or HTTP, works on Obsidian Mobile with a synced vault
|
||||||
|
|
||||||
The summary note will be created with any relevant tasks and links to the new atomic notes.
|
See [Agent Tasks docs](docs/AGENT_TASKS.md).
|
||||||
|
|
||||||
|
The older **Task Dispatch** feature (launching tmux sessions directly from the plugin) is deprecated in favor of the task-file flow — see [Task Dispatch docs](docs/TASK_DISPATCH.md).
|
||||||
|
|
||||||
|
### 3. Note Processing
|
||||||
|
|
||||||
|
Turn raw notes into organized, atomic knowledge.
|
||||||
|
|
||||||
|
- **Voice transcripts** — Break voice notes into atomic notes + tasks + summary
|
||||||
|
- **Distillation** — Synthesize multiple linked notes into deduplicated atomic notes
|
||||||
|
- **Publishing** — Turn research notes into a single polished blog post
|
||||||
|
- **Custom prompts** — Apply different "lenses" to extract different insights from the same content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Install from Obsidian Community Plugins (or manually)
|
||||||
|
2. Settings → OpenAugi → Enter your OpenAI API key
|
||||||
|
3. For agent tasks: install [OpenAugi](https://github.com/bitsofchris/openaugi) and run `openaugi up` (the task watcher)
|
||||||
|
|
||||||
|
### Queue an Agent Task
|
||||||
|
|
||||||
|
Run **Augi: Run review pass** (or **Augi: Process dashboard**, **Augi: Distill selection**) from the command palette. The plugin writes a pending task file to `OpenAugi/Tasks/`; the task watcher launches an agent session that does the work and writes its results back into the task file.
|
||||||
|
|
||||||
|
For **Augi: Distill selection**, select the text you want distilled first — the selection (or the whole active note if nothing is selected) becomes the task's context.
|
||||||
|
|
||||||
|
### Gather Context
|
||||||
|
|
||||||
|
1. Open any note with links to content you want to process
|
||||||
|
2. Run **Process notes**
|
||||||
|
3. Configure depth, filters, and character limits
|
||||||
|
4. Review discovered notes with checkboxes
|
||||||
|
5. Choose: **Distill** (atomic notes), **Publish** (blog post), or **Save** (raw context)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| **Augi: Run review pass** | Queue a full review pass for the task watcher |
|
||||||
|
| **Augi: Process dashboard** | Queue Dashboard nomination processing for the task watcher |
|
||||||
|
| **Augi: Distill selection** | Queue a distill of the current selection or active note |
|
||||||
|
| **Process notes** | Gather linked notes → review → distill / publish / save |
|
||||||
|
| **Process recent activity** | Same flow but discovers by recent modification date |
|
||||||
|
| **Save context** | Gather and save raw context (no AI processing) |
|
||||||
|
| **Task dispatch: Launch or attach** | Deprecated — launch agent session from task note |
|
||||||
|
| **Task dispatch: Kill session** | Deprecated — kill tmux session for current task note |
|
||||||
|
| **Task dispatch: List active sessions** | Deprecated — view and manage running agent sessions |
|
||||||
|
| **Parse transcript** | Process voice transcript into atomic notes |
|
||||||
|
| **Distill linked notes** | Legacy command — use Process notes instead |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Tasks
|
||||||
|
|
||||||
|
The Augi commands are the trigger surface for the OpenAugi agent loop. Each command writes a `status: pending` task file to `OpenAugi/Tasks/`; the task watcher (`openaugi up`) hydrates it, launches a Claude agent session, and the agent writes its results back into the same file.
|
||||||
|
|
||||||
|
See [Agent Tasks docs](docs/AGENT_TASKS.md) for the full reference, including the task-file format.
|
||||||
|
|
||||||
|
**Key concepts:**
|
||||||
|
- The vault filesystem is the API — the plugin only writes a file, so the commands work on mobile with a synced vault
|
||||||
|
- The same contract is shared with the `openaugi review` CLI and the `zzz:` capture grammar
|
||||||
|
- The task file doubles as the record: results and status land back in it
|
||||||
|
|
||||||
|
### Task Dispatch (deprecated)
|
||||||
|
|
||||||
|
The older agent harness: reads a task note, assembles context from linked notes, creates a tmux session, and launches your agent CLI directly from the plugin. Deprecated in favor of the task-file flow above; it keeps working for now but will be removed over a release or two. See [Task Dispatch docs](docs/TASK_DISPATCH.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context Gathering
|
||||||
|
|
||||||
|
The context gathering pipeline is a three-stage flow:
|
||||||
|
|
||||||
|
1. **Configure** — Source mode (linked notes or recent activity), depth, filters
|
||||||
|
2. **Review** — Checkbox list of discovered notes with character/token counts
|
||||||
|
3. **Process** — Distill to atomic notes, publish as blog post, or save raw
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Breadth-first link traversal up to 3 levels
|
||||||
|
- Bidirectional: forward links + backlinks at each depth
|
||||||
|
- Journal-style date filtering
|
||||||
|
- Dataview query support
|
||||||
|
- Custom prompt lenses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings are in **Settings → OpenAugi**.
|
||||||
|
|
||||||
|
**Core:**
|
||||||
|
- OpenAI API key (required for AI processing)
|
||||||
|
- Output folders: Summaries, Notes, Published, Prompts
|
||||||
|
|
||||||
|
**Context Gathering:**
|
||||||
|
- Default link depth (1-3)
|
||||||
|
- Max characters (default: 100k)
|
||||||
|
- Include backlinks (default: on)
|
||||||
|
- Journal section filtering
|
||||||
|
|
||||||
|
**Task Dispatch (deprecated):**
|
||||||
|
- Terminal app (iTerm2 or Terminal.app)
|
||||||
|
- tmux path (auto-detected or manual)
|
||||||
|
- Default working directory
|
||||||
|
- Repository path mappings
|
||||||
|
- Default agent CLI
|
||||||
|
- Max context characters (default: 200k)
|
||||||
|
|
||||||
|
**Recent Activity:**
|
||||||
|
- Days to look back (default: 7)
|
||||||
|
- Date header format
|
||||||
|
- Folder exclusions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
Note: this requires an OpenAI API key to work.
|
|
||||||
|
|
||||||
Your transcript is sent directly to OpenAI for parsing 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.
|
- **OpenAI API key** — Required for AI processing (distill, publish, parse)
|
||||||
|
- **OpenAugi task watcher** — Required for the Augi agent-task commands ([openaugi](https://github.com/bitsofchris/openaugi), run `openaugi up`)
|
||||||
|
- **tmux** — Required for deprecated task dispatch (`brew install tmux`)
|
||||||
|
- **macOS** — Deprecated task dispatch terminal opening uses AppleScript (iTerm2 or Terminal.app)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Get involved, let's build augmented intelligence
|
## Get Involved
|
||||||
|
|
||||||
This plugin is meant to solve my own problems around using Obsidian as my second brain and AI for organizing my notes.
|
OpenAugi is about augmented intelligence — using AI to help you think faster and do more, not to think for you.
|
||||||
|
|
||||||
Augmented intelligence is using AI to help you think faster and do more. Not to write and think for you. But rather to support and augment what you are capable of.
|
Open an [issue](https://github.com/bitsofchris/openaugi-obsidian-plugin/issues), join the [Discord](https://discord.gg/d26BVBrnRP), or check out [YouTube](https://www.youtube.com/@bitsofchris) for updates. Parent [repo](https://github.com/bitsofchris/openaugi).
|
||||||
|
|
||||||
Open an [issue](https://github.com/bitsofchris/openaugi-obsidian-plugin/issues), join the [Discord](https://discord.gg/d26BVBrnRP), and check out my [YouTube](https://www.youtube.com/@bitsofchris) to give feedback on how this works for you or what you'd like to see next.
|
|
||||||
|
|
|
||||||
94
docs/AGENT_TASKS.md
Normal file
94
docs/AGENT_TASKS.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
---
|
||||||
|
name: Agent Tasks (Augi commands)
|
||||||
|
description: Plugin commands that queue work for the OpenAugi task watcher by writing pending task files to OpenAugi/Tasks/ via the vault API.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Tasks (Augi commands)
|
||||||
|
|
||||||
|
**When to use:** You want an agent to run the review pass, process the
|
||||||
|
Dashboard, or distill a chunk of your writing — triggered from inside
|
||||||
|
Obsidian, on desktop or mobile.
|
||||||
|
|
||||||
|
**How it works:** The vault filesystem is the API. Each command writes a
|
||||||
|
markdown task file with `status: pending` frontmatter into
|
||||||
|
`OpenAugi/Tasks/`. The [OpenAugi task watcher](https://github.com/bitsofchris/openaugi)
|
||||||
|
(`openaugi up`) polls that folder and launches a Claude agent session for
|
||||||
|
each pending task. The plugin never shells out and never makes an HTTP
|
||||||
|
call — it only writes a file, so the same commands work on Obsidian
|
||||||
|
Mobile against a synced vault.
|
||||||
|
|
||||||
|
The `openaugi review` CLI, the `zzz:` capture grammar, and these commands
|
||||||
|
all converge on the same task-file contract, defined authoritatively in
|
||||||
|
the parent repo at `src/openaugi/templates/task-template.md`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- The [OpenAugi](https://github.com/bitsofchris/openaugi) Python package
|
||||||
|
running its task watcher (`openaugi up`) against your vault. Without it,
|
||||||
|
task files sit in `OpenAugi/Tasks/` as pending until a watcher picks
|
||||||
|
them up.
|
||||||
|
- Agent skill files in your vault under `OpenAugi/AGENT/` (created by
|
||||||
|
`openaugi init`): `review-pass.md` for the review commands,
|
||||||
|
`distill-lens.md` for distill.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Instruction queued | Scope |
|
||||||
|
|---------|--------------------|-------|
|
||||||
|
| **Augi: Run review pass** | `run the review pass` | Full loop: route new blocks → refresh views → Dashboard nominations |
|
||||||
|
| **Augi: Process dashboard** | `process the dashboard` | Execute Dashboard nomination answers only, no new-block routing |
|
||||||
|
| **Augi: Distill selection** | `distill this per OpenAugi/AGENT/distill-lens.md` | Current selection, or the active note's body when nothing is selected |
|
||||||
|
|
||||||
|
For **Distill selection**, the selected text (or note body, frontmatter
|
||||||
|
stripped) is copied verbatim into the task file's `## Context` section —
|
||||||
|
the plugin acts purely as a scope selector; the agent distills exactly
|
||||||
|
that content and nothing else.
|
||||||
|
|
||||||
|
## The task file
|
||||||
|
|
||||||
|
Example produced by **Augi: Run review pass**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
status: pending
|
||||||
|
source_block_id: obsidian-plugin
|
||||||
|
source_note: "[[OpenAugi plugin]]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# run the review pass
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Triggered via the OpenAugi plugin command "Augi: Run review pass" at 20260707-143000.
|
||||||
|
|
||||||
|
## User instruction
|
||||||
|
|
||||||
|
> run the review pass
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Read OpenAugi/AGENT/review-pass.md and execute: run the review pass.
|
||||||
|
|
||||||
|
## Human Todo
|
||||||
|
|
||||||
|
## Results
|
||||||
|
```
|
||||||
|
|
||||||
|
The watcher hydrates the file (adds `task_id`, `created`,
|
||||||
|
`tmux_session`, flips `status: active`, renames to `TASK-*.md`) and
|
||||||
|
launches the agent. When the agent finishes it fills in `## Results` and
|
||||||
|
sets `status: done` — the task file doubles as the record of what
|
||||||
|
happened.
|
||||||
|
|
||||||
|
No `repo`/`working_dir` key is written, so the watcher defaults the
|
||||||
|
agent's working directory to the vault — correct for review and distill
|
||||||
|
work.
|
||||||
|
|
||||||
|
## Relationship to Task Dispatch (deprecated)
|
||||||
|
|
||||||
|
The older [Task Dispatch](TASK_DISPATCH.md) feature launches tmux
|
||||||
|
sessions directly from the plugin. That is a parallel execution path
|
||||||
|
that drifts from the watcher (different session names, duplicate repo
|
||||||
|
settings) and requires desktop + tmux + macOS. It is deprecated and will
|
||||||
|
be removed over a release or two; use the Augi commands with the task
|
||||||
|
watcher instead.
|
||||||
470
docs/CODEBASE_MAP.md
Normal file
470
docs/CODEBASE_MAP.md
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
# OpenAugi Codebase Map
|
||||||
|
|
||||||
|
A comprehensive reference for navigating and extending this Obsidian plugin.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|------|-------|
|
||||||
|
| Plugin entry point | [main.ts](../src/main.ts) |
|
||||||
|
| Settings types & defaults | [types/settings.ts](../src/types/settings.ts) |
|
||||||
|
| OpenAI API integration | [services/openai-service.ts](../src/services/openai-service.ts) |
|
||||||
|
| File I/O operations | [services/file-service.ts](../src/services/file-service.ts) |
|
||||||
|
| Link traversal & aggregation | [services/distill-service.ts](../src/services/distill-service.ts) |
|
||||||
|
| Unified context discovery | [services/context-gathering-service.ts](../src/services/context-gathering-service.ts) |
|
||||||
|
| Settings UI | [ui/settings-tab.ts](../src/ui/settings-tab.ts) |
|
||||||
|
| Agent task-file writer | [services/task-file-service.ts](../src/services/task-file-service.ts) |
|
||||||
|
| Task dispatch service (deprecated) | [services/task-dispatch-service.ts](../src/services/task-dispatch-service.ts) |
|
||||||
|
| Task dispatch types | [types/task-dispatch.ts](../src/types/task-dispatch.ts) |
|
||||||
|
| Context modals | [ui/context-gathering-modal.ts](../src/ui/context-gathering-modal.ts), [ui/context-selection-modal.ts](../src/ui/context-selection-modal.ts), [ui/context-preview-modal.ts](../src/ui/context-preview-modal.ts) |
|
||||||
|
| Session list modal | [ui/session-list-modal.ts](../src/ui/session-list-modal.ts) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.ts # Plugin entry, command registration, service init
|
||||||
|
├── types/
|
||||||
|
│ ├── plugin.ts # Plugin interface
|
||||||
|
│ ├── settings.ts # Settings interfaces & defaults
|
||||||
|
│ ├── context.ts # Context gathering types
|
||||||
|
│ └── transcript.ts # API response types
|
||||||
|
├── services/
|
||||||
|
│ ├── openai-service.ts # OpenAI API calls
|
||||||
|
│ ├── file-service.ts # File creation & output
|
||||||
|
│ ├── distill-service.ts # Content aggregation, link traversal
|
||||||
|
│ ├── context-gathering-service.ts # Unified discovery orchestration
|
||||||
|
│ ├── task-file-service.ts # Pending task files → OpenAugi/Tasks/ (watcher trigger)
|
||||||
|
│ └── task-dispatch-service.ts # tmux session & agent dispatch (deprecated)
|
||||||
|
├── types/
|
||||||
|
│ ├── plugin.ts # Plugin interface
|
||||||
|
│ ├── settings.ts # Settings interfaces & defaults
|
||||||
|
│ ├── context.ts # Context gathering types
|
||||||
|
│ ├── transcript.ts # API response types
|
||||||
|
│ └── task-dispatch.ts # Task dispatch types (agents, sessions, frontmatter)
|
||||||
|
├── ui/
|
||||||
|
│ ├── settings-tab.ts # Settings panel
|
||||||
|
│ ├── loading-indicator.ts # Status bar spinner
|
||||||
|
│ ├── context-gathering-modal.ts # Stage 1: Discovery config
|
||||||
|
│ ├── context-selection-modal.ts # Stage 2: Checkbox selection
|
||||||
|
│ ├── context-preview-modal.ts # Stage 3: Preview & action
|
||||||
|
│ ├── prompt-selection-modal.ts # Custom prompt picker
|
||||||
|
│ ├── session-list-modal.ts # Task dispatch session list
|
||||||
|
│ └── recent-activity-modal.ts # (Legacy)
|
||||||
|
└── utils/
|
||||||
|
└── filename-utils.ts # Sanitization, backlink mapping
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Services Architecture
|
||||||
|
|
||||||
|
### OpenAIService (`openai-service.ts`)
|
||||||
|
|
||||||
|
Handles all AI interactions.
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `parseTranscript(content)` - Voice transcript → atomic notes + tasks + summary
|
||||||
|
- `distillContent(content, customPrompt?)` - Multiple notes → atomic notes + summary
|
||||||
|
- `publishContent(content, customPrompt?)` - Notes → single polished blog post
|
||||||
|
|
||||||
|
**API Details:**
|
||||||
|
- Endpoint: `https://api.openai.com/v1/chat/completions`
|
||||||
|
- Model: Configurable (GPT-5, GPT-5 Mini, GPT-5 Nano, or custom)
|
||||||
|
- Uses JSON Schema for structured outputs (distill/transcript)
|
||||||
|
- Free-form text for publishing
|
||||||
|
|
||||||
|
**Prompt Customization:**
|
||||||
|
- Extracts `context:` sections from notes to customize processing
|
||||||
|
- Supports custom prompt files from `OpenAugi/Prompts/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FileService (`file-service.ts`)
|
||||||
|
|
||||||
|
Manages all file output.
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `writeTranscriptFiles(filename, data)` - Creates summary + atomic notes from transcript
|
||||||
|
- `writeDistilledFiles(rootFile, data)` - Creates distilled summary + atomic notes
|
||||||
|
- `writePublishedPost(content, sourceNotes, promptName)` - Creates published post with frontmatter
|
||||||
|
|
||||||
|
**Output Organization:**
|
||||||
|
```
|
||||||
|
OpenAugi/
|
||||||
|
├── Summaries/ # Summary files
|
||||||
|
│ ├── [name] - summary.md
|
||||||
|
│ └── [name] - distilled.md
|
||||||
|
├── Notes/ # Atomic notes in session folders
|
||||||
|
│ ├── Transcript YYYY-MM-DD HH-mm-ss/
|
||||||
|
│ └── Distill [Name] YYYY-MM-DD HH-mm-ss/
|
||||||
|
├── Published/ # Blog posts
|
||||||
|
│ └── [Title] - Published YYYY-MM-DD.md
|
||||||
|
└── Prompts/ # Custom prompt templates
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Automatic collision handling (appends -1, -2, etc.)
|
||||||
|
- Backlink mapping (original titles → sanitized filenames)
|
||||||
|
- Session-based folders for organization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DistillService (`distill-service.ts`)
|
||||||
|
|
||||||
|
Content discovery and aggregation.
|
||||||
|
|
||||||
|
**Discovery Methods:**
|
||||||
|
- `getLinkedNotes(file)` - Get all forward-linked notes from a file
|
||||||
|
- `getBacklinksForFile(targetFile)` - Get all notes that link TO the target file
|
||||||
|
- `getBacklinkSnippets(targetFile, sourceFile, contextLines)` - Extract context around backlinks
|
||||||
|
- `getRecentlyModifiedNotes(daysBack, excludeFolders, fromDate?, toDate?)` - Time-based discovery
|
||||||
|
|
||||||
|
**Aggregation:**
|
||||||
|
- `aggregateContent(files, timeWindowDays?)` - Combine notes into single content string
|
||||||
|
|
||||||
|
**Link Discovery Supports:**
|
||||||
|
- `[[wikilinks]]` and embeds
|
||||||
|
- Dataview queries (if plugin available)
|
||||||
|
- Checkbox collections: only `[x]` checked items
|
||||||
|
- **Backlinks** - Notes that link TO discovered notes (header section/line extraction)
|
||||||
|
|
||||||
|
**Journal Filtering:**
|
||||||
|
- Detects date headers (e.g., `### YYYY-MM-DD`)
|
||||||
|
- Extracts only sections within time window
|
||||||
|
- Configurable date format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ContextGatheringService (`context-gathering-service.ts`)
|
||||||
|
|
||||||
|
Orchestrates the unified discovery system.
|
||||||
|
|
||||||
|
**Main Method:**
|
||||||
|
```typescript
|
||||||
|
gatherContext(config: ContextGatheringConfig): Promise<GatheredContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Discovery Modes:**
|
||||||
|
1. **Linked Notes (BFS)** - Breadth-first traversal up to 3 levels
|
||||||
|
2. **Recent Activity** - Time-based discovery
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Bidirectional link traversal (forward links + backlinks at each depth level)
|
||||||
|
- Forward links: extract full note content
|
||||||
|
- Backlinks: extract header section/lines around the link reference
|
||||||
|
- Character limit enforcement
|
||||||
|
- Folder exclusion filtering
|
||||||
|
- Returns discovered notes with metadata (depth, source, size, isBacklink)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TaskFileService (`task-file-service.ts`)
|
||||||
|
|
||||||
|
Writes `status: pending` task files to `OpenAugi/Tasks/` via the vault API — the trigger contract consumed by the OpenAugi task watcher (`openaugi up` in the parent repo). No Node.js modules, so it works on mobile. See [AGENT_TASKS.md](AGENT_TASKS.md).
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `createReviewPassTask()` - Queue "run the review pass"
|
||||||
|
- `createProcessDashboardTask()` - Queue "process the dashboard"
|
||||||
|
- `createDistillTask(context, sourceNote)` - Queue a distill of the given content (selection or note body)
|
||||||
|
|
||||||
|
**Contract:**
|
||||||
|
- File format defined in the parent repo's `src/openaugi/templates/task-template.md`
|
||||||
|
- Frontmatter: `status: pending`, `source_block_id: obsidian-plugin`, `source_note`
|
||||||
|
- Sections: `# title`, `## Context`, `## User instruction`, `## Task`, `## Human Todo`, `## Results`
|
||||||
|
- No `repo`/`working_dir` key — the watcher defaults the agent to the vault
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TaskDispatchService (`task-dispatch-service.ts`) — DEPRECATED
|
||||||
|
|
||||||
|
Manages tmux-based agent sessions dispatched from task notes. Deprecated: it launches tmux itself, bypassing the task watcher. Kept functional for existing users; to be removed over a release or two. Prefer `TaskFileService`.
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `launchOrAttach(file)` - If tmux session exists, attach; otherwise assemble context, create session, start agent
|
||||||
|
- `killSession(file)` - Kill tmux session for the given task note, update frontmatter
|
||||||
|
- `listActiveSessions()` - List all `task-*` tmux sessions
|
||||||
|
- `killSessionById(taskId)` - Kill session by ID (from session list modal)
|
||||||
|
- `openTerminal(sessionName)` - Open terminal app attached to tmux session
|
||||||
|
|
||||||
|
**Context Assembly:**
|
||||||
|
- Reads task note body (strips frontmatter)
|
||||||
|
- Gets linked notes via `DistillService.getLinkedNotes()`
|
||||||
|
- Aggregates via `DistillService.aggregateContent()`
|
||||||
|
- Writes to temp file at `/tmp/openaugi/task-{id}-context.md`
|
||||||
|
- Context injected into agent via `--append-system-prompt-file` (Claude Code)
|
||||||
|
|
||||||
|
**Frontmatter:**
|
||||||
|
- Reads `task_id`, `agent`, `status` via `metadataCache.getFileCache()`
|
||||||
|
- Updates `session_active`, `last_session` via `fileManager.processFrontMatter()`
|
||||||
|
|
||||||
|
**Shell Execution:**
|
||||||
|
- Uses Node.js `child_process.exec` (available via Electron)
|
||||||
|
- tmux commands: `has-session`, `new-session`, `send-keys`, `kill-session`, `list-sessions`
|
||||||
|
- Terminal opening: `osascript` for iTerm2 or Terminal.app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Types Reference
|
||||||
|
|
||||||
|
### Settings (`types/settings.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface OpenAugiSettings {
|
||||||
|
apiKey: string
|
||||||
|
defaultModel: 'gpt-5' | 'gpt-5-mini' | 'gpt-5-nano'
|
||||||
|
customModelOverride: string
|
||||||
|
summaryFolder: string // Default: 'OpenAugi/Summaries'
|
||||||
|
notesFolder: string // Default: 'OpenAugi/Notes'
|
||||||
|
promptsFolder: string // Default: 'OpenAugi/Prompts'
|
||||||
|
publishedFolder: string // Default: 'OpenAugi/Published'
|
||||||
|
useDataviewIfAvailable: boolean
|
||||||
|
enableDistillLogging: boolean
|
||||||
|
recentActivityDefaults: {
|
||||||
|
daysBack: number // Default: 7
|
||||||
|
excludeFolders: string[] // Default: ['Templates', 'Archive', 'OpenAugi']
|
||||||
|
filterJournalSections: boolean
|
||||||
|
dateHeaderFormat: string // Default: '### YYYY-MM-DD'
|
||||||
|
}
|
||||||
|
contextGatheringDefaults: {
|
||||||
|
linkDepth: 1 | 2 | 3 // Default: 1
|
||||||
|
maxCharacters: number // Default: 100000
|
||||||
|
filterRecentSectionsOnly: boolean
|
||||||
|
includeBacklinks: boolean // Default: true
|
||||||
|
backlinkContextLines: number // Default: 0 (0 = header section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Types (`types/context.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ContextGatheringConfig {
|
||||||
|
sourceMode: 'linked-notes' | 'recent-activity'
|
||||||
|
rootNote?: TFile
|
||||||
|
linkDepth: 1 | 2 | 3
|
||||||
|
maxCharacters: number
|
||||||
|
timeWindow?: {
|
||||||
|
mode: 'days-back' | 'date-range'
|
||||||
|
daysBack?: number
|
||||||
|
fromDate?: string
|
||||||
|
toDate?: string
|
||||||
|
}
|
||||||
|
excludeFolders: string[]
|
||||||
|
filterRecentSectionsOnly: boolean
|
||||||
|
journalSectionDays?: number
|
||||||
|
includeBacklinks?: boolean // Enable backlink discovery
|
||||||
|
backlinkContextLines?: number // 0 = header section, N = lines before/after
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveredNote {
|
||||||
|
file: TFile
|
||||||
|
depth: number // 0 = root, 1-3 = linked depth
|
||||||
|
discoveredVia: string // "root" | "linked from [[X]]" | "backlink from [[X]]" | "recent activity"
|
||||||
|
estimatedChars: number
|
||||||
|
included: boolean // false if exceeded character limit
|
||||||
|
isBacklink: boolean // true if discovered via backlink
|
||||||
|
backlinkSnippet?: string // Extracted header section/lines (backlinks only)
|
||||||
|
backlinkLine?: number // Line number of link (backlinks only)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatheredContext {
|
||||||
|
notes: DiscoveredNote[]
|
||||||
|
aggregatedContent: string
|
||||||
|
totalCharacters: number
|
||||||
|
totalNotes: number
|
||||||
|
config: ContextGatheringConfig
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Types (`types/transcript.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TranscriptResponse {
|
||||||
|
summary: string
|
||||||
|
notes: Array<{ title: string; content: string }>
|
||||||
|
tasks: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DistillResponse extends TranscriptResponse {
|
||||||
|
sourceNotes: string[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Registration
|
||||||
|
|
||||||
|
Commands are registered in `main.ts`:
|
||||||
|
|
||||||
|
| Command ID | Name | Purpose |
|
||||||
|
|------------|------|---------|
|
||||||
|
| `parse-transcript` | Parse transcript | Process voice transcript (legacy) |
|
||||||
|
| `distill-notes` | Distill linked notes | Distill with prompt selection (legacy) |
|
||||||
|
| `openaugi-process-notes` | Process notes | Unified flow: linked notes |
|
||||||
|
| `openaugi-process-recent` | Process recent activity | Unified flow: recent activity |
|
||||||
|
| `openaugi-save-context` | Save context | Save raw aggregated content |
|
||||||
|
| `augi-run-review-pass` | Augi: Run review pass | Write pending task file: "run the review pass" |
|
||||||
|
| `augi-process-dashboard` | Augi: Process dashboard | Write pending task file: "process the dashboard" |
|
||||||
|
| `augi-distill-selection` | Augi: Distill selection | Write pending distill task for selection/active note |
|
||||||
|
| `task-dispatch-launch` | Task dispatch: Launch or attach | Deprecated — launch or attach to agent tmux session |
|
||||||
|
| `task-dispatch-kill` | Task dispatch: Kill session | Deprecated — kill active tmux session for task note |
|
||||||
|
| `task-dispatch-list` | Task dispatch: List active sessions | Deprecated — show modal of all active task sessions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unified Three-Stage Pipeline
|
||||||
|
|
||||||
|
The new commands use a consistent flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Stage 1: ContextGatheringModal │
|
||||||
|
│ - Choose source mode │
|
||||||
|
│ - Set depth, limits, filters │
|
||||||
|
│ - Click "Discover Notes" │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Stage 2: ContextSelectionModal │
|
||||||
|
│ - Checkbox list of discovered notes│
|
||||||
|
│ - Toggle individual notes │
|
||||||
|
│ - See character/token counts │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Stage 3: ContextPreviewModal │
|
||||||
|
│ - Final preview of context │
|
||||||
|
│ - Path A: Save raw (no AI) │
|
||||||
|
│ - Path B: Process with AI │
|
||||||
|
│ → PromptSelectionModal │
|
||||||
|
│ → Choose: Distill or Publish │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Algorithms
|
||||||
|
|
||||||
|
### BFS Link Traversal
|
||||||
|
|
||||||
|
Used by `ContextGatheringService.discoverLinkedNotes()`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Queue = [RootNote at depth 0]
|
||||||
|
|
||||||
|
while Queue not empty:
|
||||||
|
note = Queue.dequeue()
|
||||||
|
|
||||||
|
if totalChars + note.size > maxChars:
|
||||||
|
mark note as excluded (overflow)
|
||||||
|
continue
|
||||||
|
|
||||||
|
mark note as included
|
||||||
|
totalChars += note.size
|
||||||
|
|
||||||
|
if depth < maxDepth:
|
||||||
|
for each link in note:
|
||||||
|
if not already discovered:
|
||||||
|
Queue.enqueue(linkedNote at depth + 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Aggregation Format
|
||||||
|
|
||||||
|
Notes are combined with clear boundaries:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Note: [Note Title 1]
|
||||||
|
|
||||||
|
[Full content of note 1]
|
||||||
|
|
||||||
|
# Note: [Note Title 2]
|
||||||
|
|
||||||
|
[Full content of note 2]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backlink Mapping
|
||||||
|
|
||||||
|
During file creation:
|
||||||
|
1. Register mapping: `"Original Title"` → `"sanitized-filename"`
|
||||||
|
2. When writing content, replace all `[[Original Title]]` with `[[sanitized-filename]]`
|
||||||
|
3. Ensures backlinks work despite filename sanitization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extending the Plugin
|
||||||
|
|
||||||
|
### Adding a New Command
|
||||||
|
|
||||||
|
1. Define command handler in `main.ts`
|
||||||
|
2. Register with `addCommand({ id, name, callback })`
|
||||||
|
3. Use existing services or create new ones
|
||||||
|
|
||||||
|
### Adding a New Service
|
||||||
|
|
||||||
|
1. Create file in `src/services/`
|
||||||
|
2. Export class with constructor accepting `App` and dependencies
|
||||||
|
3. Initialize in `main.ts` `initializeServices()`
|
||||||
|
4. Add to plugin class properties
|
||||||
|
|
||||||
|
### Adding Settings
|
||||||
|
|
||||||
|
1. Add property to `OpenAugiSettings` in `types/settings.ts`
|
||||||
|
2. Add default value to `DEFAULT_SETTINGS`
|
||||||
|
3. Add UI control in `ui/settings-tab.ts` `display()` method
|
||||||
|
|
||||||
|
### Adding a New Modal
|
||||||
|
|
||||||
|
1. Create file in `src/ui/`
|
||||||
|
2. Extend `Modal` from Obsidian
|
||||||
|
3. Implement `onOpen()` and `onClose()`
|
||||||
|
4. Use callback pattern for returning results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Development build with watch
|
||||||
|
npm run build # Production build
|
||||||
|
npm run typecheck # TypeScript checking
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
See [PUBLISHING.md](PUBLISHING.md) for the complete release process.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Folders
|
||||||
|
|
||||||
|
| Folder | Purpose | Example Files |
|
||||||
|
|--------|---------|---------------|
|
||||||
|
| `OpenAugi/Summaries/` | Summary/distilled files | `MyNote - distilled.md` |
|
||||||
|
| `OpenAugi/Notes/` | Atomic note sessions | `Distill MyNote 2025-01-12 14-30-00/` |
|
||||||
|
| `OpenAugi/Published/` | Blog posts | `My Post - Published 2025-01-12.md` |
|
||||||
|
| `OpenAugi/Prompts/` | Custom prompt templates | `Technical Focus.md` |
|
||||||
|
| `OpenAugi/Logs/` | Debug logs (if enabled) | Distill context logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Obsidian APIs Used
|
||||||
|
|
||||||
|
- `Plugin` - Base plugin class
|
||||||
|
- `App` - Vault and workspace access
|
||||||
|
- `TFile` - File abstraction
|
||||||
|
- `Modal` - Dialog windows
|
||||||
|
- `Notice` - Toast notifications
|
||||||
|
- `PluginSettingTab` - Settings panel
|
||||||
|
- `MetadataCache` - Link resolution
|
||||||
|
- `Vault` - File read/write operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External Dependencies
|
||||||
|
|
||||||
|
- **OpenAI API** - Chat completions endpoint
|
||||||
|
- **Dataview Plugin** (optional) - Query execution for advanced link discovery
|
||||||
170
docs/PUBLISHING.md
Normal file
170
docs/PUBLISHING.md
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
# Publishing a New Release
|
||||||
|
|
||||||
|
**name:** publish-release
|
||||||
|
**description:** How to cut and publish a new OpenAugi plugin version safely, keep it consistent with the Obsidian community store, and what to do if the plugin gets de-listed.
|
||||||
|
**when to use:** Any time you ship a new version of the plugin (features, fixes) or need to relist it in the community store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR — use the script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Write the release notes first (they become the GitHub release body):
|
||||||
|
# docs/release-notes/X.Y.Z.md
|
||||||
|
# 2. Then run:
|
||||||
|
./scripts/release.sh X.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
`release.sh` does the whole thing and **verifies it**: checks preconditions →
|
||||||
|
runs tests + build → bumps `manifest.json`, `package.json`, **and
|
||||||
|
`versions.json`** in lockstep → commits, pushes, tags → waits for CI → **publishes
|
||||||
|
the draft immediately** → verifies that the repo-root manifest, the released
|
||||||
|
manifest asset, and the tag all equal `X.Y.Z`. It stops loudly on any mismatch.
|
||||||
|
|
||||||
|
Everything below explains *why* it does what it does, and the manual fallback.
|
||||||
|
|
||||||
|
## The three things Obsidian actually checks
|
||||||
|
|
||||||
|
1. **`manifest.json` at the repo-root HEAD** — Obsidian reads only the `version`
|
||||||
|
field here to decide "what's the latest version."
|
||||||
|
2. **A GitHub release whose tag == that version** (no `v` prefix), with
|
||||||
|
`main.js`, `manifest.json`, and `styles.css` attached as assets. Obsidian
|
||||||
|
downloads the files from the *release*, not from the repo tree.
|
||||||
|
3. **`versions.json`** — maps each plugin version → minimum Obsidian version.
|
||||||
|
Used to decide whether a given user's Obsidian is new enough to be offered the
|
||||||
|
update.
|
||||||
|
|
||||||
|
If the root manifest advertises a version that has **no matching published
|
||||||
|
release**, the plugin is in an inconsistent state — and Obsidian's automated
|
||||||
|
validation can drop it from the store. Never leave that window open. (This is
|
||||||
|
what bit us on 0.6.0.)
|
||||||
|
|
||||||
|
## Version files — all THREE must move together
|
||||||
|
|
||||||
|
| File | Field | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `manifest.json` | `version` | source of truth for the release tag |
|
||||||
|
| `package.json` | `version` | keep in sync (npm/build hygiene) |
|
||||||
|
| `versions.json` | add `"X.Y.Z": "<minAppVersion>"` | **easy to forget — it's why updates/relisting break** |
|
||||||
|
|
||||||
|
The `tests/version-consistency.test.ts` guard test fails the build if these ever
|
||||||
|
disagree. `npm test` therefore catches a forgotten `versions.json` bump before
|
||||||
|
you tag.
|
||||||
|
|
||||||
|
> Historical note: earlier versions of this doc listed only `manifest.json` and
|
||||||
|
> `package.json`. `versions.json` was missing entirely, which broke the
|
||||||
|
> `npm version` script and left the store metadata incomplete. Don't regress.
|
||||||
|
|
||||||
|
## Pre-release checklist
|
||||||
|
|
||||||
|
- [ ] Features/fixes merged to master; `git status` clean; local == `origin/master`
|
||||||
|
- [ ] `npm test` passes (includes the version-consistency guard) — see [TESTING.md](TESTING.md)
|
||||||
|
- [ ] `npm run build` clean (tsc + esbuild)
|
||||||
|
- [ ] Manually tested in a real vault (`npm run dev`, reload plugin, exercise the change)
|
||||||
|
- [ ] `docs/CODEBASE_MAP.md` / `README.md` updated for any behavior change
|
||||||
|
- [ ] Release notes written to `docs/release-notes/X.Y.Z.md`
|
||||||
|
|
||||||
|
## Release notes format
|
||||||
|
|
||||||
|
Write `docs/release-notes/X.Y.Z.md` before releasing. Claude: diff commits since
|
||||||
|
the previous tag and focus on user-facing changes.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# X.Y.Z — <one-line theme>
|
||||||
|
|
||||||
|
## Added / Changed / Fixed / Deprecated
|
||||||
|
- <user-facing change>: <why the user cares>
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- <breaking changes or upgrade steps, if any>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual fallback (if you can't use the script)
|
||||||
|
|
||||||
|
Only if `release.sh` can't run. Do the steps **in this order** and don't stop
|
||||||
|
before publishing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Bump ALL THREE (or run: npm version X.Y.Z — see note below)
|
||||||
|
# manifest.json .version, package.json .version, versions.json add "X.Y.Z":"<minAppVersion>"
|
||||||
|
npm test && npm run build # guard test must be green
|
||||||
|
|
||||||
|
git add manifest.json package.json versions.json docs/release-notes/X.Y.Z.md
|
||||||
|
git commit -m "Release X.Y.Z"
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
git tag -a X.Y.Z -m "X.Y.Z" # annotated, no 'v' prefix
|
||||||
|
git push origin X.Y.Z # triggers .github/workflows/release.yml → draft
|
||||||
|
|
||||||
|
# 2. Wait for the workflow, then PUBLISH RIGHT AWAY (don't leave it a draft):
|
||||||
|
gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status
|
||||||
|
gh release edit X.Y.Z --draft=false --latest --notes-file docs/release-notes/X.Y.Z.md
|
||||||
|
|
||||||
|
# 3. Verify root manifest, released asset, and tag all say X.Y.Z:
|
||||||
|
curl -sL https://raw.githubusercontent.com/bitsofchris/openaugi-obsidian-plugin/master/manifest.json | grep version
|
||||||
|
curl -sL https://github.com/bitsofchris/openaugi-obsidian-plugin/releases/download/X.Y.Z/manifest.json | grep version
|
||||||
|
```
|
||||||
|
|
||||||
|
> `npm version X.Y.Z` also works: `.npmrc` sets `tag-version-prefix=""` (so the
|
||||||
|
> tag has no `v`), and the `version` npm script runs `version-bump.mjs` which
|
||||||
|
> updates `manifest.json` + `versions.json`, while npm bumps `package.json`. It
|
||||||
|
> commits and tags for you — but it does **not** push or publish, so you still
|
||||||
|
> owe steps 1–3 above (push, wait, publish, verify). The script does all of it.
|
||||||
|
|
||||||
|
## Community-store listing health
|
||||||
|
|
||||||
|
The plugin is listed in Obsidian's store via
|
||||||
|
[`obsidianmd/obsidian-releases`](https://github.com/obsidianmd/obsidian-releases).
|
||||||
|
That repo's `community-plugins.json` is now an **automated mirror** of Obsidian's
|
||||||
|
internal list (commits read `chore: Mirror community plugins and themes`), so you
|
||||||
|
can't fix listing by PR'ing that file — it gets overwritten.
|
||||||
|
|
||||||
|
Check whether we're currently listed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sL https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json | grep -i openaugi
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Listed** → core Settings → Community plugins → "Check for updates" works for users.
|
||||||
|
- **Not listed** → core update check can't see us; users must use **BRAT** or a
|
||||||
|
manual install. See the relisting steps below.
|
||||||
|
|
||||||
|
### If the plugin gets removed from the store
|
||||||
|
|
||||||
|
This happened on **2026-07-07** (removed in mirror commit `85db6f23`, bundled with
|
||||||
|
several unrelated plugins — an automated batch removal). The mirror commits carry
|
||||||
|
no per-plugin reason and Obsidian doesn't file an issue on your repo, so:
|
||||||
|
|
||||||
|
1. **Fix consistency first** (so a resubmit passes validation): run a clean
|
||||||
|
release with `release.sh` — root manifest version must equal a *published*
|
||||||
|
release with the three assets, `versions.json` present, `README`/`LICENSE`
|
||||||
|
present. (All true as of 0.6.0.)
|
||||||
|
2. **Ask Obsidian why + request reinstatement** — don't blindly resubmit an
|
||||||
|
auto-removal. Post in the Obsidian Discord **`#plugin-dev`** channel (fastest;
|
||||||
|
plugin-review team is active there) or the forum **Developers: Plugin & API**
|
||||||
|
category. Cite: repo URL, removal date `2026-07-07`, mirror commit `85db6f23`.
|
||||||
|
3. **If they tell you to resubmit:** the current path is the web portal at
|
||||||
|
**community.obsidian.md** → sign in with your Obsidian account → link GitHub →
|
||||||
|
Plugins → New plugin → enter the repo URL. (The old "PR to
|
||||||
|
community-plugins.json" flow is dead now that the file is bot-mirrored.)
|
||||||
|
4. **Gotcha — "An entry already exists for this repository":** because the old
|
||||||
|
entry is archived rather than deleted, resubmitting the same repo URL can be
|
||||||
|
rejected. Ask a moderator in `#plugin-dev` to delete the archived entry, then
|
||||||
|
resubmit.
|
||||||
|
|
||||||
|
Sources: Obsidian plugin submission docs; obsidian-releases repo (mirror
|
||||||
|
architecture); forum thread "Cannot resubmit plugin after archiving: 'An entry
|
||||||
|
already exists for this repository'".
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Tag already exists / wrong version tagged**
|
||||||
|
```bash
|
||||||
|
git tag -d X.Y.Z
|
||||||
|
git push origin --delete X.Y.Z # if already pushed
|
||||||
|
# fix versions, re-run ./scripts/release.sh X.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workflow failed** — `gh run view <id>`, fix, delete the tag, re-run the script.
|
||||||
|
|
||||||
|
**Release stuck as draft** — `gh release edit X.Y.Z --draft=false --latest`.
|
||||||
160
docs/TASK_DISPATCH.md
Normal file
160
docs/TASK_DISPATCH.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Task Dispatch
|
||||||
|
|
||||||
|
> **⚠️ Deprecated.** Task Dispatch launches tmux sessions directly from the
|
||||||
|
> plugin — a parallel execution path that bypasses the OpenAugi task
|
||||||
|
> watcher and will drift from it (different session names, duplicate repo
|
||||||
|
> settings). It still works, but it will be removed over a release or two.
|
||||||
|
> New workflows should use the **Augi commands** ([Agent Tasks](AGENT_TASKS.md)),
|
||||||
|
> which write task files to `OpenAugi/Tasks/` for the task watcher to
|
||||||
|
> execute — no tmux or macOS dependency in the plugin, and they work on
|
||||||
|
> mobile. If you rely on Task Dispatch without the Python watcher, nothing
|
||||||
|
> breaks today; plan to install [OpenAugi](https://github.com/bitsofchris/openaugi)
|
||||||
|
> and run `openaugi up` before it is removed.
|
||||||
|
|
||||||
|
Task Dispatch lets you launch AI coding agents directly from Obsidian task notes. Each task gets its own tmux session with full context from your vault — the task note body, all linked notes, and access to the OpenAugi MCP server for searching your vault.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **tmux** — Install with `brew install tmux`. The plugin auto-detects the binary, or you can set the path manually in settings.
|
||||||
|
- **An agent CLI** — Claude Code (`claude`) is the default. Any CLI agent that accepts a system prompt flag works.
|
||||||
|
- **macOS** — Terminal opening uses AppleScript (iTerm2 or Terminal.app).
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Create a task note with `task_id` in frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
task_id: fix-auth-bug
|
||||||
|
working_dir: my-repo
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Fix the authentication bug where sessions expire after 5 minutes.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The auth middleware is in `src/middleware/auth.ts`. The JWT expiry is hardcoded.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the command **Task dispatch: Launch or attach** (from the command palette).
|
||||||
|
3. A terminal window opens with the agent already running, pre-loaded with context from your note and all linked notes.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When you launch a task:
|
||||||
|
|
||||||
|
1. **Context assembly** — The plugin reads the task note body (stripped of frontmatter), then gathers all linked notes via the same link traversal used by Distill. Everything is concatenated into a single context bundle.
|
||||||
|
2. **Temp file** — The context is written to a temp file (default: `/tmp/openaugi/task-{id}-context.md`).
|
||||||
|
3. **tmux session** — A new tmux session named `task-{id}` is created in the configured working directory.
|
||||||
|
4. **Agent launch** — The agent CLI is invoked with the context file, e.g.:
|
||||||
|
```
|
||||||
|
cd '/path/to/repo' && claude --append-system-prompt-file '/tmp/openaugi/task-fix-auth-bug-context.md' "..."
|
||||||
|
```
|
||||||
|
5. **Terminal opens** — iTerm2 or Terminal.app opens attached to the session.
|
||||||
|
|
||||||
|
If the session already exists, the plugin just attaches to it (no duplicate sessions).
|
||||||
|
|
||||||
|
## Task Note Frontmatter
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `task_id` | Yes | Unique identifier for the task. Also accepts `task-id`. |
|
||||||
|
| `working_dir` | No | Where the agent starts. Can be a **repo name** (mapped in settings), an **absolute path**, or a **vault-relative path**. Falls back to the default working directory setting. |
|
||||||
|
|
||||||
|
### Working Directory Resolution
|
||||||
|
|
||||||
|
The `working_dir` value is resolved in this order:
|
||||||
|
|
||||||
|
1. **Named repo** — If it matches a name in your configured Repository Paths, the mapped absolute path is used. Case-insensitive.
|
||||||
|
2. **Absolute path** — If it starts with `/`, used as-is.
|
||||||
|
3. **Vault-relative path** — Otherwise, resolved against the vault root.
|
||||||
|
4. **Default** — If `working_dir` is absent, the Default Working Directory setting is used.
|
||||||
|
5. **Fallback** — If nothing is configured, falls back to `$HOME`.
|
||||||
|
|
||||||
|
The directory is created automatically if it doesn't exist.
|
||||||
|
|
||||||
|
### Example: Using Repo Names
|
||||||
|
|
||||||
|
After configuring repository paths in settings:
|
||||||
|
|
||||||
|
| Name | Path |
|
||||||
|
|------|------|
|
||||||
|
| my-app | /Users/chris/repos/my-app |
|
||||||
|
| api-server | /Users/chris/repos/api-server |
|
||||||
|
| infra | /Users/chris/repos/infrastructure |
|
||||||
|
|
||||||
|
Your frontmatter just needs the short name:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
task_id: add-caching
|
||||||
|
working_dir: api-server
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Task dispatch: Launch or attach** | Creates a new session or attaches to an existing one for the current task note. |
|
||||||
|
| **Task dispatch: Kill session** | Kills the tmux session for the current task note and cleans up the temp context file. |
|
||||||
|
| **Task dispatch: List active sessions** | Opens a modal showing all active `task-*` tmux sessions with options to attach or kill each. |
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
All settings are under **Settings > OpenAugi > Task Dispatch**.
|
||||||
|
|
||||||
|
### Terminal Application
|
||||||
|
|
||||||
|
Which app opens when attaching to a session.
|
||||||
|
|
||||||
|
- **iTerm2** (default)
|
||||||
|
- **Terminal.app**
|
||||||
|
|
||||||
|
### tmux Path
|
||||||
|
|
||||||
|
Absolute path to the tmux binary. Leave blank to auto-detect (checks `/opt/homebrew/bin/tmux`, `/usr/local/bin/tmux`, `/usr/bin/tmux`, then `which tmux`). Use the **Detect** button to find it automatically.
|
||||||
|
|
||||||
|
### Default Working Directory
|
||||||
|
|
||||||
|
The directory the agent starts in when a task note doesn't specify `working_dir`. Can be vault-relative (e.g., `OpenAugi/Tasks`) or absolute. Default: `OpenAugi/Tasks`.
|
||||||
|
|
||||||
|
### Repository Paths
|
||||||
|
|
||||||
|
Map short names to absolute folder paths. Each entry has:
|
||||||
|
|
||||||
|
- **Name** — The short name you'll use in frontmatter `working_dir` (e.g., `my-repo`).
|
||||||
|
- **Path** — The absolute filesystem path (e.g., `/Users/chris/repos/my-repo`).
|
||||||
|
- **Browse** — Opens the native folder picker to select a directory. Auto-fills the name from the folder basename if empty.
|
||||||
|
|
||||||
|
### Default Agent
|
||||||
|
|
||||||
|
Which agent CLI to use. Default: Claude Code (`claude`).
|
||||||
|
|
||||||
|
### Max Context Characters
|
||||||
|
|
||||||
|
Upper limit on the context bundle size. Content beyond this is truncated. Default: `200,000`.
|
||||||
|
|
||||||
|
### Context Temp Directory
|
||||||
|
|
||||||
|
Where temporary context files are written. Default: `/tmp/openaugi`. Files are cleaned up when sessions are killed.
|
||||||
|
|
||||||
|
## Agent Configuration
|
||||||
|
|
||||||
|
The default agent is Claude Code with `--append-system-prompt` as the context flag. The agent config has four fields:
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `id` | `claude-code` | Internal identifier. |
|
||||||
|
| `name` | `Claude Code` | Display name in settings. |
|
||||||
|
| `command` | `claude` | The CLI command to run. |
|
||||||
|
| `contextFlag` | `--append-system-prompt` | The CLI flag for injecting context. If the flag ends with `-file` (e.g., `--append-system-prompt-file`), the temp file path is passed directly. Otherwise, the file content is expanded inline via `$(cat ...)`. |
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Link related context** — Any notes linked from your task note (`[[Design Doc]]`, `[[API Spec]]`) are automatically included in the context bundle. Structure your task notes with relevant links.
|
||||||
|
- **Keep task IDs unique** — The tmux session name is derived from `task_id`. Duplicates will collide.
|
||||||
|
- **Session persistence** — tmux sessions survive Obsidian restarts. Use "List active sessions" to find and reattach to running agents.
|
||||||
|
- **MCP access** — The agent's context includes a note that the OpenAugi MCP server is available for vault searches, so agents can look up additional information beyond what's in the initial context.
|
||||||
190
docs/TESTING.md
Normal file
190
docs/TESTING.md
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
Automated tests for the OpenAugi plugin. Run these before every release and when developing new features.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests once
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Watch mode (re-runs on file changes)
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests run in under 1 second. No Obsidian app, no API keys, no manual setup needed.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The plugin runs inside Obsidian's Electron environment, but our tests don't need Obsidian at all. Instead:
|
||||||
|
|
||||||
|
1. **Mock Obsidian API** (`tests/mocks/`) — A filesystem-backed mock that simulates `Vault`, `MetadataCache`, `App`, and `TFile` by reading real markdown files and parsing `[[wikilinks]]` to build link graphs.
|
||||||
|
|
||||||
|
2. **Test vault** (`tests/vault/`) — A small set of markdown files committed to the repo that represent various note structures (links, backlinks, journal dates, dataview blocks, checkboxes, etc.).
|
||||||
|
|
||||||
|
3. **Vitest** — Fast TypeScript test runner. Config in `vitest.config.ts`.
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
| File | What it tests |
|
||||||
|
|------|--------------|
|
||||||
|
| `tests/filename-utils.test.ts` | `sanitizeFilename`, `BacklinkMapper`, file collision handling |
|
||||||
|
| `tests/openai-service.test.ts` | Prompt construction, context extraction, API response parsing |
|
||||||
|
| `tests/distill-service.test.ts` | Link extraction, backlinks, content aggregation, journal filtering, dataview stripping |
|
||||||
|
| `tests/file-service.test.ts` | File/folder creation, session folders, summaries, published posts |
|
||||||
|
| `tests/context-gathering-service.test.ts` | BFS link traversal, depth limits, backlink discovery, character limits, folder exclusion |
|
||||||
|
|
||||||
|
## Adding Tests for a New Feature
|
||||||
|
|
||||||
|
### 1. Decide which test file
|
||||||
|
|
||||||
|
Match your feature to the service it lives in:
|
||||||
|
|
||||||
|
| If you changed... | Add tests to... |
|
||||||
|
|-------------------|-----------------|
|
||||||
|
| `src/utils/filename-utils.ts` | `tests/filename-utils.test.ts` |
|
||||||
|
| `src/services/openai-service.ts` | `tests/openai-service.test.ts` |
|
||||||
|
| `src/services/distill-service.ts` | `tests/distill-service.test.ts` |
|
||||||
|
| `src/services/file-service.ts` | `tests/file-service.test.ts` |
|
||||||
|
| `src/services/context-gathering-service.ts` | `tests/context-gathering-service.test.ts` |
|
||||||
|
| New service | Create `tests/your-service.test.ts` |
|
||||||
|
|
||||||
|
### 2. Add test vault fixtures (if needed)
|
||||||
|
|
||||||
|
If your feature needs specific note content to test against, add markdown files to `tests/vault/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/vault/
|
||||||
|
├── Root Note.md # Forward links to A and B
|
||||||
|
├── Linked Note A.md # Links to Deep Note
|
||||||
|
├── Linked Note B.md # Links back to A
|
||||||
|
├── Backlink Source.md # Links TO Root Note
|
||||||
|
├── Journal Note.md # Date headers (### YYYY-MM-DD)
|
||||||
|
├── Dataview Note.md # ```dataview blocks
|
||||||
|
├── Collection Note.md # Checkbox links [x] / [ ]
|
||||||
|
├── Context Note.md # context: section
|
||||||
|
├── Special Characters!.md # Filename sanitization edge case
|
||||||
|
├── Deeply Linked/
|
||||||
|
│ └── Deep Note.md # Depth-2 traversal
|
||||||
|
└── Excluded Folder/
|
||||||
|
└── Should Skip.md # Folder exclusion
|
||||||
|
```
|
||||||
|
|
||||||
|
After adding a new fixture file, the mock `MetadataCache` automatically picks it up — it scans all `.md` files and parses their `[[links]]` on initialization.
|
||||||
|
|
||||||
|
### 3. Write the test
|
||||||
|
|
||||||
|
Pattern for **pure unit tests** (no Obsidian needed):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { myFunction } from '../src/utils/my-utils';
|
||||||
|
|
||||||
|
describe('myFunction', () => {
|
||||||
|
it('does the expected thing', () => {
|
||||||
|
expect(myFunction('input')).toBe('output');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern for **integration tests** (using mock Obsidian API):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { MyService } from '../src/services/my-service';
|
||||||
|
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const VAULT_DIR = path.resolve(__dirname, 'vault');
|
||||||
|
|
||||||
|
describe('MyService', () => {
|
||||||
|
let app: ReturnType<typeof createMockApp>;
|
||||||
|
let service: MyService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createMockApp(VAULT_DIR);
|
||||||
|
service = new MyService(app as any, /* other deps */);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers linked notes', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const result = await service.someMethod(rootFile);
|
||||||
|
expect(result).toContain('expected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern for **testing OpenAI calls** (mock `fetch`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
it('calls API correctly', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{ message: { content: '{"summary":"test"}', refusal: null } }]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const result = await service.someApiCall('input');
|
||||||
|
expect(result.summary).toBe('test');
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern for **file output tests** (uses temp directories):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { MockApp } from './mocks/obsidian-mock';
|
||||||
|
|
||||||
|
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
|
||||||
|
let outputDir: string;
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
counter++;
|
||||||
|
outputDir = path.join(BASE_OUTPUT_DIR, `run-${counter}-${Date.now()}`);
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
// Point MockApp at the output dir, not the fixture vault
|
||||||
|
app = new MockApp(outputDir);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run and verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's NOT Tested (and why)
|
||||||
|
|
||||||
|
| Area | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| UI modals | Tightly coupled to Obsidian DOM — test manually |
|
||||||
|
| Task dispatch (tmux) | Requires system-level tmux — test manually |
|
||||||
|
| Dataview plugin queries | Requires running Dataview plugin — mock returns empty |
|
||||||
|
| Real OpenAI API calls | Costs money — we mock `fetch` instead |
|
||||||
|
|
||||||
|
## Mock Obsidian API Reference
|
||||||
|
|
||||||
|
The mock (`tests/mocks/obsidian-mock.ts`) supports:
|
||||||
|
|
||||||
|
| Obsidian API | Mock behavior |
|
||||||
|
|-------------|---------------|
|
||||||
|
| `vault.read(file)` | Reads from filesystem |
|
||||||
|
| `vault.create(path, content)` | Writes to filesystem |
|
||||||
|
| `vault.createFolder(path)` | `mkdir -p` |
|
||||||
|
| `vault.getMarkdownFiles()` | Walks directory tree |
|
||||||
|
| `vault.getAbstractFileByPath(path)` | `fs.existsSync` lookup |
|
||||||
|
| `vault.adapter.exists(path)` | `fs.existsSync` |
|
||||||
|
| `vault.adapter.stat(path)` | `fs.statSync` |
|
||||||
|
| `metadataCache.getFileCache(file)` | Parses `[[links]]` from file content |
|
||||||
|
| `metadataCache.getFirstLinkpathDest(link, source)` | Resolves links by basename matching |
|
||||||
|
| `metadataCache.resolvedLinks` | Pre-built link graph from all vault files |
|
||||||
|
|
||||||
|
If a test needs an API that isn't mocked, add it to `tests/mocks/obsidian-mock.ts`.
|
||||||
26
docs/release-notes/0.6.0.md
Normal file
26
docs/release-notes/0.6.0.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 0.6.0 — Agent tasks via the vault (task-file trigger)
|
||||||
|
|
||||||
|
## Added — Augi commands
|
||||||
|
|
||||||
|
Three new commands queue work for the [OpenAugi task watcher](https://github.com/bitsofchris/openaugi) by writing a `status: pending` task file to `OpenAugi/Tasks/` through the Obsidian vault API. No shell-out, no HTTP, no Node modules — so they work on **Obsidian Mobile** against a synced vault.
|
||||||
|
|
||||||
|
- **Augi: Run review pass** — queues a full review pass (route new blocks → refresh views → surface Dashboard nominations).
|
||||||
|
- **Augi: Process dashboard** — queues execution of your Dashboard nomination answers only.
|
||||||
|
- **Augi: Distill selection** — queues a distill of the current editor selection (or the active note's body when nothing is selected) through the distill lens. The plugin is the scope selector; the selected text becomes the task's context verbatim.
|
||||||
|
|
||||||
|
The task-file format is shared with the `openaugi review` CLI and the `zzz:` capture grammar — one contract, defined in the parent repo's `templates/task-template.md`.
|
||||||
|
|
||||||
|
**Requires** the OpenAugi task watcher running against your vault (`openaugi up`).
|
||||||
|
|
||||||
|
## Deprecated — Task Dispatch
|
||||||
|
|
||||||
|
The legacy **Task Dispatch** feature (Launch/attach, Kill session, List active sessions) launches tmux sessions directly from the plugin, bypassing the task watcher. It is now **deprecated** and will be removed over the next release or two. It still works for existing users; the settings section and docs steer new use to the Augi commands + task watcher instead.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- New `docs/AGENT_TASKS.md` covering the commands, the task-file contract, and the migration path.
|
||||||
|
- README and CODEBASE_MAP updated to lead with the task-file flow.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
New commands are additive and backward compatible; nothing existing was removed.
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
{
|
{
|
||||||
"id": "openaugi",
|
"id": "openaugi",
|
||||||
"name": "OpenAugi",
|
"name": "OpenAugi",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
"minAppVersion": "1.8.9",
|
"minAppVersion": "1.8.9",
|
||||||
"description": "Parse your voice notes into atomic notes, tasks, and summaries. OpenAugi is the voice to self-organizing second brain. While taking a voice note say 'auggie' to help the agent. Augmented Intelligence is AI for thinkers.",
|
"description": "Process information faster with augmented intelligence (AI for thinkers). Parse your voice notes into atomic notes, tasks, and summaries. Grab context from dataview queries and linked notes. De-duplicate and merge atomic ideas into a clean, organized vault.",
|
||||||
"author": "Chris Lettieri",
|
"author": "Chris Lettieri",
|
||||||
"authorUrl": "https://bitsofchris.com",
|
"authorUrl": "https://bitsofchris.com",
|
||||||
"isDesktopOnly": false,
|
"isDesktopOnly": false
|
||||||
"css": "styles.css"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3955
package-lock.json
generated
Normal file
3955
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -1,26 +1,27 @@
|
||||||
{
|
{
|
||||||
"name": "open-augi",
|
"name": "open-augi",
|
||||||
"version": "1.0.0",
|
"version": "0.6.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node esbuild.config.mjs",
|
"dev": "node esbuild.config.mjs",
|
||||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^16.18.126",
|
"@types/node": "^20.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||||
"@typescript-eslint/parser": "5.29.0",
|
"@typescript-eslint/parser": "5.29.0",
|
||||||
"builtin-modules": "3.3.0",
|
"builtin-modules": "3.3.0",
|
||||||
"esbuild": "0.17.3",
|
"esbuild": "^0.17.3",
|
||||||
"obsidian": "^1.8.7",
|
"obsidian": "^1.8.7",
|
||||||
"tslib": "2.4.0",
|
"tslib": "2.4.0",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^4.7.4",
|
||||||
},
|
"vitest": "^3.2.4"
|
||||||
"dependencies": {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
114
scripts/release.sh
Executable file
114
scripts/release.sh
Executable file
|
|
@ -0,0 +1,114 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# release.sh — publish a new OpenAugi plugin version, safely and verifiably.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/release.sh X.Y.Z
|
||||||
|
#
|
||||||
|
# Why this exists: doing releases by hand drifted manifest.json / package.json /
|
||||||
|
# versions.json out of sync (0.6.0 shipped with a stale package.json and no
|
||||||
|
# versions.json) and left a window where the repo advertised a version that had
|
||||||
|
# no published release — which is plausibly why the plugin was auto-removed from
|
||||||
|
# the community store. This script bumps all three files in lockstep, then does
|
||||||
|
# push → tag → wait-for-CI → PUBLISH → verify in one shot so that window never
|
||||||
|
# opens. See docs/PUBLISHING.md.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="bitsofchris/openaugi-obsidian-plugin"
|
||||||
|
die() { printf '\n\033[31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
|
||||||
|
ok() { printf '\033[32m✓ %s\033[0m\n' "$*"; }
|
||||||
|
step(){ printf '\n\033[1m▸ %s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
[[ -n "$VERSION" ]] || die "usage: ./scripts/release.sh X.Y.Z"
|
||||||
|
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "version must be X.Y.Z (no 'v' prefix): got '$VERSION'"
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
NOTES="docs/release-notes/${VERSION}.md"
|
||||||
|
|
||||||
|
# ── Preconditions ────────────────────────────────────────────────────────────
|
||||||
|
step "Preconditions"
|
||||||
|
command -v gh >/dev/null || die "gh CLI not found"
|
||||||
|
gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
|
||||||
|
[[ "$(git branch --show-current)" == "master" ]] || die "must be on master"
|
||||||
|
[[ -z "$(git status --porcelain)" ]] || die "working tree not clean — commit or stash first"
|
||||||
|
git fetch -q origin master
|
||||||
|
[[ "$(git rev-parse HEAD)" == "$(git rev-parse origin/master)" ]] || die "local master differs from origin/master — push/pull first"
|
||||||
|
git rev-parse "$VERSION" >/dev/null 2>&1 && die "tag $VERSION already exists locally"
|
||||||
|
[[ -f "$NOTES" ]] || die "release notes not found: $NOTES (write them first — they become the GitHub release body)"
|
||||||
|
ok "on master, clean, synced; notes present; tag is free"
|
||||||
|
|
||||||
|
# ── Quality gate ─────────────────────────────────────────────────────────────
|
||||||
|
step "Tests + build"
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
ok "tests + build pass"
|
||||||
|
|
||||||
|
# ── Bump all three version files in lockstep ─────────────────────────────────
|
||||||
|
step "Bump manifest.json / package.json / versions.json → $VERSION"
|
||||||
|
node - "$VERSION" <<'NODE'
|
||||||
|
const fs = require('fs');
|
||||||
|
const v = process.argv[2];
|
||||||
|
const write = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, '\t') + '\n');
|
||||||
|
|
||||||
|
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
|
||||||
|
manifest.version = v;
|
||||||
|
write('manifest.json', manifest);
|
||||||
|
|
||||||
|
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||||
|
pkg.version = v;
|
||||||
|
write('package.json', pkg);
|
||||||
|
|
||||||
|
const versions = JSON.parse(fs.readFileSync('versions.json', 'utf8'));
|
||||||
|
versions[v] = manifest.minAppVersion; // version -> min Obsidian version
|
||||||
|
write('versions.json', versions);
|
||||||
|
|
||||||
|
console.log(` manifest ${manifest.version} · package ${pkg.version} · versions[${v}]=${versions[v]}`);
|
||||||
|
NODE
|
||||||
|
# The guard test now proves the three files agree before we tag anything.
|
||||||
|
npx vitest run tests/version-consistency.test.ts
|
||||||
|
ok "version files consistent"
|
||||||
|
|
||||||
|
# ── Commit + push + tag ──────────────────────────────────────────────────────
|
||||||
|
step "Commit, push, tag"
|
||||||
|
git add manifest.json package.json versions.json "$NOTES"
|
||||||
|
git commit -q -m "Release $VERSION"
|
||||||
|
git push -q origin master
|
||||||
|
git tag -a "$VERSION" -m "$VERSION" # no 'v' prefix — Obsidian requires tag == manifest version
|
||||||
|
git push -q origin "$VERSION"
|
||||||
|
ok "pushed master + tag $VERSION (CI release workflow triggered)"
|
||||||
|
|
||||||
|
# ── Wait for CI to build the draft release ───────────────────────────────────
|
||||||
|
step "Waiting for release workflow"
|
||||||
|
sleep 5
|
||||||
|
RUN_ID="$(gh run list --workflow=release.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
|
||||||
|
gh run watch "$RUN_ID" --exit-status >/dev/null || die "release workflow failed — see: gh run view $RUN_ID"
|
||||||
|
ok "workflow succeeded (draft release created)"
|
||||||
|
|
||||||
|
# ── Publish immediately (close the inconsistency window) ─────────────────────
|
||||||
|
step "Publishing release $VERSION"
|
||||||
|
gh release edit "$VERSION" --draft=false --latest --notes-file "$NOTES" >/dev/null
|
||||||
|
ok "release published"
|
||||||
|
|
||||||
|
# ── Verify remote consistency ────────────────────────────────────────────────
|
||||||
|
step "Verifying"
|
||||||
|
RAW="https://raw.githubusercontent.com/${REPO}"
|
||||||
|
DL="https://github.com/${REPO}/releases/download/${VERSION}"
|
||||||
|
root_v="$(curl -sfL "$RAW/master/manifest.json" | node -e 'process.stdin.on("data",d=>console.log(JSON.parse(d).version))')"
|
||||||
|
rel_v="$(curl -sfL "$DL/manifest.json" | node -e 'process.stdin.on("data",d=>console.log(JSON.parse(d).version))')"
|
||||||
|
[[ "$root_v" == "$VERSION" ]] || die "root manifest on master is '$root_v', expected '$VERSION'"
|
||||||
|
[[ "$rel_v" == "$VERSION" ]] || die "released manifest asset is '$rel_v', expected '$VERSION'"
|
||||||
|
is_draft="$(gh release view "$VERSION" --json isDraft --jq .isDraft)"
|
||||||
|
[[ "$is_draft" == "false" ]] || die "release is still a draft"
|
||||||
|
ok "root manifest = release asset = tag = $VERSION; release is published"
|
||||||
|
|
||||||
|
# ── Store-listing health (informational) ─────────────────────────────────────
|
||||||
|
step "Community-store listing check"
|
||||||
|
if curl -sfL "https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json" | grep -qi '"openaugi"'; then
|
||||||
|
ok "plugin is currently listed in the Obsidian community store"
|
||||||
|
else
|
||||||
|
printf '\033[33m! Not in the community store list. Users update via BRAT or manual install.\n'
|
||||||
|
printf ' To relist, see docs/PUBLISHING.md → "If the plugin gets removed from the store".\033[0m\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n\033[32m✓ Released %s\033[0m → https://github.com/%s/releases/tag/%s\n' "$VERSION" "$REPO" "$VERSION"
|
||||||
726
src/main.ts
726
src/main.ts
|
|
@ -1,15 +1,42 @@
|
||||||
import { Plugin, Notice, TFile, WorkspaceLeaf } from 'obsidian';
|
import { Plugin, Notice, TFile, Platform } from 'obsidian';
|
||||||
import { OpenAugiSettings, DEFAULT_SETTINGS } from './types/settings';
|
import { OpenAugiSettings, DEFAULT_SETTINGS } from './types/settings';
|
||||||
import { OpenAIService } from './services/openai-service';
|
import { OpenAIService } from './services/openai-service';
|
||||||
import { FileService } from './services/file-service';
|
import { FileService } from './services/file-service';
|
||||||
|
import { DistillService } from './services/distill-service';
|
||||||
|
import { ContextGatheringService } from './services/context-gathering-service';
|
||||||
|
import { TaskFileService, stripFrontmatter } from './services/task-file-service';
|
||||||
|
// Lazy-imported: TaskDispatchService uses Node.js modules (child_process, fs, path)
|
||||||
|
// that are unavailable on mobile. We dynamic-import it only on desktop.
|
||||||
|
import type { TaskDispatchService } from './services/task-dispatch-service';
|
||||||
import { OpenAugiSettingTab } from './ui/settings-tab';
|
import { OpenAugiSettingTab } from './ui/settings-tab';
|
||||||
import { LoadingIndicator } from './ui/loading-indicator';
|
import { LoadingIndicator } from './ui/loading-indicator';
|
||||||
import { sanitizeFilename } from './utils/filename-utils';
|
import { SessionListModal } from './ui/session-list-modal';
|
||||||
|
import { sanitizeFilename, createFileWithCollisionHandling } from './utils/filename-utils';
|
||||||
|
import { PromptSelectionModal, PromptSelectionConfig } from './ui/prompt-selection-modal';
|
||||||
|
import { ContextGatheringModal } from './ui/context-gathering-modal';
|
||||||
|
import { ContextSelectionModal } from './ui/context-selection-modal';
|
||||||
|
import { ContextPreviewModal } from './ui/context-preview-modal';
|
||||||
|
import { CommandOptions, ContextGatheringConfig, GatheredContext, DiscoveredNote } from './types/context';
|
||||||
|
import { TaskSession } from './types/task-dispatch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple tokeinzer to estimate the number of tokens
|
||||||
|
* @param text Text to count tokens from
|
||||||
|
* @returns Approximate token count
|
||||||
|
*/
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
// Rough estimate: 1 token is approximately 4 characters
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
export default class OpenAugiPlugin extends Plugin {
|
export default class OpenAugiPlugin extends Plugin {
|
||||||
settings: OpenAugiSettings;
|
settings: OpenAugiSettings;
|
||||||
openAIService: OpenAIService;
|
openAIService: OpenAIService;
|
||||||
fileService: FileService;
|
fileService: FileService;
|
||||||
|
distillService: DistillService;
|
||||||
|
contextGatheringService: ContextGatheringService;
|
||||||
|
taskFileService: TaskFileService;
|
||||||
|
taskDispatchService: TaskDispatchService | null;
|
||||||
loadingIndicator: LoadingIndicator;
|
loadingIndicator: LoadingIndicator;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
|
|
@ -30,7 +57,7 @@ export default class OpenAugiPlugin extends Plugin {
|
||||||
// Add command to manually parse a transcript file
|
// Add command to manually parse a transcript file
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: 'parse-transcript',
|
id: 'parse-transcript',
|
||||||
name: 'Parse Transcript',
|
name: 'Parse transcript',
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
const activeFile = this.app.workspace.getActiveFile();
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
if (activeFile && activeFile.extension === 'md') {
|
if (activeFile && activeFile.extension === 'md') {
|
||||||
|
|
@ -41,20 +68,242 @@ export default class OpenAugiPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add command to distill linked notes
|
||||||
|
this.addCommand({
|
||||||
|
id: 'distill-notes',
|
||||||
|
name: 'Distill linked notes',
|
||||||
|
callback: async () => {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
if (activeFile && activeFile.extension === 'md') {
|
||||||
|
await this.distillLinkedNotes(activeFile);
|
||||||
|
} else {
|
||||||
|
new Notice('Please open a markdown file first');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new unified context gathering commands
|
||||||
|
this.addCommand({
|
||||||
|
id: 'openaugi-process-notes',
|
||||||
|
name: 'Process notes',
|
||||||
|
callback: async () => {
|
||||||
|
await this.gatherAndProcessContext({
|
||||||
|
commandType: 'distill',
|
||||||
|
defaultSourceMode: 'linked-notes',
|
||||||
|
defaultDepth: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'openaugi-process-recent',
|
||||||
|
name: 'Process recent activity',
|
||||||
|
callback: async () => {
|
||||||
|
await this.gatherAndProcessContext({
|
||||||
|
commandType: 'distill',
|
||||||
|
defaultSourceMode: 'recent-activity',
|
||||||
|
defaultDepth: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'openaugi-save-context',
|
||||||
|
name: 'Save context',
|
||||||
|
callback: async () => {
|
||||||
|
await this.gatherAndProcessContext({
|
||||||
|
commandType: 'save-raw',
|
||||||
|
defaultSourceMode: 'linked-notes',
|
||||||
|
defaultDepth: 1,
|
||||||
|
skipPreview: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Augi task-file commands — write a pending task to OpenAugi/Tasks/
|
||||||
|
// for the OpenAugi task watcher to pick up. Pure vault API, works on
|
||||||
|
// mobile too. These supersede the deprecated Task Dispatch commands.
|
||||||
|
this.addCommand({
|
||||||
|
id: 'augi-run-review-pass',
|
||||||
|
name: 'Augi: Run review pass',
|
||||||
|
callback: async () => {
|
||||||
|
await this.queueTaskFile(() => this.taskFileService.createReviewPassTask());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'augi-process-dashboard',
|
||||||
|
name: 'Augi: Process dashboard',
|
||||||
|
callback: async () => {
|
||||||
|
await this.queueTaskFile(() => this.taskFileService.createProcessDashboardTask());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'augi-distill-selection',
|
||||||
|
name: 'Augi: Distill selection',
|
||||||
|
callback: async () => {
|
||||||
|
await this.distillSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task dispatch commands — desktop only.
|
||||||
|
// DEPRECATED: these launch tmux directly from the plugin, bypassing the
|
||||||
|
// task watcher. Kept working for existing users; will be removed over a
|
||||||
|
// release or two. New workflows should use the Augi commands above.
|
||||||
|
if (!Platform.isMobile) {
|
||||||
|
this.addCommand({
|
||||||
|
id: 'task-dispatch-launch',
|
||||||
|
name: 'Task dispatch: Launch or attach',
|
||||||
|
callback: async () => {
|
||||||
|
if (!this.taskDispatchService) {
|
||||||
|
new Notice('Task dispatch is still loading, try again in a moment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
if (activeFile && activeFile.extension === 'md') {
|
||||||
|
await this.taskDispatchService.launchOrAttach(activeFile);
|
||||||
|
} else {
|
||||||
|
new Notice('Please open a task note first');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'task-dispatch-kill',
|
||||||
|
name: 'Task dispatch: Kill session',
|
||||||
|
callback: async () => {
|
||||||
|
if (!this.taskDispatchService) {
|
||||||
|
new Notice('Task dispatch is still loading, try again in a moment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
if (activeFile && activeFile.extension === 'md') {
|
||||||
|
await this.taskDispatchService.killSession(activeFile);
|
||||||
|
} else {
|
||||||
|
new Notice('Please open a task note first');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'task-dispatch-list',
|
||||||
|
name: 'Task dispatch: List active sessions',
|
||||||
|
callback: async () => {
|
||||||
|
if (!this.taskDispatchService) {
|
||||||
|
new Notice('Task dispatch is still loading, try again in a moment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessions = await this.taskDispatchService.listActiveSessions();
|
||||||
|
const modal = new SessionListModal(
|
||||||
|
this.app,
|
||||||
|
sessions,
|
||||||
|
async (session: TaskSession) => {
|
||||||
|
await this.taskDispatchService!.openTerminal(session.tmuxSessionName);
|
||||||
|
},
|
||||||
|
async (session: TaskSession) => {
|
||||||
|
await this.taskDispatchService!.killSessionById(session.taskId);
|
||||||
|
new Notice(`Killed session: ${session.taskId}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
modal.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add settings tab
|
// Add settings tab
|
||||||
this.addSettingTab(new OpenAugiSettingTab(this.app, this));
|
this.addSettingTab(new OpenAugiSettingTab(this.app, this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the configured model (custom override or default)
|
||||||
|
*/
|
||||||
|
private getConfiguredModel(): string {
|
||||||
|
return this.settings.customModelOverride.trim() || this.settings.defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize services with current settings
|
* Initialize services with current settings
|
||||||
*/
|
*/
|
||||||
private initializeServices(): void {
|
private initializeServices(): void {
|
||||||
this.openAIService = new OpenAIService(this.settings.apiKey);
|
this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel());
|
||||||
this.fileService = new FileService(
|
this.fileService = new FileService(
|
||||||
this.app,
|
this.app,
|
||||||
this.settings.summaryFolder,
|
this.settings.summaryFolder,
|
||||||
this.settings.notesFolder
|
this.settings.notesFolder,
|
||||||
|
this.settings.publishedFolder
|
||||||
);
|
);
|
||||||
|
this.distillService = new DistillService(
|
||||||
|
this.app,
|
||||||
|
this.openAIService,
|
||||||
|
this.settings
|
||||||
|
);
|
||||||
|
this.contextGatheringService = new ContextGatheringService(
|
||||||
|
this.app,
|
||||||
|
this.distillService,
|
||||||
|
this.settings
|
||||||
|
);
|
||||||
|
this.taskFileService = new TaskFileService(this.app);
|
||||||
|
|
||||||
|
// TaskDispatchService uses Node.js modules (child_process, fs, path)
|
||||||
|
// unavailable on mobile. Skip entirely on mobile.
|
||||||
|
this.taskDispatchService = null;
|
||||||
|
if (!Platform.isMobile) {
|
||||||
|
import('./services/task-dispatch-service')
|
||||||
|
.then(({ TaskDispatchService }) => {
|
||||||
|
this.taskDispatchService = new TaskDispatchService(
|
||||||
|
this.app,
|
||||||
|
this.settings,
|
||||||
|
this.distillService
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback: Node.js modules unavailable in this environment.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a task-file writer and surface the result as a Notice.
|
||||||
|
* @param write Callback that writes the task file and returns its path
|
||||||
|
*/
|
||||||
|
private async queueTaskFile(write: () => Promise<string>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const path = await write();
|
||||||
|
const filename = path.split('/').pop();
|
||||||
|
new Notice(`Task queued: ${filename}\nThe OpenAugi task watcher will pick it up.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to write task file:', error);
|
||||||
|
new Notice('Failed to write task file. Check console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a distill task for the current selection, or the active note's
|
||||||
|
* body when nothing is selected. The chosen text becomes the task's
|
||||||
|
* ## Context section — the plugin acts as the scope selector.
|
||||||
|
*/
|
||||||
|
private async distillSelection(): Promise<void> {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
const selection = this.app.workspace.activeEditor?.editor?.getSelection()?.trim() ?? '';
|
||||||
|
|
||||||
|
let context = selection;
|
||||||
|
if (!context) {
|
||||||
|
if (!activeFile || activeFile.extension !== 'md') {
|
||||||
|
new Notice('Select text or open a markdown note to distill');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = await this.app.vault.read(activeFile);
|
||||||
|
context = stripFrontmatter(raw).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
new Notice('Nothing to distill — the note is empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceNote = activeFile?.basename ?? 'OpenAugi plugin';
|
||||||
|
await this.queueTaskFile(() => this.taskFileService.createDistillTask(context, sourceNote));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -91,8 +340,11 @@ export default class OpenAugiPlugin extends Plugin {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update openAIService with latest API key
|
// Display character and token count
|
||||||
this.openAIService = new OpenAIService(this.settings.apiKey);
|
new Notice(`Processing transcript: ${file.basename}\nCharacters: ${content.length}\nEst. Tokens: ${estimateTokens(content)}`);
|
||||||
|
|
||||||
|
// Update openAIService with latest API key and model
|
||||||
|
this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel());
|
||||||
|
|
||||||
// Parse transcript
|
// Parse transcript
|
||||||
const parsedData = await this.openAIService.parseTranscript(content);
|
const parsedData = await this.openAIService.parseTranscript(content);
|
||||||
|
|
@ -104,7 +356,7 @@ export default class OpenAugiPlugin extends Plugin {
|
||||||
this.loadingIndicator?.hide();
|
this.loadingIndicator?.hide();
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
new Notice(`Successfully parsed transcript: ${file.basename}`);
|
new Notice(`Successfully parsed transcript: ${file.basename}\nCreated ${parsedData.notes.length} atomic notes`);
|
||||||
|
|
||||||
// Open the summary file in a new tab
|
// Open the summary file in a new tab
|
||||||
const sanitizedFilename = sanitizeFilename(file.basename);
|
const sanitizedFilename = sanitizeFilename(file.basename);
|
||||||
|
|
@ -119,8 +371,136 @@ export default class OpenAugiPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distill linked notes
|
||||||
|
* @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');
|
||||||
|
|
||||||
|
// Check if API key is set
|
||||||
|
if (!this.settings.apiKey) {
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
new Notice('Please set your OpenAI API key in the plugin settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update services with latest API key and model
|
||||||
|
this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel());
|
||||||
|
this.distillService = new DistillService(
|
||||||
|
this.app,
|
||||||
|
this.openAIService,
|
||||||
|
this.settings
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get linked files
|
||||||
|
let linkedFiles = await this.distillService.getLinkedNotes(rootFile);
|
||||||
|
|
||||||
|
// Deduplicate the linked files by path
|
||||||
|
const uniqueFiles = new Map<string, TFile>();
|
||||||
|
for (const file of linkedFiles) {
|
||||||
|
if (!uniqueFiles.has(file.path)) {
|
||||||
|
uniqueFiles.set(file.path, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to array
|
||||||
|
linkedFiles = Array.from(uniqueFiles.values());
|
||||||
|
|
||||||
|
if (linkedFiles.length === 0) {
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
new Notice('No linked notes found to distill');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notice about linked files
|
||||||
|
new Notice(`Found ${linkedFiles.length} linked notes to process.`);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the distill service handle all the content aggregation (no time filtering)
|
||||||
|
const distilledData = await this.distillService.distillFromRootNote(
|
||||||
|
rootFile,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
0, // No time filtering for regular distill command
|
||||||
|
customPrompt
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write result to files
|
||||||
|
const summaryPath = await this.fileService.writeDistilledFiles(rootFile, distilledData);
|
||||||
|
|
||||||
|
// Hide loading indicator
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
new Notice(`Successfully distilled notes from: ${rootFile.basename}\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 notes:', error);
|
||||||
|
new Notice('Failed to distill notes. Check console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
const savedData = await this.loadData();
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, savedData);
|
||||||
|
|
||||||
|
// Deep merge nested settings to pick up new defaults
|
||||||
|
if (savedData?.contextGatheringDefaults) {
|
||||||
|
this.settings.contextGatheringDefaults = Object.assign(
|
||||||
|
{},
|
||||||
|
DEFAULT_SETTINGS.contextGatheringDefaults,
|
||||||
|
savedData.contextGatheringDefaults
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (savedData?.recentActivityDefaults) {
|
||||||
|
this.settings.recentActivityDefaults = Object.assign(
|
||||||
|
{},
|
||||||
|
DEFAULT_SETTINGS.recentActivityDefaults,
|
||||||
|
savedData.recentActivityDefaults
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (savedData?.taskDispatch) {
|
||||||
|
this.settings.taskDispatch = Object.assign(
|
||||||
|
{},
|
||||||
|
DEFAULT_SETTINGS.taskDispatch,
|
||||||
|
savedData.taskDispatch
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
|
|
@ -128,4 +508,332 @@ export default class OpenAugiPlugin extends Plugin {
|
||||||
// Reinitialize services with new settings
|
// Reinitialize services with new settings
|
||||||
this.initializeServices();
|
this.initializeServices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main orchestration method for unified context gathering and processing
|
||||||
|
* @param options Options specifying the command type and defaults
|
||||||
|
*/
|
||||||
|
private async gatherAndProcessContext(options: CommandOptions): Promise<void> {
|
||||||
|
// Step 1: Show context gathering configuration modal
|
||||||
|
const configModal = new ContextGatheringModal(
|
||||||
|
this.app,
|
||||||
|
this.settings,
|
||||||
|
this.contextGatheringService,
|
||||||
|
async (config: ContextGatheringConfig) => {
|
||||||
|
await this.executeContextGathering(config, options);
|
||||||
|
},
|
||||||
|
options.defaultSourceMode,
|
||||||
|
options.defaultDepth
|
||||||
|
);
|
||||||
|
configModal.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute context gathering with the given configuration
|
||||||
|
* @param config Context gathering configuration
|
||||||
|
* @param options Command options
|
||||||
|
*/
|
||||||
|
private async executeContextGathering(
|
||||||
|
config: ContextGatheringConfig,
|
||||||
|
options: CommandOptions
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.loadingIndicator?.show('Discovering notes...');
|
||||||
|
|
||||||
|
// Gather context
|
||||||
|
const gatheredContext = await this.contextGatheringService.gatherContext(config);
|
||||||
|
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
|
||||||
|
// Check if any notes were discovered
|
||||||
|
if (gatheredContext.notes.length === 0) {
|
||||||
|
new Notice('No notes discovered with the given configuration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Show checkbox selection modal
|
||||||
|
const selectionModal = new ContextSelectionModal(
|
||||||
|
this.app,
|
||||||
|
gatheredContext.notes,
|
||||||
|
async (selectedNotes: DiscoveredNote[]) => {
|
||||||
|
await this.showContextPreview(gatheredContext, selectedNotes, options);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
selectionModal.open();
|
||||||
|
} catch (error) {
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
console.error('Failed to gather context:', error);
|
||||||
|
new Notice('Failed to gather context: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show context preview with save/process options
|
||||||
|
* @param context Original gathered context
|
||||||
|
* @param selectedNotes User-selected notes
|
||||||
|
* @param options Command options
|
||||||
|
*/
|
||||||
|
private async showContextPreview(
|
||||||
|
context: GatheredContext,
|
||||||
|
selectedNotes: DiscoveredNote[],
|
||||||
|
options: CommandOptions
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if any notes selected
|
||||||
|
if (selectedNotes.length === 0) {
|
||||||
|
new Notice('No notes selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingIndicator?.show('Aggregating content...');
|
||||||
|
|
||||||
|
// Re-aggregate with only selected notes (preserving backlink info)
|
||||||
|
const aggregated = await this.contextGatheringService.aggregateContent(
|
||||||
|
selectedNotes,
|
||||||
|
context.config.filterRecentSectionsOnly ? context.config.journalSectionDays : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update context with selected notes
|
||||||
|
const finalContext: GatheredContext = {
|
||||||
|
...context,
|
||||||
|
notes: selectedNotes,
|
||||||
|
aggregatedContent: aggregated.content,
|
||||||
|
totalCharacters: aggregated.content.length,
|
||||||
|
totalNotes: selectedNotes.length
|
||||||
|
};
|
||||||
|
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
|
||||||
|
// Determine button label based on command type
|
||||||
|
const processButtonLabel = options.commandType === 'save-raw'
|
||||||
|
? 'Save Context'
|
||||||
|
: 'Process with AI';
|
||||||
|
|
||||||
|
// Step 3: Show preview modal
|
||||||
|
const previewModal = new ContextPreviewModal(
|
||||||
|
this.app,
|
||||||
|
finalContext,
|
||||||
|
async () => await this.saveRawContext(finalContext),
|
||||||
|
async () => {
|
||||||
|
if (options.commandType === 'save-raw') {
|
||||||
|
await this.saveRawContext(finalContext);
|
||||||
|
} else {
|
||||||
|
await this.processContextWithAI(finalContext, options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
processButtonLabel
|
||||||
|
);
|
||||||
|
previewModal.open();
|
||||||
|
} catch (error) {
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
console.error('Failed to preview context:', error);
|
||||||
|
new Notice('Failed to preview context: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save raw context to a note without AI processing
|
||||||
|
* @param context The gathered context to save
|
||||||
|
*/
|
||||||
|
private async saveRawContext(context: GatheredContext): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.loadingIndicator?.show('Saving context...');
|
||||||
|
|
||||||
|
// Ensure OpenAugi folder exists
|
||||||
|
if (!await this.app.vault.adapter.exists('OpenAugi')) {
|
||||||
|
await this.app.vault.createFolder('OpenAugi');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
const timeString = new Date().toISOString().split('T')[1].substring(0, 8).replace(/:/g, '-');
|
||||||
|
const filename = `Context ${timestamp} ${timeString}`;
|
||||||
|
const path = `OpenAugi/${filename}.md`;
|
||||||
|
|
||||||
|
// Build content
|
||||||
|
let content = `# Gathered Context\n\n`;
|
||||||
|
content += `**Source**: ${context.config.sourceMode}\n`;
|
||||||
|
content += `**Notes**: ${context.totalNotes}\n`;
|
||||||
|
content += `**Characters**: ${context.totalCharacters.toLocaleString()}\n`;
|
||||||
|
content += `**Timestamp**: ${context.timestamp}\n`;
|
||||||
|
|
||||||
|
if (context.config.sourceMode === 'linked-notes') {
|
||||||
|
content += `**Link Depth**: ${context.config.linkDepth}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `\n## Included Notes\n`;
|
||||||
|
content += context.notes.map(n => `- [[${n.file.basename}]]`).join('\n');
|
||||||
|
content += `\n\n---\n\n`;
|
||||||
|
content += context.aggregatedContent;
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
await createFileWithCollisionHandling(this.app.vault, path, content);
|
||||||
|
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
|
||||||
|
new Notice(`Context saved to: ${filename}`);
|
||||||
|
|
||||||
|
// Open the file
|
||||||
|
await this.openFileInNewTab(path);
|
||||||
|
} catch (error) {
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
console.error('Failed to save raw context:', error);
|
||||||
|
new Notice('Failed to save context: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process context with AI (distill or publish)
|
||||||
|
* @param context The gathered context
|
||||||
|
* @param options Command options
|
||||||
|
*/
|
||||||
|
private async processContextWithAI(
|
||||||
|
context: GatheredContext,
|
||||||
|
options: CommandOptions
|
||||||
|
): Promise<void> {
|
||||||
|
// Show prompt selection modal with processing type option
|
||||||
|
const promptModal = new PromptSelectionModal(
|
||||||
|
this.app,
|
||||||
|
this.settings.promptsFolder,
|
||||||
|
async (promptConfig: PromptSelectionConfig) => {
|
||||||
|
await this.executeProcessing(context, promptConfig, options);
|
||||||
|
},
|
||||||
|
true, // Show processing type selector
|
||||||
|
'distill' // Default to distill
|
||||||
|
);
|
||||||
|
promptModal.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute AI processing with the selected prompt and processing type
|
||||||
|
* @param context The gathered context
|
||||||
|
* @param promptConfig Prompt selection configuration
|
||||||
|
* @param options Command options
|
||||||
|
*/
|
||||||
|
private async executeProcessing(
|
||||||
|
context: GatheredContext,
|
||||||
|
promptConfig: PromptSelectionConfig,
|
||||||
|
options: CommandOptions
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check API key
|
||||||
|
if (!this.settings.apiKey) {
|
||||||
|
new Notice('Please set your OpenAI API key in the settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read custom prompt if selected
|
||||||
|
let customPrompt: string | undefined;
|
||||||
|
if (promptConfig.useCustomPrompt && promptConfig.selectedPrompt) {
|
||||||
|
try {
|
||||||
|
customPrompt = await this.app.vault.read(promptConfig.selectedPrompt);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read custom prompt:', error);
|
||||||
|
new Notice('Failed to read custom prompt, using default');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processingType = promptConfig.processingType || 'distill';
|
||||||
|
|
||||||
|
if (processingType === 'publish') {
|
||||||
|
await this.executePublish(context, customPrompt, promptConfig.selectedPrompt?.basename || 'default');
|
||||||
|
} else {
|
||||||
|
await this.executeDistill(context, customPrompt);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
console.error('Failed to process context:', error);
|
||||||
|
new Notice('Failed to process context: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute distill processing
|
||||||
|
* @param context The gathered context
|
||||||
|
* @param customPrompt Optional custom prompt
|
||||||
|
*/
|
||||||
|
private async executeDistill(
|
||||||
|
context: GatheredContext,
|
||||||
|
customPrompt?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.loadingIndicator?.show('Distilling content...');
|
||||||
|
|
||||||
|
// Update services with latest API key and model
|
||||||
|
this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel());
|
||||||
|
this.distillService = new DistillService(
|
||||||
|
this.app,
|
||||||
|
this.openAIService,
|
||||||
|
this.settings
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call distill API
|
||||||
|
const distilledData = await this.openAIService.distillContent(
|
||||||
|
context.aggregatedContent,
|
||||||
|
customPrompt
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add source notes
|
||||||
|
distilledData.sourceNotes = context.notes.map(n => n.file.basename);
|
||||||
|
|
||||||
|
// Write files using first note as synthetic root
|
||||||
|
const syntheticRoot = context.notes[0].file;
|
||||||
|
const summaryPath = await this.fileService.writeDistilledFiles(syntheticRoot, distilledData);
|
||||||
|
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
|
||||||
|
new Notice(`Successfully distilled! Created ${distilledData.notes.length} atomic notes`);
|
||||||
|
|
||||||
|
// Open the summary file
|
||||||
|
await this.openFileInNewTab(summaryPath);
|
||||||
|
} catch (error) {
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute publish processing
|
||||||
|
* @param context The gathered context
|
||||||
|
* @param customPrompt Optional custom prompt
|
||||||
|
* @param promptName Name of the prompt used
|
||||||
|
*/
|
||||||
|
private async executePublish(
|
||||||
|
context: GatheredContext,
|
||||||
|
customPrompt: string | undefined,
|
||||||
|
promptName: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.loadingIndicator?.show('Publishing content...');
|
||||||
|
|
||||||
|
// Update services with latest API key and model
|
||||||
|
this.openAIService = new OpenAIService(this.settings.apiKey, this.getConfiguredModel());
|
||||||
|
|
||||||
|
// Call publish API
|
||||||
|
const publishedContent = await this.openAIService.publishContent(
|
||||||
|
context.aggregatedContent,
|
||||||
|
customPrompt
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write published post
|
||||||
|
const sourceNotes = context.notes.map(n => n.file.basename);
|
||||||
|
const publishedPath = await this.fileService.writePublishedPost(
|
||||||
|
publishedContent,
|
||||||
|
sourceNotes,
|
||||||
|
promptName
|
||||||
|
);
|
||||||
|
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
|
||||||
|
new Notice('Successfully published blog post!');
|
||||||
|
|
||||||
|
// Open the published file
|
||||||
|
await this.openFileInNewTab(publishedPath);
|
||||||
|
} catch (error) {
|
||||||
|
this.loadingIndicator?.hide();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
369
src/services/context-gathering-service.ts
Normal file
369
src/services/context-gathering-service.ts
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
import { App, TFile } from 'obsidian';
|
||||||
|
import { DistillService } from './distill-service';
|
||||||
|
import { OpenAugiSettings } from '../types/settings';
|
||||||
|
import {
|
||||||
|
ContextGatheringConfig,
|
||||||
|
DiscoveredNote,
|
||||||
|
GatheredContext
|
||||||
|
} from '../types/context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for unified context gathering across all commands
|
||||||
|
* Handles link traversal, recent activity discovery, and content aggregation
|
||||||
|
*/
|
||||||
|
export class ContextGatheringService {
|
||||||
|
private app: App;
|
||||||
|
private distillService: DistillService;
|
||||||
|
private settings: OpenAugiSettings;
|
||||||
|
|
||||||
|
constructor(app: App, distillService: DistillService, settings: OpenAugiSettings) {
|
||||||
|
this.app = app;
|
||||||
|
this.distillService = distillService;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point: Gather context based on configuration
|
||||||
|
* @param config The configuration specifying how to gather context
|
||||||
|
* @returns Complete gathered context with metadata
|
||||||
|
*/
|
||||||
|
async gatherContext(config: ContextGatheringConfig): Promise<GatheredContext> {
|
||||||
|
let discoveredNotes: DiscoveredNote[];
|
||||||
|
|
||||||
|
if (config.sourceMode === 'linked-notes') {
|
||||||
|
if (!config.rootNote) {
|
||||||
|
throw new Error('Root note required for linked-notes mode');
|
||||||
|
}
|
||||||
|
discoveredNotes = await this.discoverLinkedNotes(config);
|
||||||
|
} else {
|
||||||
|
if (!config.timeWindow) {
|
||||||
|
throw new Error('Time window required for recent-activity mode');
|
||||||
|
}
|
||||||
|
discoveredNotes = await this.discoverRecentNotes(
|
||||||
|
config.timeWindow,
|
||||||
|
config.excludeFolders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply folder filtering
|
||||||
|
discoveredNotes = this.applyFolderFilters(discoveredNotes, config.excludeFolders);
|
||||||
|
|
||||||
|
// Aggregate content from included notes only
|
||||||
|
const includedNotes = discoveredNotes.filter(n => n.included);
|
||||||
|
|
||||||
|
const aggregatedContent = await this.aggregateContent(
|
||||||
|
includedNotes,
|
||||||
|
config.filterRecentSectionsOnly ? config.journalSectionDays : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notes: discoveredNotes,
|
||||||
|
aggregatedContent: aggregatedContent.content,
|
||||||
|
totalCharacters: aggregatedContent.content.length,
|
||||||
|
totalNotes: includedNotes.length,
|
||||||
|
config: config,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover notes by traversing links up to specified depth using BFS
|
||||||
|
* Traverses both forward links and backlinks at each level
|
||||||
|
* @param config The context gathering configuration
|
||||||
|
* @returns Array of discovered notes with metadata
|
||||||
|
*/
|
||||||
|
private async discoverLinkedNotes(
|
||||||
|
config: ContextGatheringConfig
|
||||||
|
): Promise<DiscoveredNote[]> {
|
||||||
|
const rootNote = config.rootNote!;
|
||||||
|
const maxDepth = config.linkDepth;
|
||||||
|
const maxCharacters = config.maxCharacters;
|
||||||
|
const includeBacklinks = config.includeBacklinks ?? false;
|
||||||
|
const backlinkContextLines = config.backlinkContextLines ?? 2;
|
||||||
|
|
||||||
|
const discovered = new Map<string, DiscoveredNote>();
|
||||||
|
const queued = new Set<string>(); // Track queued files to prevent duplicates
|
||||||
|
const queue: Array<{ file: TFile; depth: number; via: string; isBacklink: boolean }> = [];
|
||||||
|
|
||||||
|
// Start with root note
|
||||||
|
const rootContent = await this.app.vault.read(rootNote);
|
||||||
|
discovered.set(rootNote.path, {
|
||||||
|
file: rootNote,
|
||||||
|
depth: 0,
|
||||||
|
discoveredVia: 'root',
|
||||||
|
estimatedChars: rootContent.length,
|
||||||
|
included: true,
|
||||||
|
isBacklink: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get direct forward links from root
|
||||||
|
const rootLinks = await this.distillService.getLinkedNotes(rootNote);
|
||||||
|
for (const link of rootLinks) {
|
||||||
|
queue.push({ file: link, depth: 1, via: rootNote.basename, isBacklink: false });
|
||||||
|
queued.add(link.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backlinks to root if enabled
|
||||||
|
if (includeBacklinks) {
|
||||||
|
const rootBacklinks = this.distillService.getBacklinksForFile(rootNote);
|
||||||
|
for (const backlink of rootBacklinks) {
|
||||||
|
// Forward links take priority - only add if not already queued
|
||||||
|
if (!queued.has(backlink.path)) {
|
||||||
|
queue.push({ file: backlink, depth: 1, via: rootNote.basename, isBacklink: true });
|
||||||
|
queued.add(backlink.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS traversal
|
||||||
|
let totalChars = rootContent.length;
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { file, depth, via, isBacklink } = queue.shift()!;
|
||||||
|
|
||||||
|
// Skip if already discovered
|
||||||
|
if (discovered.has(file.path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chars: number;
|
||||||
|
let snippetContent: string | undefined;
|
||||||
|
|
||||||
|
if (isBacklink) {
|
||||||
|
// For backlinks, get snippets instead of full content
|
||||||
|
// Find the target file (the note this backlink points TO) from discovered notes
|
||||||
|
const targetEntry = Array.from(discovered.entries()).find(
|
||||||
|
([_, note]) => note.file.basename === via
|
||||||
|
);
|
||||||
|
const targetFile = targetEntry ? targetEntry[1].file : rootNote;
|
||||||
|
|
||||||
|
const snippets = await this.distillService.getBacklinkSnippets(
|
||||||
|
targetFile,
|
||||||
|
file,
|
||||||
|
backlinkContextLines
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deduplicate snippets (multiple links in same section would produce identical snippets)
|
||||||
|
const uniqueSnippets = [...new Set(snippets.map(s => s.snippet))];
|
||||||
|
snippetContent = uniqueSnippets.join('\n\n---\n\n');
|
||||||
|
chars = snippetContent.length;
|
||||||
|
} else {
|
||||||
|
// For forward links, read full content
|
||||||
|
const content = await this.app.vault.read(file);
|
||||||
|
chars = content.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if adding this note would exceed character limit
|
||||||
|
if (totalChars + chars > maxCharacters) {
|
||||||
|
// Mark as discovered but not included due to size limit
|
||||||
|
discovered.set(file.path, {
|
||||||
|
file,
|
||||||
|
depth,
|
||||||
|
discoveredVia: isBacklink ? `backlink from [[${via}]]` : `linked from [[${via}]]`,
|
||||||
|
estimatedChars: chars,
|
||||||
|
included: false, // Excluded due to size limit
|
||||||
|
isBacklink,
|
||||||
|
backlinkSnippet: isBacklink ? snippetContent : undefined
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to discovered and include it
|
||||||
|
discovered.set(file.path, {
|
||||||
|
file,
|
||||||
|
depth,
|
||||||
|
discoveredVia: isBacklink ? `backlink from [[${via}]]` : `linked from [[${via}]]`,
|
||||||
|
estimatedChars: chars,
|
||||||
|
included: true,
|
||||||
|
isBacklink,
|
||||||
|
backlinkSnippet: isBacklink ? snippetContent : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[OpenAugi] Discovered: ${file.basename} | isBacklink: ${isBacklink} | via: ${via} | depth: ${depth}`);
|
||||||
|
|
||||||
|
totalChars += chars;
|
||||||
|
|
||||||
|
// If we haven't reached max depth, get links from this note
|
||||||
|
if (depth < maxDepth) {
|
||||||
|
try {
|
||||||
|
// Get forward links
|
||||||
|
const linkedNotes = await this.distillService.getLinkedNotes(file);
|
||||||
|
for (const linkedNote of linkedNotes) {
|
||||||
|
// Don't queue if already queued or discovered
|
||||||
|
if (!queued.has(linkedNote.path) && !discovered.has(linkedNote.path)) {
|
||||||
|
queue.push({ file: linkedNote, depth: depth + 1, via: file.basename, isBacklink: false });
|
||||||
|
queued.add(linkedNote.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backlinks if enabled
|
||||||
|
if (includeBacklinks) {
|
||||||
|
const backlinks = this.distillService.getBacklinksForFile(file);
|
||||||
|
for (const backlink of backlinks) {
|
||||||
|
// Don't queue if already queued or discovered
|
||||||
|
if (!queued.has(backlink.path) && !discovered.has(backlink.path)) {
|
||||||
|
queue.push({ file: backlink, depth: depth + 1, via: file.basename, isBacklink: true });
|
||||||
|
queued.add(backlink.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to get linked notes from ${file.path}:`, error);
|
||||||
|
// Continue with other notes even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array and sort by depth, then by name
|
||||||
|
return Array.from(discovered.values()).sort((a, b) => {
|
||||||
|
if (a.depth !== b.depth) return a.depth - b.depth;
|
||||||
|
return a.file.basename.localeCompare(b.file.basename);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover notes by recent activity
|
||||||
|
* @param timeWindow Time window configuration
|
||||||
|
* @param excludeFolders Folders to exclude
|
||||||
|
* @returns Array of discovered notes
|
||||||
|
*/
|
||||||
|
private async discoverRecentNotes(
|
||||||
|
timeWindow: { mode: string; daysBack?: number; fromDate?: string; toDate?: string },
|
||||||
|
excludeFolders: string[]
|
||||||
|
): Promise<DiscoveredNote[]> {
|
||||||
|
const recentFiles = await this.distillService.getRecentlyModifiedNotes(
|
||||||
|
timeWindow.daysBack || 7,
|
||||||
|
excludeFolders,
|
||||||
|
timeWindow.fromDate,
|
||||||
|
timeWindow.toDate
|
||||||
|
);
|
||||||
|
|
||||||
|
const discovered: DiscoveredNote[] = [];
|
||||||
|
|
||||||
|
for (const file of recentFiles) {
|
||||||
|
const content = await this.app.vault.read(file);
|
||||||
|
discovered.push({
|
||||||
|
file,
|
||||||
|
depth: 0, // No depth concept for recent activity
|
||||||
|
discoveredVia: 'recent activity',
|
||||||
|
estimatedChars: content.length,
|
||||||
|
included: true,
|
||||||
|
isBacklink: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply folder filtering to discovered notes
|
||||||
|
* @param notes Notes to filter
|
||||||
|
* @param excludeFolders Folders to exclude
|
||||||
|
* @returns Filtered notes with updated included property
|
||||||
|
*/
|
||||||
|
private applyFolderFilters(
|
||||||
|
notes: DiscoveredNote[],
|
||||||
|
excludeFolders: string[]
|
||||||
|
): DiscoveredNote[] {
|
||||||
|
return notes.map(note => {
|
||||||
|
const isExcluded = excludeFolders.some(folder =>
|
||||||
|
note.file.path.startsWith(folder + '/') ||
|
||||||
|
note.file.path.includes('/' + folder + '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isExcluded && note.included) {
|
||||||
|
return { ...note, included: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return note;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate content from notes
|
||||||
|
* For forward links: uses full content (with optional journal filtering)
|
||||||
|
* For backlinks: uses only the snippet
|
||||||
|
* @param notes Notes to aggregate
|
||||||
|
* @param filterJournalDays Optional days back for journal section filtering
|
||||||
|
* @returns Aggregated content and source note names
|
||||||
|
*/
|
||||||
|
async aggregateContent(
|
||||||
|
notes: DiscoveredNote[],
|
||||||
|
filterJournalDays?: number
|
||||||
|
): Promise<{ content: string; sourceNotes: string[] }> {
|
||||||
|
// Separate forward links and backlinks
|
||||||
|
const forwardLinks = notes.filter(n => !n.isBacklink);
|
||||||
|
const backlinks = notes.filter(n => n.isBacklink);
|
||||||
|
|
||||||
|
// Aggregate forward links using existing method
|
||||||
|
const forwardFiles = forwardLinks.map(n => n.file);
|
||||||
|
const forwardResult = await this.distillService.aggregateContent(forwardFiles, filterJournalDays);
|
||||||
|
|
||||||
|
// Build backlinks section
|
||||||
|
let backlinkContent = '';
|
||||||
|
const backlinkSourceNotes: string[] = [];
|
||||||
|
|
||||||
|
for (const note of backlinks) {
|
||||||
|
backlinkContent += `\n\n# Backlink: ${note.file.basename}\n`;
|
||||||
|
backlinkContent += `*${note.discoveredVia}*\n\n`;
|
||||||
|
if (note.backlinkSnippet) {
|
||||||
|
backlinkContent += note.backlinkSnippet;
|
||||||
|
} else {
|
||||||
|
// Fallback: read full content if snippet is missing
|
||||||
|
const fullContent = await this.app.vault.read(note.file);
|
||||||
|
backlinkContent += fullContent;
|
||||||
|
}
|
||||||
|
backlinkSourceNotes.push(note.file.basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: forwardResult.content + backlinkContent,
|
||||||
|
sourceNotes: [...forwardResult.sourceNotes, ...backlinkSourceNotes]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate size before gathering (quick estimation for UI)
|
||||||
|
* @param rootNote Root note for linked-notes mode
|
||||||
|
* @param mode Source mode
|
||||||
|
* @param depth Link depth for linked-notes mode
|
||||||
|
* @returns Estimated note count and character count
|
||||||
|
*/
|
||||||
|
async estimateSize(
|
||||||
|
rootNote: TFile | undefined,
|
||||||
|
mode: 'linked-notes' | 'recent-activity',
|
||||||
|
depth?: number
|
||||||
|
): Promise<{ noteCount: number; estimatedChars: number }> {
|
||||||
|
if (mode === 'linked-notes') {
|
||||||
|
if (!rootNote) {
|
||||||
|
return { noteCount: 0, estimatedChars: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const links = await this.distillService.getLinkedNotes(rootNote);
|
||||||
|
// Rough estimate: 5000 chars per note average
|
||||||
|
// For depth > 1, multiply by depth factor
|
||||||
|
const depthFactor = depth || 1;
|
||||||
|
const estimatedNoteCount = (links.length * depthFactor) + 1; // +1 for root
|
||||||
|
const estimated = estimatedNoteCount * 5000;
|
||||||
|
return { noteCount: estimatedNoteCount, estimatedChars: estimated };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to estimate size:', error);
|
||||||
|
return { noteCount: 0, estimatedChars: 0 };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For recent activity, get actual recent files
|
||||||
|
try {
|
||||||
|
const recentFiles = await this.distillService.getRecentlyModifiedNotes(
|
||||||
|
7, // Default estimate
|
||||||
|
this.settings.recentActivityDefaults.excludeFolders
|
||||||
|
);
|
||||||
|
const estimated = recentFiles.length * 5000;
|
||||||
|
return { noteCount: recentFiles.length, estimatedChars: estimated };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to estimate size:', error);
|
||||||
|
return { noteCount: 0, estimatedChars: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
876
src/services/distill-service.ts
Normal file
876
src/services/distill-service.ts
Normal file
|
|
@ -0,0 +1,876 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple tokeinzer to estimate the number of tokens
|
||||||
|
* @param text Text to count tokens from
|
||||||
|
* @returns Approximate token count
|
||||||
|
*/
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
// Rough estimate: 1 token is approximately 4 characters
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for distilling content from linked notes
|
||||||
|
*/
|
||||||
|
export class DistillService {
|
||||||
|
private app: App;
|
||||||
|
private openAIService: OpenAIService;
|
||||||
|
private settings: OpenAugiSettings;
|
||||||
|
|
||||||
|
constructor(app: App, openAIService: OpenAIService, settings: OpenAugiSettings) {
|
||||||
|
this.app = app;
|
||||||
|
this.openAIService = openAIService;
|
||||||
|
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
|
||||||
|
* @returns True if the content contains a dataview query
|
||||||
|
*/
|
||||||
|
private containsDataviewQuery(content: string): boolean {
|
||||||
|
return content.includes("```dataview");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip dataview query blocks from content
|
||||||
|
* @param content The content to clean
|
||||||
|
* @returns Content with dataview blocks removed
|
||||||
|
*/
|
||||||
|
private stripDataviewQueries(content: string): string {
|
||||||
|
return content.replace(/```dataview[\s\S]*?```\n?/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip tags from content (e.g., #tag, #nested/tag)
|
||||||
|
* @param content The content to clean
|
||||||
|
* @returns Content with tags removed
|
||||||
|
*/
|
||||||
|
private stripTags(content: string): string {
|
||||||
|
// Match tags: # followed by word characters, may include forward slashes for nested tags
|
||||||
|
// Must be preceded by whitespace or start of line, and followed by whitespace, punctuation, or end of line
|
||||||
|
// Avoid matching markdown headers (## Header) by requiring non-# after the tag start
|
||||||
|
return content.replace(/(?:^|(?<=\s))#(?!#)[a-zA-Z0-9_][a-zA-Z0-9_/]*(?=\s|$|[.,;:!?)\]])/gm, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract dataview queries from content
|
||||||
|
* @param content The content to extract queries from
|
||||||
|
* @returns Array of extracted dataview queries
|
||||||
|
*/
|
||||||
|
private extractDataviewQueries(content: string): string[] {
|
||||||
|
const regex = /```dataview\s+([\s\S]*?)```/g;
|
||||||
|
const queries: string[] = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
const query = match[1].trim();
|
||||||
|
if (query) {
|
||||||
|
queries.push(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get files from a dataview query
|
||||||
|
* @param query The dataview query
|
||||||
|
* @param sourcePath The source file path
|
||||||
|
* @returns Array of files from dataview query result
|
||||||
|
*/
|
||||||
|
private async getFilesFromDataviewQuery(query: string, sourcePath: string): Promise<TFile[]> {
|
||||||
|
// Check if dataview plugin is available
|
||||||
|
// @ts-ignore - Dataview API is not typed
|
||||||
|
const dataviewPlugin = this.app.plugins.plugins["dataview"];
|
||||||
|
if (!dataviewPlugin?.api) {
|
||||||
|
console.log("[OpenAugi] Dataview plugin not available");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dvApi = dataviewPlugin.api;
|
||||||
|
const files: TFile[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the structured query API (dvApi.query) which returns Link objects
|
||||||
|
// with clean file paths. The older queryMarkdown approach returned rendered
|
||||||
|
// markdown where TABLE queries escape pipes as \| inside [[path\|alias]],
|
||||||
|
// breaking path resolution.
|
||||||
|
const queryResult = await dvApi.query(query, sourcePath);
|
||||||
|
|
||||||
|
if (queryResult.successful && queryResult.value) {
|
||||||
|
if (queryResult.value.type === "list") {
|
||||||
|
for (const item of queryResult.value.values) {
|
||||||
|
if (item && typeof item === "object" && "path" in item && typeof item.path === "string") {
|
||||||
|
const file = this.app.vault.getAbstractFileByPath(item.path);
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (queryResult.value.type === "table") {
|
||||||
|
for (const row of queryResult.value.values) {
|
||||||
|
const firstCol = row[0];
|
||||||
|
if (firstCol && typeof firstCol === "object" && "path" in firstCol && typeof firstCol.path === "string") {
|
||||||
|
const file = this.app.vault.getAbstractFileByPath(firstCol.path);
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OpenAugi] Error executing dataview query:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract links from a string and resolve them to files
|
||||||
|
* @param content The string content to search for links
|
||||||
|
* @param sourcePath The source file path for resolving links
|
||||||
|
* @param files Array to add found files to
|
||||||
|
*/
|
||||||
|
private extractLinksFromString(content: string, sourcePath: string, files: TFile[]): void {
|
||||||
|
// Try multiple patterns to extract links
|
||||||
|
|
||||||
|
// Standard markdown links: [[link]] or [[link|alias]]
|
||||||
|
const standardLinkRegex = /\[\[(.*?)\]\]/g;
|
||||||
|
this.processRegexMatches(standardLinkRegex, content, sourcePath, files);
|
||||||
|
|
||||||
|
// Dataview specific format links: [link](link.md) or [alias](link.md)
|
||||||
|
const markdownLinkRegex = /\[(.*?)\]\((.*?)\)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = markdownLinkRegex.exec(content)) !== null) {
|
||||||
|
// Use the URL part (second group) as the link
|
||||||
|
const linkPath = match[2];
|
||||||
|
this.resolveAndAddFile(linkPath, sourcePath, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle line items that might be paths
|
||||||
|
const lines = content.split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
// Check if line is a list item with potential path (starts with - or *)
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if ((trimmedLine.startsWith("- ") || trimmedLine.startsWith("* ")) && !trimmedLine.includes("[[") && !trimmedLine.includes("](")) {
|
||||||
|
// Extract the potential path (remove the list marker and trim)
|
||||||
|
const potentialPath = trimmedLine.substring(2).trim();
|
||||||
|
// If it looks like a path (contains '/' or '.md' or doesn't have spaces)
|
||||||
|
if (potentialPath.includes("/") || potentialPath.endsWith(".md") || !potentialPath.includes(" ")) {
|
||||||
|
this.resolveAndAddFile(potentialPath, sourcePath, files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process regex matches to extract links
|
||||||
|
* @param regex The regex to use
|
||||||
|
* @param content The content to search
|
||||||
|
* @param sourcePath The source path for resolving links
|
||||||
|
* @param files Array to add found files to
|
||||||
|
*/
|
||||||
|
private processRegexMatches(regex: RegExp, content: string, sourcePath: string, files: TFile[]): void {
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
let linkPath = match[1];
|
||||||
|
|
||||||
|
// Handle aliased links: [[path|alias]] -> path
|
||||||
|
if (linkPath.includes("|")) {
|
||||||
|
linkPath = linkPath.split("|")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resolveAndAddFile(linkPath, sourcePath, files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a path to a file and add it to the files array if found
|
||||||
|
* @param linkPath The path to resolve
|
||||||
|
* @param sourcePath The source path for resolving
|
||||||
|
* @param files Array to add the file to if found
|
||||||
|
*/
|
||||||
|
private resolveAndAddFile(linkPath: string, sourcePath: string, files: TFile[]): void {
|
||||||
|
// Check if the path already has an extension
|
||||||
|
const hasExtension = /\.[a-zA-Z0-9]+$/.test(linkPath);
|
||||||
|
|
||||||
|
// Only add .md extension if no extension exists
|
||||||
|
if (!hasExtension) {
|
||||||
|
linkPath += ".md";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve the file using the Obsidian metadata API first
|
||||||
|
const resolvedFile = this.app.metadataCache.getFirstLinkpathDest(linkPath, sourcePath);
|
||||||
|
|
||||||
|
// Fallback to direct path resolution if needed
|
||||||
|
let file: TFile | null = null;
|
||||||
|
if (resolvedFile instanceof TFile) {
|
||||||
|
file = resolvedFile;
|
||||||
|
} else if (!resolvedFile) {
|
||||||
|
const abstractFile = this.app.vault.getAbstractFileByPath(linkPath);
|
||||||
|
if (abstractFile instanceof TFile) {
|
||||||
|
file = abstractFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
// Avoid duplicate files
|
||||||
|
if (!files.some(existingFile => existingFile.path === file!.path)) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract linked notes from a note
|
||||||
|
* @param file The file to extract links from
|
||||||
|
* @returns Array of linked TFiles
|
||||||
|
*/
|
||||||
|
async getLinkedNotes(file: TFile): Promise<TFile[]> {
|
||||||
|
let linkedFiles: TFile[] = [];
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
if (queries.length > 0) {
|
||||||
|
// Process each query and collect results
|
||||||
|
for (let i = 0; i < queries.length; i++) {
|
||||||
|
const query = queries[i];
|
||||||
|
|
||||||
|
const dataviewFiles = await this.getFilesFromDataviewQuery(query, file.path);
|
||||||
|
|
||||||
|
if (dataviewFiles.length > 0) {
|
||||||
|
// Add new files that aren't already in the linked files array
|
||||||
|
for (const dataviewFile of dataviewFiles) {
|
||||||
|
if (!linkedFiles.some(existingFile => existingFile.path === dataviewFile.path)) {
|
||||||
|
linkedFiles.push(dataviewFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always extract regular links as well, and combine with dataview results
|
||||||
|
const metadataCache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const initialCount = linkedFiles.length;
|
||||||
|
|
||||||
|
if (metadataCache?.links) {
|
||||||
|
for (const link of metadataCache.links) {
|
||||||
|
// Get the file for this link
|
||||||
|
const linkedFile = this.app.metadataCache.getFirstLinkpathDest(link.link, file.path);
|
||||||
|
|
||||||
|
if (linkedFile && linkedFile instanceof TFile) {
|
||||||
|
// Check if the file is already in the linkedFiles array
|
||||||
|
if (!linkedFiles.some(existingFile => existingFile.path === linkedFile.path)) {
|
||||||
|
linkedFiles.push(linkedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for embeds
|
||||||
|
if (metadataCache?.embeds) {
|
||||||
|
for (const embed of metadataCache.embeds) {
|
||||||
|
const linkedFile = this.app.metadataCache.getFirstLinkpathDest(embed.link, file.path);
|
||||||
|
|
||||||
|
if (linkedFile && linkedFile instanceof TFile) {
|
||||||
|
// Check if the file is already in the linkedFiles array
|
||||||
|
if (!linkedFiles.some(existingFile => existingFile.path === linkedFile.path)) {
|
||||||
|
linkedFiles.push(linkedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final deduplication step
|
||||||
|
const uniquePaths = new Set<string>();
|
||||||
|
const uniqueFiles: TFile[] = [];
|
||||||
|
|
||||||
|
for (const linkedFile of linkedFiles) {
|
||||||
|
if (!uniquePaths.has(linkedFile.path)) {
|
||||||
|
uniquePaths.add(linkedFile.path);
|
||||||
|
uniqueFiles.push(linkedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all files that link TO the given target file (backlinks)
|
||||||
|
* Uses the metadata cache's resolvedLinks for efficient lookup
|
||||||
|
* @param targetFile The file to find backlinks for
|
||||||
|
* @returns Array of files that link to the target
|
||||||
|
*/
|
||||||
|
getBacklinksForFile(targetFile: TFile): TFile[] {
|
||||||
|
const backlinks: TFile[] = [];
|
||||||
|
const resolvedLinks = this.app.metadataCache.resolvedLinks;
|
||||||
|
|
||||||
|
// resolvedLinks is Record<sourcePath, Record<targetPath, linkCount>>
|
||||||
|
for (const sourcePath in resolvedLinks) {
|
||||||
|
const targetLinks = resolvedLinks[sourcePath];
|
||||||
|
if (targetLinks && targetFile.path in targetLinks) {
|
||||||
|
// This source file links to our target
|
||||||
|
const sourceFile = this.app.vault.getAbstractFileByPath(sourcePath);
|
||||||
|
if (sourceFile instanceof TFile) {
|
||||||
|
backlinks.push(sourceFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return backlinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract context snippets from a source file for all links pointing to target
|
||||||
|
* @param targetFile The file being linked TO
|
||||||
|
* @param sourceFile The file containing the backlinks
|
||||||
|
* @param contextLines Number of lines before/after (0 = full paragraph)
|
||||||
|
* @returns Array of snippets with line numbers
|
||||||
|
*/
|
||||||
|
async getBacklinkSnippets(
|
||||||
|
targetFile: TFile,
|
||||||
|
sourceFile: TFile,
|
||||||
|
contextLines: number = 2
|
||||||
|
): Promise<Array<{ snippet: string; line: number }>> {
|
||||||
|
const snippets: Array<{ snippet: string; line: number }> = [];
|
||||||
|
const cache = this.app.metadataCache.getFileCache(sourceFile);
|
||||||
|
|
||||||
|
if (!cache?.links) {
|
||||||
|
return snippets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await this.app.vault.read(sourceFile);
|
||||||
|
|
||||||
|
for (const link of cache.links) {
|
||||||
|
// Check if this link points to our target file
|
||||||
|
const resolvedFile = this.app.metadataCache.getFirstLinkpathDest(
|
||||||
|
link.link,
|
||||||
|
sourceFile.path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resolvedFile?.path === targetFile.path) {
|
||||||
|
const lineNumber = link.position.start.line;
|
||||||
|
const snippet = this.extractContextAroundLine(content, lineNumber, contextLines);
|
||||||
|
snippets.push({ snippet, line: lineNumber });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snippets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract context around a specific line
|
||||||
|
* @param content The full file content
|
||||||
|
* @param targetLine The line number (0-indexed) to extract context around
|
||||||
|
* @param contextLines Number of lines before/after (0 = header section)
|
||||||
|
* @returns The extracted context string
|
||||||
|
*/
|
||||||
|
private extractContextAroundLine(
|
||||||
|
content: string,
|
||||||
|
targetLine: number,
|
||||||
|
contextLines: number
|
||||||
|
): string {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
if (contextLines === 0) {
|
||||||
|
// Header section mode: find the containing section
|
||||||
|
let start = targetLine;
|
||||||
|
let end = targetLine;
|
||||||
|
|
||||||
|
// Walk backward to find the nearest header (or start of file)
|
||||||
|
while (start > 0 && !lines[start].match(/^#+\s/)) {
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk forward to find the next header (or end of file)
|
||||||
|
end = targetLine + 1;
|
||||||
|
while (end < lines.length && !lines[end].match(/^#+\s/)) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
end--; // Don't include the next header
|
||||||
|
|
||||||
|
return lines.slice(start, end + 1).join('\n');
|
||||||
|
} else {
|
||||||
|
// Fixed lines mode: N lines before and after
|
||||||
|
const start = Math.max(0, targetLine - contextLines);
|
||||||
|
const end = Math.min(lines.length - 1, targetLine + contextLines);
|
||||||
|
return lines.slice(start, end + 1).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}`, '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[], timeWindowDays?: number): Promise<{content: string, sourceNotes: string[]}> {
|
||||||
|
let aggregatedContent = "";
|
||||||
|
const sourceNotes: string[] = [];
|
||||||
|
|
||||||
|
// Create a map to track processed files by path to avoid duplicates
|
||||||
|
const processedFiles = new Map<string, boolean>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Skip if we've already processed this file
|
||||||
|
if (processedFiles.has(file.path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = await this.app.vault.read(file);
|
||||||
|
|
||||||
|
// Strip dataview queries from output content
|
||||||
|
content = this.stripDataviewQueries(content);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Mark this file as processed
|
||||||
|
processedFiles.set(file.path, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueFileCount = processedFiles.size;
|
||||||
|
|
||||||
|
return { content: aggregatedContent, sourceNotes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distill content from linked notes in a root note
|
||||||
|
* @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[],
|
||||||
|
timeWindowDays?: number,
|
||||||
|
customPrompt?: string
|
||||||
|
): Promise<DistillResponse> {
|
||||||
|
let combinedContent: string;
|
||||||
|
let sourceNotes: string[];
|
||||||
|
|
||||||
|
if (preparedContent && preparedSourceNotes) {
|
||||||
|
// Use the provided content and source notes
|
||||||
|
combinedContent = preparedContent;
|
||||||
|
sourceNotes = preparedSourceNotes;
|
||||||
|
} else {
|
||||||
|
// Get all linked notes
|
||||||
|
const linkedFiles = await this.getLinkedNotes(rootFile);
|
||||||
|
|
||||||
|
// Get content from root note
|
||||||
|
let rootContent = await this.app.vault.read(rootFile);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Combine root content with linked content
|
||||||
|
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 with optional custom prompt
|
||||||
|
const distilledContent = await this.openAIService.distillContent(combinedContent, customPrompt);
|
||||||
|
|
||||||
|
// Add source notes to the response
|
||||||
|
distilledContent.sourceNotes = [rootFile.basename, ...sourceNotes];
|
||||||
|
|
||||||
|
return distilledContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { App, TFile, Vault } from 'obsidian';
|
import { App, TFile, Vault } from 'obsidian';
|
||||||
import { sanitizeFilename, BacklinkMapper } from '../utils/filename-utils';
|
import { sanitizeFilename, BacklinkMapper, createFileWithCollisionHandling } from '../utils/filename-utils';
|
||||||
import { TranscriptResponse } from '../types/transcript';
|
import { TranscriptResponse, DistillResponse } from '../types/transcript';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for handling file operations
|
* Service for handling file operations
|
||||||
|
|
@ -9,22 +9,56 @@ export class FileService {
|
||||||
private vault: Vault;
|
private vault: Vault;
|
||||||
private summaryFolder: string;
|
private summaryFolder: string;
|
||||||
private notesFolder: string;
|
private notesFolder: string;
|
||||||
|
private publishedFolder: string;
|
||||||
private backlinkMapper: BacklinkMapper;
|
private backlinkMapper: BacklinkMapper;
|
||||||
|
|
||||||
constructor(app: App, summaryFolder: string, notesFolder: string) {
|
constructor(app: App, summaryFolder: string, notesFolder: string, publishedFolder: string) {
|
||||||
this.vault = app.vault;
|
this.vault = app.vault;
|
||||||
this.summaryFolder = summaryFolder;
|
this.summaryFolder = summaryFolder;
|
||||||
this.notesFolder = notesFolder;
|
this.notesFolder = notesFolder;
|
||||||
|
this.publishedFolder = publishedFolder;
|
||||||
this.backlinkMapper = new BacklinkMapper();
|
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
|
* Ensure output directories exist
|
||||||
*/
|
*/
|
||||||
async ensureDirectoriesExist(): Promise<void> {
|
async ensureDirectoriesExist(): Promise<void> {
|
||||||
const dirs = [
|
const dirs = [
|
||||||
this.summaryFolder,
|
this.summaryFolder,
|
||||||
this.notesFolder
|
this.notesFolder,
|
||||||
|
this.publishedFolder
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
|
|
@ -44,6 +78,14 @@ export class FileService {
|
||||||
// Ensure directories exist
|
// Ensure directories exist
|
||||||
await this.ensureDirectoriesExist();
|
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
|
// Sanitize filename
|
||||||
const sanitizedFilename = sanitizeFilename(filename);
|
const sanitizedFilename = sanitizeFilename(filename);
|
||||||
|
|
||||||
|
|
@ -66,15 +108,187 @@ export class FileService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output Summary with tasks
|
// 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) {
|
for (const note of data.notes) {
|
||||||
// Sanitize note title for filename
|
// Sanitize note title for filename
|
||||||
const sanitizedTitle = sanitizeFilename(note.title);
|
const sanitizedTitle = sanitizeFilename(note.title);
|
||||||
// Process content to ensure backlinks use sanitized filenames
|
// Process content to ensure backlinks use sanitized filenames
|
||||||
const processedContent = this.backlinkMapper.processBacklinks(note.content);
|
const processedContent = this.backlinkMapper.processBacklinks(note.content);
|
||||||
await this.vault.create(`${this.notesFolder}/${sanitizedTitle}.md`, processedContent);
|
await createFileWithCollisionHandling(
|
||||||
|
this.vault,
|
||||||
|
`${sessionPath}/${sanitizedTitle}.md`,
|
||||||
|
processedContent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write distilled data to files
|
||||||
|
* @param rootFile The root file that was distilled
|
||||||
|
* @param data Distilled data
|
||||||
|
*/
|
||||||
|
async writeDistilledFiles(rootFile: TFile, data: DistillResponse): Promise<string> {
|
||||||
|
// Ensure directories exist
|
||||||
|
await this.ensureDirectoriesExist();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
for (const note of data.notes) {
|
||||||
|
const sanitizedTitle = sanitizeFilename(note.title);
|
||||||
|
this.backlinkMapper.registerTitle(note.title, sanitizedTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format summary content with source notes and tasks
|
||||||
|
let summaryContent = this.backlinkMapper.processBacklinks(data.summary);
|
||||||
|
|
||||||
|
// Add source notes section
|
||||||
|
if (data.sourceNotes && data.sourceNotes.length > 0) {
|
||||||
|
const sourceNoteLinks = data.sourceNotes.map(note =>
|
||||||
|
`- [[${note}]]`
|
||||||
|
);
|
||||||
|
summaryContent += '\n\n## Source Notes\n' + sourceNoteLinks.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tasks section if there are tasks
|
||||||
|
if (data.tasks && data.tasks.length > 0) {
|
||||||
|
const processedTasks = data.tasks.map(task =>
|
||||||
|
this.backlinkMapper.processBacklinks(task)
|
||||||
|
);
|
||||||
|
summaryContent += '\n\n## Tasks\n' + processedTasks.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output Summary
|
||||||
|
const summaryPath = await createFileWithCollisionHandling(
|
||||||
|
this.vault,
|
||||||
|
`${this.summaryFolder}/${summaryFilename}.md`,
|
||||||
|
summaryContent
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 createFileWithCollisionHandling(
|
||||||
|
this.vault,
|
||||||
|
`${sessionPath}/${sanitizedTitle}.md`,
|
||||||
|
processedContent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write published post to file
|
||||||
|
* @param content The published blog post content
|
||||||
|
* @param sourceNotes Array of source note names
|
||||||
|
* @param promptName Name of the prompt used (or "default")
|
||||||
|
* @returns Path to the created file
|
||||||
|
*/
|
||||||
|
async writePublishedPost(
|
||||||
|
content: string,
|
||||||
|
sourceNotes: string[],
|
||||||
|
promptName: string = 'default'
|
||||||
|
): Promise<string> {
|
||||||
|
// Ensure published directory exists
|
||||||
|
await this.ensureDirectoriesExist();
|
||||||
|
|
||||||
|
// Generate filename with timestamp
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
|
||||||
|
// Try to extract a title from the first heading in the content
|
||||||
|
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||||
|
let titlePart = 'Published Post';
|
||||||
|
if (titleMatch && titleMatch[1]) {
|
||||||
|
titlePart = sanitizeFilename(titleMatch[1]);
|
||||||
|
// Truncate if too long
|
||||||
|
if (titlePart.length > 50) {
|
||||||
|
titlePart = titlePart.substring(0, 50) + '...';
|
||||||
|
}
|
||||||
|
} else if (sourceNotes.length > 0) {
|
||||||
|
// Fallback to first source note name
|
||||||
|
titlePart = sanitizeFilename(sourceNotes[0]);
|
||||||
|
if (titlePart.length > 30) {
|
||||||
|
titlePart = titlePart.substring(0, 30) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${titlePart} - Published ${timestamp}`;
|
||||||
|
|
||||||
|
// Create frontmatter
|
||||||
|
const frontmatter = `---
|
||||||
|
type: published-post
|
||||||
|
published_date: ${now.toISOString()}
|
||||||
|
prompt_used: ${promptName}
|
||||||
|
status: draft
|
||||||
|
source_notes: ${sourceNotes.map(n => `[[${n}]]`).join(', ')}
|
||||||
|
---
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create footer
|
||||||
|
const footer = `
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated from notes using OpenAugi. Source notes: ${sourceNotes.map(n => `[[${n}]]`).join(', ')}*
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Combine everything
|
||||||
|
const fullContent = frontmatter + content + footer;
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
const filePath = await createFileWithCollisionHandling(
|
||||||
|
this.vault,
|
||||||
|
`${this.publishedFolder}/${filename}.md`,
|
||||||
|
fullContent
|
||||||
|
);
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,130 @@
|
||||||
import { TranscriptResponse } from '../types/transcript';
|
import { TranscriptResponse, DistillResponse, PublishResponse } from '../types/transcript';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple tokeinzer to estimate the number of tokens
|
||||||
|
* @param text Text to count tokens from
|
||||||
|
* @returns Approximate token count
|
||||||
|
*/
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
// Rough estimate: 1 token is approximately 4 characters
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for handling OpenAI API calls
|
* Service for handling OpenAI API calls
|
||||||
*/
|
*/
|
||||||
export class OpenAIService {
|
export class OpenAIService {
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
|
private model: string;
|
||||||
|
|
||||||
constructor(apiKey: string) {
|
constructor(apiKey: string, model: string) {
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
|
this.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch available models from OpenAI API
|
||||||
|
* @param apiKey The OpenAI API key
|
||||||
|
* @returns Array of chat-compatible model IDs, sorted with flagship models first
|
||||||
|
*/
|
||||||
|
static async fetchAvailableModels(apiKey: string): Promise<string[]> {
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('API key is required to fetch models');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://api.openai.com/v1/models', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`Failed to fetch models: ${errorData.error?.message || response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Patterns to exclude (legacy, specialized, non-chat models)
|
||||||
|
const excludePatterns = [
|
||||||
|
/^ft:/, // Fine-tuned models
|
||||||
|
/^gpt-3/, // All GPT-3.x models (legacy)
|
||||||
|
/^gpt-4(?!\.)/, // GPT-4 without dot (gpt-4, gpt-4-turbo, gpt-4o, etc.) - all legacy
|
||||||
|
/^o1/, // o1 series (legacy reasoning)
|
||||||
|
/^o3/, // o3 series (legacy reasoning)
|
||||||
|
/realtime/i, // Realtime models (specialized)
|
||||||
|
/audio/i, // Audio-specific models
|
||||||
|
/transcription/i, // Transcription models
|
||||||
|
/tts/i, // Text-to-speech models
|
||||||
|
/whisper/i, // Whisper models
|
||||||
|
/dall-e/i, // Image generation
|
||||||
|
/embedding/i, // Embedding models
|
||||||
|
/moderation/i, // Moderation models
|
||||||
|
/davinci|curie|babbage|ada/i, // Legacy completion models
|
||||||
|
/search/i, // Search models
|
||||||
|
/-\d{4}-\d{2}-\d{2}/, // Dated snapshots (YYYY-MM-DD format)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Include models that start with gpt- (for gpt-5, gpt-5.1, gpt-6, etc.) or o4+
|
||||||
|
const chatModels = data.data
|
||||||
|
.map((model: { id: string }) => model.id)
|
||||||
|
.filter((id: string) => {
|
||||||
|
// Must start with gpt- or o (for reasoning models like o4, o5, etc.)
|
||||||
|
if (!id.startsWith('gpt-') && !id.startsWith('o') && !id.startsWith('chatgpt-')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not match any exclude pattern
|
||||||
|
const isExcluded = excludePatterns.some(pattern => pattern.test(id));
|
||||||
|
return !isExcluded;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort with flagship models first, then alphabetically
|
||||||
|
// GPT-5.2 is current flagship (Dec 2025), with instant/thinking/pro variants
|
||||||
|
const flagshipOrder = [
|
||||||
|
'gpt-5.2', 'gpt-5.2-instant', 'gpt-5.2-thinking', 'gpt-5.2-pro', 'gpt-5.2-codex',
|
||||||
|
'gpt-5.1', 'gpt-5.1-instant', 'gpt-5.1-thinking', 'gpt-5.1-pro',
|
||||||
|
'gpt-5', 'gpt-5-instant', 'gpt-5-thinking', 'gpt-5-pro',
|
||||||
|
'o4', 'o4-mini', 'o4-pro', 'o5', 'o5-mini', 'o5-pro'
|
||||||
|
];
|
||||||
|
|
||||||
|
chatModels.sort((a: string, b: string) => {
|
||||||
|
const aIndex = flagshipOrder.indexOf(a);
|
||||||
|
const bIndex = flagshipOrder.indexOf(b);
|
||||||
|
|
||||||
|
// Both are flagship models - sort by flagship order
|
||||||
|
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||||
|
// Only a is flagship - a comes first
|
||||||
|
if (aIndex !== -1) return -1;
|
||||||
|
// Only b is flagship - b comes first
|
||||||
|
if (bIndex !== -1) return 1;
|
||||||
|
// Neither is flagship - sort alphabetically
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return chatModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract custom context instructions from content if they exist
|
||||||
|
* @param content The content to extract the context from
|
||||||
|
* @returns The extracted context or null if none exists
|
||||||
|
*/
|
||||||
|
private extractCustomContext(content: string): string | null {
|
||||||
|
// Look for context: section in the content
|
||||||
|
const contextRegex = /```context:([\s\S]*?)```|context:([\s\S]*?)(?:\n\n|\n$|$)/;
|
||||||
|
const match = contextRegex.exec(content);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
// Return the first matching group that has content
|
||||||
|
const rawContext = (match[1] || match[2])?.trim() || null;
|
||||||
|
if (rawContext) {
|
||||||
|
return `# USER CONTEXT\nPlease apply these additional instructions when processing. The instructions should take priority to guide and focus what you should extract:\n${rawContext}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -16,7 +133,11 @@ export class OpenAIService {
|
||||||
* @returns The formatted prompt
|
* @returns The formatted prompt
|
||||||
*/
|
*/
|
||||||
private getPrompt(content: string): string {
|
private getPrompt(content: string): string {
|
||||||
return `
|
// Extract any custom context
|
||||||
|
const customContext = this.extractCustomContext(content);
|
||||||
|
|
||||||
|
// Base prompt
|
||||||
|
let prompt = `
|
||||||
You are an expert agent helping users process their voice notes into structured, useful Obsidian notes. Your mission is to capture the user's ideas, actions, and reflections in clean, atomic form. You act like a smart second brain, formatting output as Obsidian-ready markdown.
|
You are an expert agent helping users process their voice notes into structured, useful Obsidian notes. Your mission is to capture the user's ideas, actions, and reflections in clean, atomic form. You act like a smart second brain, formatting output as Obsidian-ready markdown.
|
||||||
|
|
||||||
# Special Command Handling
|
# Special Command Handling
|
||||||
|
|
@ -62,9 +183,17 @@ export class OpenAIService {
|
||||||
2. **Group context**: Organize ideas around coherent units. These units fomr the foundation of atomic notes.
|
2. **Group context**: Organize ideas around coherent units. These units fomr the foundation of atomic notes.
|
||||||
3. **Respect ambiguity**: When unsure, err on the side of creating a thoughtful atomic note.
|
3. **Respect ambiguity**: When unsure, err on the side of creating a thoughtful atomic note.
|
||||||
4. **Don't repeat**: Avoid redundancy across notes, tasks, or summary.
|
4. **Don't repeat**: Avoid redundancy across notes, tasks, or summary.
|
||||||
|
`;
|
||||||
|
|
||||||
Transcript:
|
// Add custom context if available
|
||||||
${content}`;
|
if (customContext) {
|
||||||
|
prompt += `\n\n${customContext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add transcript content
|
||||||
|
prompt += `\n\nTranscript:\n${content}`;
|
||||||
|
|
||||||
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -87,10 +216,9 @@ ${content}`;
|
||||||
'Authorization': `Bearer ${this.apiKey}`
|
'Authorization': `Bearer ${this.apiKey}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'gpt-4.1-2025-04-14',
|
model: this.model,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
temperature: 0.2,
|
max_completion_tokens: 32768,
|
||||||
max_tokens: 32768,
|
|
||||||
response_format: {
|
response_format: {
|
||||||
type: "json_schema",
|
type: "json_schema",
|
||||||
json_schema: {
|
json_schema: {
|
||||||
|
|
@ -161,4 +289,276 @@ ${content}`;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, customPrompt?: string): string {
|
||||||
|
// Extract any custom context from the content (which should include the root note)
|
||||||
|
const customContext = this.extractCustomContext(content);
|
||||||
|
|
||||||
|
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
|
||||||
|
Analyze the following notes carefully. Consider the title of the note to help you identify distinct concepts, ideas, and insights.
|
||||||
|
The title can be used to help you figure out what's relevant. Some titles might not be helpful in which case you should
|
||||||
|
determine the intent and most relevant concepts from the content.
|
||||||
|
|
||||||
|
### 1. Create Atomic Notes
|
||||||
|
- Identify distinct concepts, ideas, and insights across all notes
|
||||||
|
- Deduplicate and merge overlapping ideas
|
||||||
|
- Any distinct idea should be a separate note
|
||||||
|
- Create self-contained atomic notes with one clear idea per note
|
||||||
|
- Include supporting details and context
|
||||||
|
- Use \`[[Obsidian backlinks]]\` between notes when relevant
|
||||||
|
- Avoid repetition across notes
|
||||||
|
|
||||||
|
### 2. Extract Tasks
|
||||||
|
- Identify any actionable tasks present in the notes
|
||||||
|
- Format as: \`- [ ] Description of task [[Linked Atomic Note]]\` (if relevant)
|
||||||
|
- Only include genuinely actionable items, it's okay if there are none
|
||||||
|
|
||||||
|
### 3. Create a Summary
|
||||||
|
- Write a concise summary that synthesizes the key concepts
|
||||||
|
- Highlight connections between ideas
|
||||||
|
- Use \`[[Backlinks]]\` to connect to relevant atomic notes
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom context if available (this is from the context: section in notes)
|
||||||
|
if (customContext) {
|
||||||
|
prompt += `\n\n${customContext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add content to distill
|
||||||
|
prompt += `\n\n# Content to Distill:\n${content}`;
|
||||||
|
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, customPrompt?: string): Promise<DistillResponse> {
|
||||||
|
if (!this.apiKey) {
|
||||||
|
throw new Error('OpenAI API key not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = this.getDistillPrompt(content, customPrompt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
max_completion_tokens: 32768,
|
||||||
|
response_format: {
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: {
|
||||||
|
name: "distill_content",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
summary: {
|
||||||
|
type: "string",
|
||||||
|
description: "Concise summary that synthesizes the key concepts from all notes, with backlinks to atomic notes."
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: "string",
|
||||||
|
description: "Title of the atomic note"
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: "string",
|
||||||
|
description: "Markdown-formatted, self-contained idea with backlinks if relevant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["title", "content"],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
description: "Markdown-formatted task with checkbox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["summary", "notes", "tasks"],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
strict: true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`OpenAI API error: ${response.status} ${errorData.error?.message || response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
const structuredData = responseData.choices[0].message.content;
|
||||||
|
|
||||||
|
// Check for API refusal
|
||||||
|
if (responseData.choices[0].message.refusal) {
|
||||||
|
throw new Error(`API refusal: ${responseData.choices[0].message.refusal}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON
|
||||||
|
const parsedData: DistillResponse = typeof structuredData === 'string'
|
||||||
|
? JSON.parse(structuredData)
|
||||||
|
: structuredData;
|
||||||
|
|
||||||
|
// Initialize sourceNotes as empty array (will be populated by DistillService)
|
||||||
|
parsedData.sourceNotes = [];
|
||||||
|
|
||||||
|
return parsedData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling OpenAI API:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default publishing prompt
|
||||||
|
* @returns The default prompt for publishing content
|
||||||
|
*/
|
||||||
|
private getDefaultPublishPrompt(): string {
|
||||||
|
return `You are helping transform raw notes into a polished, publishable blog post.
|
||||||
|
|
||||||
|
Take these notes and create ONE cohesive blog post that:
|
||||||
|
|
||||||
|
PRESERVE:
|
||||||
|
- The author's unique voice and personality
|
||||||
|
- Direct, conversational tone
|
||||||
|
- Creative language and specific phrases
|
||||||
|
- The core insights and ideas
|
||||||
|
|
||||||
|
ADD:
|
||||||
|
- Why this matters to the reader
|
||||||
|
- Context where needed (but don't over-explain)
|
||||||
|
- Natural transitions between ideas
|
||||||
|
- A clear narrative arc or structure
|
||||||
|
|
||||||
|
FORMAT:
|
||||||
|
- Short paragraphs (2-4 sentences)
|
||||||
|
- Use headers/subheaders to organize sections
|
||||||
|
- Bold key phrases sparingly for emphasis
|
||||||
|
- Conversational but polished
|
||||||
|
|
||||||
|
TONE:
|
||||||
|
- Write like you're explaining to a curious friend
|
||||||
|
- Be direct and honest
|
||||||
|
- Don't be overly formal or academic
|
||||||
|
- Let personality shine through
|
||||||
|
|
||||||
|
OUTPUT:
|
||||||
|
Return a single markdown blog post, ready to publish.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the publishing prompt for the OpenAI API
|
||||||
|
* @param content The aggregated content from notes
|
||||||
|
* @param customPrompt Optional custom prompt to replace the default
|
||||||
|
* @returns The formatted prompt
|
||||||
|
*/
|
||||||
|
private getPublishPrompt(content: string, customPrompt?: string): string {
|
||||||
|
// Extract any custom context from the content
|
||||||
|
const customContext = this.extractCustomContext(content);
|
||||||
|
|
||||||
|
let prompt: string;
|
||||||
|
|
||||||
|
if (customPrompt) {
|
||||||
|
// Use the custom prompt as the main instructions
|
||||||
|
prompt = customPrompt;
|
||||||
|
} else {
|
||||||
|
// Use the default publishing prompt
|
||||||
|
prompt = this.getDefaultPublishPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom context if available (this is from the context: section in notes)
|
||||||
|
if (customContext) {
|
||||||
|
prompt += `\n\n${customContext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add content to publish
|
||||||
|
prompt += `\n\n# Content to Transform:\n${content}`;
|
||||||
|
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call the OpenAI API to publish content as a single blog post
|
||||||
|
* @param content The aggregated content from notes
|
||||||
|
* @param customPrompt Optional custom prompt to replace the default
|
||||||
|
* @returns Published content as plain markdown
|
||||||
|
*/
|
||||||
|
async publishContent(content: string, customPrompt?: string): Promise<string> {
|
||||||
|
if (!this.apiKey) {
|
||||||
|
throw new Error('OpenAI API key not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = this.getPublishPrompt(content, customPrompt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
max_completion_tokens: 32768
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`OpenAI API error: ${response.status} ${errorData.error?.message || response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
// Check for API refusal
|
||||||
|
if (responseData.choices[0].message.refusal) {
|
||||||
|
throw new Error(`API refusal: ${responseData.choices[0].message.refusal}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the plain text content
|
||||||
|
const publishedContent = responseData.choices[0].message.content;
|
||||||
|
|
||||||
|
return publishedContent;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling OpenAI API for publishing:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
406
src/services/task-dispatch-service.ts
Normal file
406
src/services/task-dispatch-service.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
import { App, TFile, Notice, FileSystemAdapter } from 'obsidian';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { OpenAugiSettings } from '../types/settings';
|
||||||
|
import { DistillService } from './distill-service';
|
||||||
|
import { AgentConfig, RepoPath, TaskSession } from '../types/task-dispatch';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/** Common locations where Homebrew installs tmux. */
|
||||||
|
const TMUX_SEARCH_PATHS = [
|
||||||
|
'/opt/homebrew/bin/tmux', // Apple Silicon Homebrew
|
||||||
|
'/usr/local/bin/tmux', // Intel Homebrew
|
||||||
|
'/usr/bin/tmux', // system install
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to locate the tmux binary on disk.
|
||||||
|
* Returns the absolute path if found, or null.
|
||||||
|
*/
|
||||||
|
export async function detectTmuxPath(): Promise<string | null> {
|
||||||
|
for (const p of TMUX_SEARCH_PATHS) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(p, fs.constants.X_OK);
|
||||||
|
return p;
|
||||||
|
} catch { /* not here */ }
|
||||||
|
}
|
||||||
|
// Fallback: try `which` with an augmented PATH
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('which tmux', {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH ?? '/usr/bin:/bin'}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const found = stdout.trim();
|
||||||
|
if (found) return found;
|
||||||
|
} catch { /* not found */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a working_dir value against the configured repo paths.
|
||||||
|
* Case-insensitive match. Returns the absolute path if found, or null.
|
||||||
|
*/
|
||||||
|
export function resolveRepoPath(name: string, repoPaths: RepoPath[]): string | null {
|
||||||
|
if (!repoPaths || repoPaths.length === 0) return null;
|
||||||
|
const match = repoPaths.find(rp => rp.name.toLowerCase() === name.toLowerCase());
|
||||||
|
return match?.path ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaskDispatchService {
|
||||||
|
private app: App;
|
||||||
|
private settings: OpenAugiSettings;
|
||||||
|
private distillService: DistillService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
settings: OpenAugiSettings,
|
||||||
|
distillService: DistillService
|
||||||
|
) {
|
||||||
|
this.app = app;
|
||||||
|
this.settings = settings;
|
||||||
|
this.distillService = distillService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the tmux binary path from settings or auto-detect. */
|
||||||
|
private async getTmux(): Promise<string> {
|
||||||
|
const configured = this.settings.taskDispatch.tmuxPath;
|
||||||
|
if (configured) return configured;
|
||||||
|
|
||||||
|
const detected = await detectTmuxPath();
|
||||||
|
if (detected) return detected;
|
||||||
|
throw new Error('tmux not found. Set the path in Settings → Task Dispatch, or install with: brew install tmux');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a new session or attach to an existing one for the given task note.
|
||||||
|
* The plugin only reads task_id from frontmatter — all other task state
|
||||||
|
* is managed by the MCP server / agent inside the session.
|
||||||
|
*/
|
||||||
|
async launchOrAttach(file: TFile): Promise<void> {
|
||||||
|
const taskId = this.getTaskId(file);
|
||||||
|
if (!taskId) {
|
||||||
|
new Notice('This note is not a task note. Add task_id to frontmatter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionName = `task-${taskId}`;
|
||||||
|
const agentConfig = this.getAgentConfig();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
const exists = await this.tmuxSessionExists(tmux, sessionName);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
new Notice(`Attaching to session: ${taskId}`);
|
||||||
|
await this.openTerminal(sessionName);
|
||||||
|
} else {
|
||||||
|
new Notice(`Launching session: ${taskId}`);
|
||||||
|
|
||||||
|
const contextContent = await this.assembleContext(file, taskId);
|
||||||
|
const contextFilePath = await this.writeContextFile(taskId, contextContent);
|
||||||
|
|
||||||
|
const workingDir = this.getWorkingDir(file);
|
||||||
|
await this.createTmuxSession(tmux, sessionName, agentConfig, contextFilePath, workingDir);
|
||||||
|
await this.openTerminal(sessionName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Task dispatch error:', error);
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
new Notice(`Task dispatch failed: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill the tmux session for the given task note.
|
||||||
|
*/
|
||||||
|
async killSession(file: TFile): Promise<void> {
|
||||||
|
const taskId = this.getTaskId(file);
|
||||||
|
if (!taskId) {
|
||||||
|
new Notice('This note is not a task note. Add task_id to frontmatter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionName = `task-${taskId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
const exists = await this.tmuxSessionExists(tmux, sessionName);
|
||||||
|
if (!exists) {
|
||||||
|
new Notice(`No active session for: ${taskId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(`${tmux} kill-session -t ${this.shellEscape(sessionName)}`);
|
||||||
|
this.cleanupContextFile(taskId);
|
||||||
|
|
||||||
|
new Notice(`Killed session: ${taskId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to kill session:', error);
|
||||||
|
new Notice(`Failed to kill session: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a session by task ID (used from the session list modal).
|
||||||
|
*/
|
||||||
|
async killSessionById(taskId: string): Promise<void> {
|
||||||
|
const sessionName = `task-${taskId}`;
|
||||||
|
try {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
await execAsync(`${tmux} kill-session -t ${this.shellEscape(sessionName)}`);
|
||||||
|
this.cleanupContextFile(taskId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to kill session:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all active task tmux sessions.
|
||||||
|
*/
|
||||||
|
async listActiveSessions(): Promise<TaskSession[]> {
|
||||||
|
try {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
const { stdout } = await execAsync(
|
||||||
|
`${tmux} list-sessions -F "#{session_name} #{session_created}" 2>/dev/null`
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessions: TaskSession[] = [];
|
||||||
|
|
||||||
|
for (const line of stdout.trim().split('\n')) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
const parts = line.trim().split(' ');
|
||||||
|
const sessionName = parts[0];
|
||||||
|
const createdTimestamp = parts[1];
|
||||||
|
|
||||||
|
if (!sessionName.startsWith('task-')) continue;
|
||||||
|
|
||||||
|
const taskId = sessionName.replace('task-', '');
|
||||||
|
const noteFile = this.findTaskNote(taskId);
|
||||||
|
|
||||||
|
const startedAt = createdTimestamp
|
||||||
|
? new Date(parseInt(createdTimestamp) * 1000).toISOString()
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
taskId,
|
||||||
|
tmuxSessionName: sessionName,
|
||||||
|
startedAt,
|
||||||
|
noteFile
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions;
|
||||||
|
} catch {
|
||||||
|
// tmux not running or no sessions — return empty
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open terminal attached to a tmux session (public for use from modal).
|
||||||
|
*/
|
||||||
|
async openTerminal(sessionName: string): Promise<void> {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
const terminalApp = this.settings.taskDispatch.terminalApp;
|
||||||
|
const attachCmd = `${tmux} attach -t ${this.shellEscape(sessionName)}`;
|
||||||
|
|
||||||
|
let osascript: string;
|
||||||
|
if (terminalApp === 'iterm2') {
|
||||||
|
osascript = `tell application "iTerm2" to create window with default profile command "${attachCmd}"`;
|
||||||
|
} else {
|
||||||
|
osascript = `tell app "Terminal" to do script "${attachCmd}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execAsync(`osascript -e '${osascript}'`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open terminal:', error);
|
||||||
|
new Notice(`Could not open ${terminalApp === 'iterm2' ? 'iTerm2' : 'Terminal.app'}. Check your terminal settings.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read task_id from frontmatter. This is the only field the plugin cares about.
|
||||||
|
*/
|
||||||
|
private getTaskId(file: TFile): string | null {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const fm = cache?.frontmatter;
|
||||||
|
const taskId = fm?.['task_id'] || fm?.['task-id'];
|
||||||
|
return taskId ? String(taskId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the working directory for a task session.
|
||||||
|
* Priority: `working_dir` frontmatter → defaultWorkingDir setting → home dir.
|
||||||
|
*/
|
||||||
|
private getWorkingDir(file: TFile): string {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const fm = cache?.frontmatter;
|
||||||
|
const workingDir = fm?.['working_dir'] || fm?.['working-dir'];
|
||||||
|
|
||||||
|
if (workingDir && typeof workingDir === 'string') {
|
||||||
|
// 1. Check if it matches a named repo path
|
||||||
|
const repoMatch = resolveRepoPath(workingDir, this.settings.taskDispatch.repoPaths);
|
||||||
|
if (repoMatch) return repoMatch;
|
||||||
|
|
||||||
|
// 2. Absolute path — use as-is
|
||||||
|
if (path.isAbsolute(workingDir)) return workingDir;
|
||||||
|
|
||||||
|
// 3. Relative path — resolve against vault root
|
||||||
|
return this.resolveVaultPath(workingDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDir = this.settings.taskDispatch.defaultWorkingDir;
|
||||||
|
if (defaultDir) {
|
||||||
|
return path.isAbsolute(defaultDir) ? defaultDir : this.resolveVaultPath(defaultDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.HOME ?? '/tmp';
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveVaultPath(relative: string): string {
|
||||||
|
const adapter = this.app.vault.adapter as FileSystemAdapter;
|
||||||
|
return path.join(adapter.getBasePath(), relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assembleContext(file: TFile, taskId: string): Promise<string> {
|
||||||
|
// Read note body and strip frontmatter
|
||||||
|
const rawContent = await this.app.vault.read(file);
|
||||||
|
const noteBody = rawContent.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
|
||||||
|
|
||||||
|
// Get linked notes and aggregate their content
|
||||||
|
const linkedFiles = await this.distillService.getLinkedNotes(file);
|
||||||
|
let linkedContent = '';
|
||||||
|
|
||||||
|
if (linkedFiles.length > 0) {
|
||||||
|
const { content } = await this.distillService.aggregateContent(linkedFiles);
|
||||||
|
linkedContent = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
let context = `# Task: ${taskId}\n\n---\n\n## Task Note\n\n${noteBody}`;
|
||||||
|
|
||||||
|
if (linkedContent) {
|
||||||
|
context += `\n\n---\n\n## Linked Context\n${linkedContent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
context += `\n\n---\n\n## Instructions\n\nTask file: ${file.path}\nTask ID: ${taskId}\n\nWork with the context above first. Only search the vault via MCP if needed.\n\nWhen you have results, use the MCP append_results tool to write them back to the task note.\nThe ## Results section of the task note is our shared communication channel.\nLink any files you create as [[wikilinks]] in your results.\n\nYou have MCP tools available for searching the user's Obsidian vault (semantic search, tag search, hub discovery, etc.) and for writing results back. Use them to find related notes, look up referenced concepts, or gather additional context when the information above is insufficient.`;
|
||||||
|
|
||||||
|
// Cap at max characters
|
||||||
|
const maxChars = this.settings.taskDispatch.maxContextChars;
|
||||||
|
if (context.length > maxChars) {
|
||||||
|
context = context.substring(0, maxChars) + '\n\n...(context truncated at character limit)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeContextFile(taskId: string, content: string): Promise<string> {
|
||||||
|
const dir = this.settings.taskDispatch.contextTempDir;
|
||||||
|
const filePath = path.join(dir, `task-${taskId}-context.md`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, content, 'utf-8');
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupContextFile(taskId: string): void {
|
||||||
|
const filePath = path.join(
|
||||||
|
this.settings.taskDispatch.contextTempDir,
|
||||||
|
`task-${taskId}-context.md`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical, ignore cleanup failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tmuxSessionExists(tmux: string, sessionName: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execAsync(`${tmux} has-session -t ${this.shellEscape(sessionName)} 2>/dev/null`);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createTmuxSession(
|
||||||
|
tmux: string,
|
||||||
|
sessionName: string,
|
||||||
|
agentConfig: AgentConfig,
|
||||||
|
contextFilePath: string,
|
||||||
|
workingDir: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Ensure the working directory exists before launching the session.
|
||||||
|
await fs.promises.mkdir(workingDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create session with a normal login shell so the user's PATH is available.
|
||||||
|
await execAsync(`${tmux} new-session -d -s ${this.shellEscape(sessionName)} -c ${this.shellEscape(workingDir)}`);
|
||||||
|
|
||||||
|
// Wait for the shell prompt to appear before sending keys.
|
||||||
|
// Without this, send-keys can fire before the shell is ready and characters get lost.
|
||||||
|
await this.waitForShellReady(tmux, sessionName);
|
||||||
|
|
||||||
|
// Pass the context file contents as the initial user prompt via $(cat ...).
|
||||||
|
const agentCmd = `cd ${this.shellEscape(workingDir)} && ${agentConfig.command} "$(cat ${this.shellEscape(contextFilePath)})"`;
|
||||||
|
await execAsync(
|
||||||
|
`${tmux} send-keys -t ${this.shellEscape(sessionName)} ${this.shellEscape(agentCmd)} Enter`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAgentConfig(): AgentConfig {
|
||||||
|
const agents = this.settings.taskDispatch.agents;
|
||||||
|
const id = this.settings.taskDispatch.defaultAgent;
|
||||||
|
return agents.find(a => a.id === id) || agents[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTaskNote(taskId: string): TFile | undefined {
|
||||||
|
const files = this.app.vault.getMarkdownFiles();
|
||||||
|
for (const file of files) {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const fm = cache?.frontmatter;
|
||||||
|
if (fm?.['task_id'] === taskId || fm?.['task-id'] === taskId) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll the tmux pane until the shell has printed something (i.e. the prompt),
|
||||||
|
* indicating it's ready to receive input.
|
||||||
|
*/
|
||||||
|
private async waitForShellReady(tmux: string, sessionName: string, maxAttempts = 10): Promise<void> {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(
|
||||||
|
`${tmux} capture-pane -t ${this.shellEscape(sessionName)} -p`
|
||||||
|
);
|
||||||
|
// Once the pane has any non-empty content, the shell prompt is up.
|
||||||
|
if (stdout.trim().length > 0) return;
|
||||||
|
} catch {
|
||||||
|
// Session not ready yet, keep waiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we exhausted attempts, proceed anyway — better than hanging forever.
|
||||||
|
}
|
||||||
|
|
||||||
|
private shellEscape(str: string): string {
|
||||||
|
return `'${str.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/services/task-file-service.ts
Normal file
160
src/services/task-file-service.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { App } from 'obsidian';
|
||||||
|
import { createFileWithCollisionHandling } from '../utils/filename-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TaskFileService — writes `status: pending` task files to OpenAugi/Tasks/
|
||||||
|
* via the Obsidian vault API. No shell-out, no HTTP, mobile-safe.
|
||||||
|
*
|
||||||
|
* The task file IS the trigger contract: the OpenAugi task watcher
|
||||||
|
* (`openaugi up` in the parent repo) polls OpenAugi/Tasks/ for pending
|
||||||
|
* files and launches an agent session for each. This service, the
|
||||||
|
* `openaugi review` CLI, and the zzz capture grammar all converge on the
|
||||||
|
* same file format, defined authoritatively in the parent repo at
|
||||||
|
* `src/openaugi/templates/task-template.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Where the task watcher looks for pending task files (vault-relative). */
|
||||||
|
export const TASKS_FOLDER = 'OpenAugi/Tasks';
|
||||||
|
|
||||||
|
/** Frontmatter marker identifying tasks written by this plugin. */
|
||||||
|
export const SOURCE_BLOCK_ID = 'obsidian-plugin';
|
||||||
|
|
||||||
|
export interface TaskFileOptions {
|
||||||
|
/** Human-readable task title — becomes the `# heading` and filename slug. */
|
||||||
|
title: string;
|
||||||
|
/** Verbatim content for the `## Context` section. */
|
||||||
|
context: string;
|
||||||
|
/** The literal user directive, block-quoted under `## User instruction`. */
|
||||||
|
instruction: string;
|
||||||
|
/** Self-contained description for the `## Task` section (the agent's real prompt). */
|
||||||
|
task: string;
|
||||||
|
/** Wikilink target for `source_note` frontmatter (note title, no brackets). */
|
||||||
|
sourceNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip YAML frontmatter from note content, if present. */
|
||||||
|
export function stripFrontmatter(content: string): string {
|
||||||
|
return content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase, filename-safe slug for the task filename. */
|
||||||
|
export function taskFileSlug(title: string, maxLen = 50): string {
|
||||||
|
const slug = title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return slug.slice(0, maxLen).replace(/-+$/, '') || 'task';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYYMMDD-HHMMSS local timestamp, matching the `openaugi review` CLI. */
|
||||||
|
export function taskFileTimestamp(now: Date = new Date()): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
||||||
|
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full task-file markdown per the task-template contract.
|
||||||
|
* Section order matters: the watcher hydrates around `## Results` and the
|
||||||
|
* agent treats `## Task` as its prompt.
|
||||||
|
*/
|
||||||
|
export function buildTaskFileContent(opts: TaskFileOptions): string {
|
||||||
|
return `---
|
||||||
|
status: pending
|
||||||
|
source_block_id: ${SOURCE_BLOCK_ID}
|
||||||
|
source_note: "[[${opts.sourceNote}]]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${opts.title}
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
${opts.context.trim()}
|
||||||
|
|
||||||
|
## User instruction
|
||||||
|
|
||||||
|
> ${opts.instruction}
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
${opts.task.trim()}
|
||||||
|
|
||||||
|
## Human Todo
|
||||||
|
|
||||||
|
## Results
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaskFileService {
|
||||||
|
constructor(private app: App) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a full review pass. The watcher-launched agent reads
|
||||||
|
* OpenAugi/AGENT/review-pass.md and runs the whole loop.
|
||||||
|
*/
|
||||||
|
async createReviewPassTask(now: Date = new Date()): Promise<string> {
|
||||||
|
const instruction = 'run the review pass';
|
||||||
|
return this.writeTask({
|
||||||
|
title: instruction,
|
||||||
|
context: `Triggered via the OpenAugi plugin command "Augi: Run review pass" at ${taskFileTimestamp(now)}.`,
|
||||||
|
instruction,
|
||||||
|
task: `Read OpenAugi/AGENT/review-pass.md and execute: ${instruction}.`,
|
||||||
|
sourceNote: 'OpenAugi plugin',
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue dashboard processing only — execute nomination answers,
|
||||||
|
* no new-block routing.
|
||||||
|
*/
|
||||||
|
async createProcessDashboardTask(now: Date = new Date()): Promise<string> {
|
||||||
|
const instruction = 'process the dashboard';
|
||||||
|
return this.writeTask({
|
||||||
|
title: instruction,
|
||||||
|
context: `Triggered via the OpenAugi plugin command "Augi: Process dashboard" at ${taskFileTimestamp(now)}.`,
|
||||||
|
instruction,
|
||||||
|
task: `Read OpenAugi/AGENT/review-pass.md and execute: ${instruction}.`,
|
||||||
|
sourceNote: 'OpenAugi plugin',
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a distill task for the given content (current selection or
|
||||||
|
* active note body). The content goes into `## Context` verbatim; the
|
||||||
|
* agent applies the distill lens to exactly that scope.
|
||||||
|
*/
|
||||||
|
async createDistillTask(context: string, sourceNote: string, now: Date = new Date()): Promise<string> {
|
||||||
|
return this.writeTask({
|
||||||
|
title: `distill ${sourceNote}`,
|
||||||
|
context,
|
||||||
|
instruction: 'distill this per OpenAugi/AGENT/distill-lens.md',
|
||||||
|
task:
|
||||||
|
'Read OpenAugi/AGENT/distill-lens.md and apply the distill lens to the ' +
|
||||||
|
'content in the ## Context section above. That content is the entire ' +
|
||||||
|
'scope — do not pull in additional notes.',
|
||||||
|
sourceNote,
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure OpenAugi/Tasks/ exists and write the task file into it. */
|
||||||
|
private async writeTask(opts: TaskFileOptions, now: Date): Promise<string> {
|
||||||
|
// Create ancestors one level at a time — createFolder is not
|
||||||
|
// guaranteed to be recursive on all platforms.
|
||||||
|
let folder = '';
|
||||||
|
for (const segment of TASKS_FOLDER.split('/')) {
|
||||||
|
folder = folder ? `${folder}/${segment}` : segment;
|
||||||
|
if (!(await this.app.vault.adapter.exists(folder))) {
|
||||||
|
await this.app.vault.createFolder(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const filename = `${taskFileSlug(opts.title)}-${taskFileTimestamp(now)}.md`;
|
||||||
|
const content = buildTaskFileContent(opts);
|
||||||
|
return createFileWithCollisionHandling(
|
||||||
|
this.app.vault,
|
||||||
|
`${TASKS_FOLDER}/${filename}`,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/types/context.ts
Normal file
88
src/types/context.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { TFile } from 'obsidian';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for context gathering
|
||||||
|
*/
|
||||||
|
export interface ContextGatheringConfig {
|
||||||
|
// Source mode
|
||||||
|
sourceMode: 'linked-notes' | 'recent-activity';
|
||||||
|
rootNote?: TFile; // Required for linked-notes mode
|
||||||
|
|
||||||
|
// Link traversal settings (for linked-notes mode)
|
||||||
|
linkDepth: number; // 1-3
|
||||||
|
maxCharacters: number; // Default: 100000
|
||||||
|
|
||||||
|
// Time filtering (for recent-activity mode)
|
||||||
|
timeWindow?: {
|
||||||
|
mode: 'days-back' | 'date-range';
|
||||||
|
daysBack?: number;
|
||||||
|
fromDate?: string;
|
||||||
|
toDate?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Folder filtering
|
||||||
|
excludeFolders: string[];
|
||||||
|
|
||||||
|
// Section filtering for journal-style notes
|
||||||
|
filterRecentSectionsOnly: boolean; // Uses date header logic from settings
|
||||||
|
dateHeaderFormat: string; // e.g., "### YYYY-MM-DD"
|
||||||
|
|
||||||
|
// For recent activity: how many days back to filter journal sections
|
||||||
|
journalSectionDays?: number;
|
||||||
|
|
||||||
|
// Backlink discovery settings
|
||||||
|
includeBacklinks?: boolean; // Whether to also discover backlinks at each level
|
||||||
|
backlinkContextLines?: number; // 0 = header section, N = lines before/after link
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a discovered note with metadata about how it was found
|
||||||
|
*/
|
||||||
|
export interface DiscoveredNote {
|
||||||
|
file: TFile;
|
||||||
|
depth: number; // 0 = root, 1 = direct link, 2 = second level, etc.
|
||||||
|
discoveredVia: string; // "root" | "linked from [[Note]]" | "backlink from [[Note]]" | "recent activity"
|
||||||
|
estimatedChars: number;
|
||||||
|
included: boolean; // User can toggle in checkbox modal
|
||||||
|
|
||||||
|
// Backlink-specific fields
|
||||||
|
isBacklink: boolean; // true if discovered via backlink
|
||||||
|
backlinkSnippet?: string; // The extracted header section/block (only for backlinks)
|
||||||
|
backlinkLine?: number; // Line number where link appears
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The complete gathered context with all metadata
|
||||||
|
*/
|
||||||
|
export interface GatheredContext {
|
||||||
|
notes: DiscoveredNote[];
|
||||||
|
aggregatedContent: string;
|
||||||
|
totalCharacters: number;
|
||||||
|
totalNotes: number;
|
||||||
|
config: ContextGatheringConfig;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options that customize the flow per command
|
||||||
|
*/
|
||||||
|
export interface CommandOptions {
|
||||||
|
commandType: 'distill' | 'publish' | 'save-raw';
|
||||||
|
defaultSourceMode: 'linked-notes' | 'recent-activity';
|
||||||
|
defaultDepth: number;
|
||||||
|
skipPreview?: boolean; // For "save raw" command
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processing type for AI operations
|
||||||
|
*/
|
||||||
|
export type ProcessingType = 'distill' | 'publish';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for processing with AI
|
||||||
|
*/
|
||||||
|
export interface ProcessingConfig {
|
||||||
|
type: ProcessingType;
|
||||||
|
customPrompt?: string;
|
||||||
|
customPromptName?: string;
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,15 @@ import { Plugin } from 'obsidian';
|
||||||
import { OpenAugiSettings } from './settings';
|
import { OpenAugiSettings } from './settings';
|
||||||
import { OpenAIService } from '../services/openai-service';
|
import { OpenAIService } from '../services/openai-service';
|
||||||
import { FileService } from '../services/file-service';
|
import { FileService } from '../services/file-service';
|
||||||
|
import type { TaskDispatchService } from '../services/task-dispatch-service';
|
||||||
|
import { TaskFileService } from '../services/task-file-service';
|
||||||
|
|
||||||
export default interface OpenAugiPlugin extends Plugin {
|
export default interface OpenAugiPlugin extends Plugin {
|
||||||
settings: OpenAugiSettings;
|
settings: OpenAugiSettings;
|
||||||
openAIService: OpenAIService;
|
openAIService: OpenAIService;
|
||||||
fileService: FileService;
|
fileService: FileService;
|
||||||
|
taskFileService: TaskFileService;
|
||||||
|
/** @deprecated Task Dispatch bypasses the task watcher — use TaskFileService. */
|
||||||
|
taskDispatchService: TaskDispatchService | null;
|
||||||
saveSettings(): Promise<void>;
|
saveSettings(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,76 @@
|
||||||
import { App } from 'obsidian';
|
import { App } from 'obsidian';
|
||||||
|
import { TaskDispatchSettings } from './task-dispatch';
|
||||||
|
|
||||||
|
export interface RecentActivitySettings {
|
||||||
|
daysBack: number;
|
||||||
|
excludeFolders: string[];
|
||||||
|
filterJournalSections: boolean;
|
||||||
|
dateHeaderFormat: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextGatheringDefaults {
|
||||||
|
linkDepth: number;
|
||||||
|
maxCharacters: number;
|
||||||
|
filterRecentSectionsOnly: boolean;
|
||||||
|
includeBacklinks: boolean;
|
||||||
|
backlinkContextLines: number; // 0 = header section, N = lines before/after
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenAugiSettings {
|
export interface OpenAugiSettings {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
defaultModel: string;
|
||||||
|
customModelOverride: string;
|
||||||
|
cachedModels: string[];
|
||||||
summaryFolder: string;
|
summaryFolder: string;
|
||||||
notesFolder: string;
|
notesFolder: string;
|
||||||
|
promptsFolder: string;
|
||||||
|
publishedFolder: string;
|
||||||
|
useDataviewIfAvailable: boolean;
|
||||||
|
enableDistillLogging: boolean;
|
||||||
|
recentActivityDefaults: RecentActivitySettings;
|
||||||
|
contextGatheringDefaults: ContextGatheringDefaults;
|
||||||
|
taskDispatch: TaskDispatchSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: OpenAugiSettings = {
|
export const DEFAULT_SETTINGS: OpenAugiSettings = {
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
|
defaultModel: 'gpt-5.2',
|
||||||
|
customModelOverride: '',
|
||||||
|
cachedModels: ['gpt-5.2', 'gpt-5.2-instant', 'gpt-5.2-thinking', 'gpt-5.2-pro', 'gpt-5.2-codex', 'gpt-5.1', 'gpt-5', 'o4-mini'],
|
||||||
summaryFolder: 'OpenAugi/Summaries',
|
summaryFolder: 'OpenAugi/Summaries',
|
||||||
notesFolder: 'OpenAugi/Notes'
|
notesFolder: 'OpenAugi/Notes',
|
||||||
|
promptsFolder: 'OpenAugi/Prompts',
|
||||||
|
publishedFolder: 'OpenAugi/Published',
|
||||||
|
useDataviewIfAvailable: true,
|
||||||
|
enableDistillLogging: false,
|
||||||
|
recentActivityDefaults: {
|
||||||
|
daysBack: 7,
|
||||||
|
excludeFolders: ['Templates', 'Archive', 'OpenAugi'],
|
||||||
|
filterJournalSections: true,
|
||||||
|
dateHeaderFormat: '### YYYY-MM-DD'
|
||||||
|
},
|
||||||
|
contextGatheringDefaults: {
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
filterRecentSectionsOnly: true,
|
||||||
|
includeBacklinks: true,
|
||||||
|
backlinkContextLines: 0
|
||||||
|
},
|
||||||
|
taskDispatch: {
|
||||||
|
terminalApp: 'iterm2',
|
||||||
|
tmuxPath: '',
|
||||||
|
defaultWorkingDir: 'OpenAugi/Tasks',
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
id: 'claude-code',
|
||||||
|
name: 'Claude Code',
|
||||||
|
command: 'claude',
|
||||||
|
contextFlag: '--append-system-prompt'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaultAgent: 'claude-code',
|
||||||
|
contextTempDir: '/tmp/openaugi',
|
||||||
|
maxContextChars: 200000,
|
||||||
|
repoPaths: []
|
||||||
|
}
|
||||||
};
|
};
|
||||||
33
src/types/task-dispatch.ts
Normal file
33
src/types/task-dispatch.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { TFile } from 'obsidian';
|
||||||
|
|
||||||
|
export interface AgentConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
contextFlag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoPath {
|
||||||
|
name: string; // short name used in frontmatter, e.g. "my-repo"
|
||||||
|
path: string; // absolute filesystem path, e.g. "/Users/chris/repos/my-repo"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TerminalApp = 'iterm2' | 'terminal-app';
|
||||||
|
|
||||||
|
export interface TaskDispatchSettings {
|
||||||
|
terminalApp: TerminalApp;
|
||||||
|
tmuxPath: string; // absolute path to tmux binary; empty = auto-detect
|
||||||
|
defaultWorkingDir: string; // directory Claude launches in; overridden by `working_dir` frontmatter
|
||||||
|
agents: AgentConfig[];
|
||||||
|
defaultAgent: string;
|
||||||
|
contextTempDir: string;
|
||||||
|
maxContextChars: number;
|
||||||
|
repoPaths: RepoPath[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskSession {
|
||||||
|
taskId: string;
|
||||||
|
tmuxSessionName: string;
|
||||||
|
startedAt: string;
|
||||||
|
noteFile?: TFile;
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,19 @@ export interface TranscriptNote {
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptResponse {
|
export interface BaseResponse {
|
||||||
summary: string;
|
summary: string;
|
||||||
notes: TranscriptNote[];
|
notes: TranscriptNote[];
|
||||||
tasks: string[];
|
tasks: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TranscriptResponse extends BaseResponse {}
|
||||||
|
|
||||||
|
export interface DistillResponse extends BaseResponse {
|
||||||
|
sourceNotes: string[]; // List of source note names that were distilled
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishResponse {
|
||||||
|
content: string; // The full blog post markdown
|
||||||
|
sourceNotes: string[]; // List of source note names that were used
|
||||||
|
}
|
||||||
389
src/ui/context-gathering-modal.ts
Normal file
389
src/ui/context-gathering-modal.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
import { App, Modal, Setting } from 'obsidian';
|
||||||
|
import { ContextGatheringConfig } from '../types/context';
|
||||||
|
import { OpenAugiSettings } from '../types/settings';
|
||||||
|
import { ContextGatheringService } from '../services/context-gathering-service';
|
||||||
|
|
||||||
|
export class ContextGatheringModal extends Modal {
|
||||||
|
private config: ContextGatheringConfig;
|
||||||
|
private onSubmit: (config: ContextGatheringConfig) => void;
|
||||||
|
private settings: OpenAugiSettings;
|
||||||
|
private contextService: ContextGatheringService;
|
||||||
|
private estimateEl: HTMLElement;
|
||||||
|
private modeSpecificContainer: HTMLElement;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
settings: OpenAugiSettings,
|
||||||
|
contextService: ContextGatheringService,
|
||||||
|
onSubmit: (config: ContextGatheringConfig) => void,
|
||||||
|
defaultSourceMode: 'linked-notes' | 'recent-activity' = 'linked-notes',
|
||||||
|
defaultDepth: number = 1
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.settings = settings;
|
||||||
|
this.contextService = contextService;
|
||||||
|
this.onSubmit = onSubmit;
|
||||||
|
|
||||||
|
// Initialize with defaults
|
||||||
|
this.config = {
|
||||||
|
sourceMode: defaultSourceMode,
|
||||||
|
rootNote: app.workspace.getActiveFile() || undefined,
|
||||||
|
linkDepth: defaultDepth,
|
||||||
|
maxCharacters: settings.contextGatheringDefaults.maxCharacters,
|
||||||
|
excludeFolders: settings.recentActivityDefaults.excludeFolders,
|
||||||
|
filterRecentSectionsOnly: settings.contextGatheringDefaults.filterRecentSectionsOnly,
|
||||||
|
dateHeaderFormat: settings.recentActivityDefaults.dateHeaderFormat,
|
||||||
|
journalSectionDays: settings.recentActivityDefaults.daysBack,
|
||||||
|
includeBacklinks: settings.contextGatheringDefaults.includeBacklinks,
|
||||||
|
backlinkContextLines: settings.contextGatheringDefaults.backlinkContextLines
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.addClass('openaugi-context-modal');
|
||||||
|
|
||||||
|
contentEl.createEl('h2', { text: 'Gather Context' });
|
||||||
|
|
||||||
|
contentEl.createEl('p', {
|
||||||
|
text: 'Configure how to discover and gather notes for processing.',
|
||||||
|
cls: 'setting-item-description'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Source mode selection
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Source')
|
||||||
|
.setDesc('How to discover notes')
|
||||||
|
.addDropdown(dropdown => {
|
||||||
|
dropdown
|
||||||
|
.addOption('linked-notes', 'Linked notes from current note')
|
||||||
|
.addOption('recent-activity', 'Recently modified notes')
|
||||||
|
.setValue(this.config.sourceMode)
|
||||||
|
.onChange(async (value: 'linked-notes' | 'recent-activity') => {
|
||||||
|
this.config.sourceMode = value;
|
||||||
|
await this.renderModeSpecificSettings();
|
||||||
|
await this.updateEstimate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Container for mode-specific settings
|
||||||
|
this.modeSpecificContainer = contentEl.createDiv({ cls: 'mode-specific-settings' });
|
||||||
|
await this.renderModeSpecificSettings();
|
||||||
|
|
||||||
|
// Folder filtering
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Exclude folders')
|
||||||
|
.setDesc('Comma-separated list of folders to exclude')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('Templates, Archive, OpenAugi')
|
||||||
|
.setValue(this.config.excludeFolders.join(', '))
|
||||||
|
.onChange(async value => {
|
||||||
|
this.config.excludeFolders = value
|
||||||
|
.split(',')
|
||||||
|
.map(f => f.trim())
|
||||||
|
.filter(f => f.length > 0);
|
||||||
|
await this.updateEstimate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recent sections filtering
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Recent sections only')
|
||||||
|
.setDesc('For journal-style notes with date headers, only include recent sections')
|
||||||
|
.addToggle(toggle => {
|
||||||
|
toggle
|
||||||
|
.setValue(this.config.filterRecentSectionsOnly)
|
||||||
|
.onChange(async value => {
|
||||||
|
this.config.filterRecentSectionsOnly = value;
|
||||||
|
if (value) {
|
||||||
|
await this.showJournalDaysInput();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Journal days input (conditional)
|
||||||
|
if (this.config.filterRecentSectionsOnly) {
|
||||||
|
await this.showJournalDaysInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate display
|
||||||
|
this.estimateEl = contentEl.createDiv({ cls: 'context-estimate' });
|
||||||
|
this.estimateEl.style.padding = '10px';
|
||||||
|
this.estimateEl.style.marginTop = '10px';
|
||||||
|
this.estimateEl.style.backgroundColor = 'var(--background-secondary)';
|
||||||
|
this.estimateEl.style.borderRadius = '5px';
|
||||||
|
await this.updateEstimate();
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Cancel')
|
||||||
|
.onClick(() => this.close())
|
||||||
|
)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Discover Notes')
|
||||||
|
.setCta()
|
||||||
|
.onClick(() => {
|
||||||
|
this.onSubmit(this.config);
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderModeSpecificSettings() {
|
||||||
|
this.modeSpecificContainer.empty();
|
||||||
|
|
||||||
|
if (this.config.sourceMode === 'linked-notes') {
|
||||||
|
this.renderLinkedNotesSettings();
|
||||||
|
} else {
|
||||||
|
this.renderRecentActivitySettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLinkedNotesSettings() {
|
||||||
|
// Root note display
|
||||||
|
if (this.config.rootNote) {
|
||||||
|
new Setting(this.modeSpecificContainer)
|
||||||
|
.setName('Root note')
|
||||||
|
.setDesc('Starting note for link traversal')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setValue(this.config.rootNote!.basename)
|
||||||
|
.setDisabled(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const noNoteEl = this.modeSpecificContainer.createDiv();
|
||||||
|
noNoteEl.style.padding = '10px';
|
||||||
|
noNoteEl.style.color = 'var(--text-error)';
|
||||||
|
noNoteEl.setText('⚠️ No active note. Please open a note first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link depth slider
|
||||||
|
new Setting(this.modeSpecificContainer)
|
||||||
|
.setName('Link depth')
|
||||||
|
.setDesc('How many levels of links to traverse (1-3)')
|
||||||
|
.addSlider(slider => {
|
||||||
|
slider
|
||||||
|
.setLimits(1, 3, 1)
|
||||||
|
.setValue(this.config.linkDepth)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async value => {
|
||||||
|
this.config.linkDepth = value;
|
||||||
|
await this.updateEstimate();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.addExtraButton(button => {
|
||||||
|
button.setIcon('info');
|
||||||
|
button.setTooltip('Depth 1: direct links only\nDepth 2: links of links\nDepth 3: three levels deep');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Character limit
|
||||||
|
new Setting(this.modeSpecificContainer)
|
||||||
|
.setName('Max characters')
|
||||||
|
.setDesc('Stop gathering when this many characters collected')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('100000')
|
||||||
|
.setValue(String(this.config.maxCharacters))
|
||||||
|
.onChange(async value => {
|
||||||
|
const num = parseInt(value);
|
||||||
|
if (!isNaN(num) && num > 0) {
|
||||||
|
this.config.maxCharacters = num;
|
||||||
|
await this.updateEstimate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backlink settings container
|
||||||
|
const backlinkContainer = this.modeSpecificContainer.createDiv({ cls: 'backlink-settings' });
|
||||||
|
|
||||||
|
// Include backlinks toggle
|
||||||
|
new Setting(backlinkContainer)
|
||||||
|
.setName('Include backlinks')
|
||||||
|
.setDesc('Also discover notes that link to discovered notes')
|
||||||
|
.addToggle(toggle => {
|
||||||
|
toggle
|
||||||
|
.setValue(this.config.includeBacklinks ?? false)
|
||||||
|
.onChange(async value => {
|
||||||
|
this.config.includeBacklinks = value;
|
||||||
|
this.renderBacklinkContextSetting(backlinkContainer);
|
||||||
|
await this.updateEstimate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render context lines setting if backlinks enabled
|
||||||
|
this.renderBacklinkContextSetting(backlinkContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBacklinkContextSetting(container: HTMLElement) {
|
||||||
|
// Remove existing setting if present
|
||||||
|
const existing = container.querySelector('.backlink-context-setting');
|
||||||
|
if (existing) {
|
||||||
|
existing.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.config.includeBacklinks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextLines = this.config.backlinkContextLines ?? 2;
|
||||||
|
const contextDesc = contextLines === 0
|
||||||
|
? 'Header section'
|
||||||
|
: `${contextLines} line${contextLines === 1 ? '' : 's'} before/after`;
|
||||||
|
|
||||||
|
new Setting(container)
|
||||||
|
.setName('Backlink context')
|
||||||
|
.setDesc(`Extract: ${contextDesc}`)
|
||||||
|
.setClass('backlink-context-setting')
|
||||||
|
.addSlider(slider => {
|
||||||
|
slider
|
||||||
|
.setLimits(0, 5, 1)
|
||||||
|
.setValue(contextLines)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(value => {
|
||||||
|
this.config.backlinkContextLines = value;
|
||||||
|
// Update description
|
||||||
|
const desc = value === 0
|
||||||
|
? 'Header section'
|
||||||
|
: `${value} line${value === 1 ? '' : 's'} before/after`;
|
||||||
|
const settingEl = container.querySelector('.backlink-context-setting .setting-item-description');
|
||||||
|
if (settingEl) {
|
||||||
|
settingEl.textContent = `Extract: ${desc}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.addExtraButton(button => {
|
||||||
|
button.setIcon('info');
|
||||||
|
button.setTooltip('0 = header section (text under current markdown header)\n1-5 = fixed number of lines before and after the link');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRecentActivitySettings() {
|
||||||
|
// Initialize time window if not set
|
||||||
|
if (!this.config.timeWindow) {
|
||||||
|
this.config.timeWindow = {
|
||||||
|
mode: 'days-back',
|
||||||
|
daysBack: this.settings.recentActivityDefaults.daysBack
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time window mode
|
||||||
|
new Setting(this.modeSpecificContainer)
|
||||||
|
.setName('Time window')
|
||||||
|
.setDesc('How to select recent notes')
|
||||||
|
.addDropdown(dropdown => {
|
||||||
|
dropdown
|
||||||
|
.addOption('days-back', 'Last N days')
|
||||||
|
.addOption('date-range', 'Specific date range')
|
||||||
|
.setValue(this.config.timeWindow!.mode)
|
||||||
|
.onChange(async (value: 'days-back' | 'date-range') => {
|
||||||
|
this.config.timeWindow!.mode = value;
|
||||||
|
await this.renderModeSpecificSettings();
|
||||||
|
await this.updateEstimate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Days back or date range inputs
|
||||||
|
if (this.config.timeWindow.mode === 'days-back') {
|
||||||
|
new Setting(this.modeSpecificContainer)
|
||||||
|
.setName('Days back')
|
||||||
|
.setDesc('Number of days to look back')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('7')
|
||||||
|
.setValue(String(this.config.timeWindow!.daysBack || 7))
|
||||||
|
.onChange(async value => {
|
||||||
|
const days = parseInt(value);
|
||||||
|
if (!isNaN(days) && days > 0) {
|
||||||
|
this.config.timeWindow!.daysBack = days;
|
||||||
|
await this.updateEstimate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Date range inputs
|
||||||
|
new Setting(this.modeSpecificContainer)
|
||||||
|
.setName('From date')
|
||||||
|
.setDesc('Start date (YYYY-MM-DD)')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('YYYY-MM-DD')
|
||||||
|
.setValue(this.config.timeWindow!.fromDate || '')
|
||||||
|
.onChange(async value => {
|
||||||
|
this.config.timeWindow!.fromDate = value;
|
||||||
|
await this.updateEstimate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(this.modeSpecificContainer)
|
||||||
|
.setName('To date')
|
||||||
|
.setDesc('End date (YYYY-MM-DD)')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('YYYY-MM-DD')
|
||||||
|
.setValue(this.config.timeWindow!.toDate || '')
|
||||||
|
.onChange(async value => {
|
||||||
|
this.config.timeWindow!.toDate = value;
|
||||||
|
await this.updateEstimate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showJournalDaysInput() {
|
||||||
|
// Check if input already exists
|
||||||
|
const existingInput = this.contentEl.querySelector('.journal-days-setting');
|
||||||
|
if (existingInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
new Setting(this.contentEl)
|
||||||
|
.setName('Journal sections days back')
|
||||||
|
.setDesc('For journal filtering, how many days back to include sections')
|
||||||
|
.setClass('journal-days-setting')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('7')
|
||||||
|
.setValue(String(this.config.journalSectionDays || 7))
|
||||||
|
.onChange(value => {
|
||||||
|
const days = parseInt(value);
|
||||||
|
if (!isNaN(days) && days > 0) {
|
||||||
|
this.config.journalSectionDays = days;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateEstimate() {
|
||||||
|
if (!this.estimateEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.config.rootNote && this.config.sourceMode === 'linked-notes') {
|
||||||
|
this.estimateEl.setText('⚠️ No active note selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const estimate = await this.contextService.estimateSize(
|
||||||
|
this.config.rootNote,
|
||||||
|
this.config.sourceMode,
|
||||||
|
this.config.linkDepth
|
||||||
|
);
|
||||||
|
|
||||||
|
this.estimateEl.setText(
|
||||||
|
`📊 Estimated: ${estimate.noteCount} notes, ~${estimate.estimatedChars.toLocaleString()} characters`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to estimate size:', error);
|
||||||
|
this.estimateEl.setText('⚠️ Unable to estimate size');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/ui/context-preview-modal.ts
Normal file
164
src/ui/context-preview-modal.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { App, Modal, Notice, Setting } from 'obsidian';
|
||||||
|
import { GatheredContext } from '../types/context';
|
||||||
|
|
||||||
|
export class ContextPreviewModal extends Modal {
|
||||||
|
private context: GatheredContext;
|
||||||
|
private onSaveRaw: () => void;
|
||||||
|
private onProcess: () => void;
|
||||||
|
private processButtonLabel: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
context: GatheredContext,
|
||||||
|
onSaveRaw: () => void,
|
||||||
|
onProcess: () => void,
|
||||||
|
processButtonLabel: string = 'Process with AI'
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.context = context;
|
||||||
|
this.onSaveRaw = onSaveRaw;
|
||||||
|
this.onProcess = onProcess;
|
||||||
|
this.processButtonLabel = processButtonLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.addClass('openaugi-preview-modal');
|
||||||
|
|
||||||
|
contentEl.createEl('h2', { text: 'Context Preview' });
|
||||||
|
|
||||||
|
contentEl.createEl('p', {
|
||||||
|
text: 'Review the gathered context before processing or saving.',
|
||||||
|
cls: 'setting-item-description'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const statsEl = contentEl.createDiv({ cls: 'context-stats' });
|
||||||
|
statsEl.style.padding = '15px';
|
||||||
|
statsEl.style.marginBottom = '15px';
|
||||||
|
statsEl.style.backgroundColor = 'var(--background-secondary)';
|
||||||
|
statsEl.style.borderRadius = '5px';
|
||||||
|
|
||||||
|
const statsTitle = statsEl.createEl('h3', { text: '📊 Summary' });
|
||||||
|
statsTitle.style.marginTop = '0';
|
||||||
|
statsTitle.style.marginBottom = '10px';
|
||||||
|
|
||||||
|
statsEl.createEl('p', {
|
||||||
|
text: `Notes: ${this.context.totalNotes} notes`
|
||||||
|
});
|
||||||
|
statsEl.createEl('p', {
|
||||||
|
text: `Characters: ${this.context.totalCharacters.toLocaleString()}`
|
||||||
|
});
|
||||||
|
statsEl.createEl('p', {
|
||||||
|
text: `Estimated tokens: ~${Math.ceil(this.context.totalCharacters / 4).toLocaleString()}`
|
||||||
|
});
|
||||||
|
statsEl.createEl('p', {
|
||||||
|
text: `Source: ${this.context.config.sourceMode === 'linked-notes' ? 'Linked notes' : 'Recent activity'}`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.context.config.sourceMode === 'linked-notes') {
|
||||||
|
statsEl.createEl('p', {
|
||||||
|
text: `Link depth: ${this.context.config.linkDepth}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// List of included notes
|
||||||
|
const notesListEl = contentEl.createDiv({ cls: 'notes-list' });
|
||||||
|
notesListEl.style.marginBottom = '15px';
|
||||||
|
|
||||||
|
notesListEl.createEl('h3', { text: '📝 Included Notes' });
|
||||||
|
|
||||||
|
const listEl = notesListEl.createEl('ul');
|
||||||
|
listEl.style.maxHeight = '150px';
|
||||||
|
listEl.style.overflowY = 'auto';
|
||||||
|
listEl.style.padding = '10px';
|
||||||
|
listEl.style.margin = '0';
|
||||||
|
listEl.style.backgroundColor = 'var(--background-primary-alt)';
|
||||||
|
listEl.style.borderRadius = '5px';
|
||||||
|
listEl.style.listStyle = 'none';
|
||||||
|
|
||||||
|
this.context.notes.forEach(note => {
|
||||||
|
const itemEl = listEl.createEl('li');
|
||||||
|
itemEl.style.padding = '5px';
|
||||||
|
itemEl.style.borderBottom = '1px solid var(--background-modifier-border)';
|
||||||
|
|
||||||
|
const titleEl = itemEl.createEl('span');
|
||||||
|
titleEl.setText(note.file.basename);
|
||||||
|
titleEl.style.fontWeight = '500';
|
||||||
|
|
||||||
|
if (note.depth > 0) {
|
||||||
|
const depthBadge = itemEl.createEl('span');
|
||||||
|
depthBadge.setText(` (L${note.depth})`);
|
||||||
|
depthBadge.style.fontSize = '0.85em';
|
||||||
|
depthBadge.style.color = 'var(--text-muted)';
|
||||||
|
depthBadge.style.marginLeft = '5px';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeEl = itemEl.createEl('span');
|
||||||
|
sizeEl.setText(` · ${(note.estimatedChars / 1000).toFixed(1)}k chars`);
|
||||||
|
sizeEl.style.fontSize = '0.85em';
|
||||||
|
sizeEl.style.color = 'var(--text-muted)';
|
||||||
|
sizeEl.style.marginLeft = '5px';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Content preview (first 1000 chars)
|
||||||
|
const previewEl = contentEl.createDiv({ cls: 'content-preview' });
|
||||||
|
previewEl.style.marginBottom = '20px';
|
||||||
|
|
||||||
|
previewEl.createEl('h3', { text: '👁️ Content Preview' });
|
||||||
|
|
||||||
|
const preText = previewEl.createEl('pre');
|
||||||
|
preText.style.maxHeight = '200px';
|
||||||
|
preText.style.overflowY = 'auto';
|
||||||
|
preText.style.padding = '10px';
|
||||||
|
preText.style.backgroundColor = 'var(--background-primary-alt)';
|
||||||
|
preText.style.border = '1px solid var(--background-modifier-border)';
|
||||||
|
preText.style.borderRadius = '5px';
|
||||||
|
preText.style.fontSize = '0.9em';
|
||||||
|
preText.style.whiteSpace = 'pre-wrap';
|
||||||
|
preText.style.wordWrap = 'break-word';
|
||||||
|
|
||||||
|
const preview = this.context.aggregatedContent.substring(0, 1000);
|
||||||
|
const hasMore = this.context.aggregatedContent.length > 1000;
|
||||||
|
preText.setText(preview + (hasMore ? '\n\n...(truncated)' : ''));
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Back')
|
||||||
|
.onClick(() => this.close())
|
||||||
|
)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Copy to Clipboard')
|
||||||
|
.setTooltip('Copy the gathered context to clipboard')
|
||||||
|
.onClick(async () => {
|
||||||
|
await navigator.clipboard.writeText(this.context.aggregatedContent);
|
||||||
|
new Notice('Context copied to clipboard!');
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Save Raw Context')
|
||||||
|
.setTooltip('Save the gathered context as a note without AI processing')
|
||||||
|
.onClick(() => {
|
||||||
|
this.onSaveRaw();
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText(this.processButtonLabel)
|
||||||
|
.setCta()
|
||||||
|
.setTooltip('Continue to process this context with AI')
|
||||||
|
.onClick(() => {
|
||||||
|
this.onProcess();
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/ui/context-selection-modal.ts
Normal file
245
src/ui/context-selection-modal.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { App, Modal, Setting } from 'obsidian';
|
||||||
|
import { DiscoveredNote } from '../types/context';
|
||||||
|
|
||||||
|
export class ContextSelectionModal extends Modal {
|
||||||
|
private discoveredNotes: DiscoveredNote[];
|
||||||
|
private onSubmit: (selectedNotes: DiscoveredNote[]) => void;
|
||||||
|
private checkboxStates: Map<string, boolean>;
|
||||||
|
private summaryEl: HTMLElement;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
discoveredNotes: DiscoveredNote[],
|
||||||
|
onSubmit: (selectedNotes: DiscoveredNote[]) => void
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.discoveredNotes = discoveredNotes;
|
||||||
|
this.onSubmit = onSubmit;
|
||||||
|
this.checkboxStates = new Map();
|
||||||
|
|
||||||
|
// Initialize checkbox states from included property
|
||||||
|
discoveredNotes.forEach(note => {
|
||||||
|
this.checkboxStates.set(note.file.path, note.included);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.addClass('openaugi-selection-modal');
|
||||||
|
|
||||||
|
contentEl.createEl('h2', { text: 'Select Notes to Include' });
|
||||||
|
|
||||||
|
contentEl.createEl('p', {
|
||||||
|
text: 'Review and select which notes to include in the gathered context.',
|
||||||
|
cls: 'setting-item-description'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
this.summaryEl = contentEl.createDiv({ cls: 'selection-summary' });
|
||||||
|
this.summaryEl.style.padding = '10px';
|
||||||
|
this.summaryEl.style.marginBottom = '15px';
|
||||||
|
this.summaryEl.style.backgroundColor = 'var(--background-secondary)';
|
||||||
|
this.summaryEl.style.borderRadius = '5px';
|
||||||
|
this.updateSummary();
|
||||||
|
|
||||||
|
// Select all / Deselect all buttons
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Quick actions')
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Select All')
|
||||||
|
.onClick(() => {
|
||||||
|
this.discoveredNotes.forEach(note => {
|
||||||
|
this.checkboxStates.set(note.file.path, true);
|
||||||
|
});
|
||||||
|
this.renderNoteList();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Deselect All')
|
||||||
|
.onClick(() => {
|
||||||
|
this.discoveredNotes.forEach(note => {
|
||||||
|
this.checkboxStates.set(note.file.path, false);
|
||||||
|
});
|
||||||
|
this.renderNoteList();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scrollable list of checkboxes
|
||||||
|
const listContainer = contentEl.createDiv({ cls: 'note-list-container' });
|
||||||
|
listContainer.style.maxHeight = '400px';
|
||||||
|
listContainer.style.overflowY = 'auto';
|
||||||
|
listContainer.style.border = '1px solid var(--background-modifier-border)';
|
||||||
|
listContainer.style.padding = '10px';
|
||||||
|
listContainer.style.marginBottom = '20px';
|
||||||
|
listContainer.style.borderRadius = '5px';
|
||||||
|
|
||||||
|
this.renderNoteListInContainer(listContainer);
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
const totalSelected = Array.from(this.checkboxStates.values()).filter(v => v).length;
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Back')
|
||||||
|
.onClick(() => this.close())
|
||||||
|
)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Continue')
|
||||||
|
.setCta()
|
||||||
|
.setDisabled(totalSelected === 0)
|
||||||
|
.onClick(() => {
|
||||||
|
// Update included property based on checkboxes
|
||||||
|
this.discoveredNotes.forEach(note => {
|
||||||
|
note.included = this.checkboxStates.get(note.file.path) || false;
|
||||||
|
});
|
||||||
|
this.onSubmit(this.discoveredNotes.filter(n => n.included));
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderNoteList() {
|
||||||
|
// Find and re-render the list container
|
||||||
|
const listContainer = this.contentEl.querySelector('.note-list-container');
|
||||||
|
if (listContainer) {
|
||||||
|
listContainer.empty();
|
||||||
|
this.renderNoteListInContainer(listContainer as HTMLElement);
|
||||||
|
}
|
||||||
|
this.updateSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderNoteListInContainer(listContainer: HTMLElement) {
|
||||||
|
// Group by depth for linked notes
|
||||||
|
const byDepth = new Map<number, DiscoveredNote[]>();
|
||||||
|
this.discoveredNotes.forEach(note => {
|
||||||
|
if (!byDepth.has(note.depth)) {
|
||||||
|
byDepth.set(note.depth, []);
|
||||||
|
}
|
||||||
|
byDepth.get(note.depth)!.push(note);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render notes grouped by depth
|
||||||
|
const sortedDepths = Array.from(byDepth.keys()).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
sortedDepths.forEach(depth => {
|
||||||
|
const notes = byDepth.get(depth)!;
|
||||||
|
|
||||||
|
// Depth header
|
||||||
|
if (sortedDepths.length > 1 && depth > 0) {
|
||||||
|
const depthHeader = listContainer.createEl('div', {
|
||||||
|
cls: 'depth-header',
|
||||||
|
text: `📁 Level ${depth}`
|
||||||
|
});
|
||||||
|
depthHeader.style.fontWeight = 'bold';
|
||||||
|
depthHeader.style.marginTop = depth > 0 ? '15px' : '0';
|
||||||
|
depthHeader.style.marginBottom = '5px';
|
||||||
|
depthHeader.style.color = 'var(--text-muted)';
|
||||||
|
} else if (depth === 0 && sortedDepths.length > 1) {
|
||||||
|
const depthHeader = listContainer.createEl('div', {
|
||||||
|
cls: 'depth-header',
|
||||||
|
text: '📄 Root Note'
|
||||||
|
});
|
||||||
|
depthHeader.style.fontWeight = 'bold';
|
||||||
|
depthHeader.style.marginBottom = '5px';
|
||||||
|
depthHeader.style.color = 'var(--text-muted)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes at this depth
|
||||||
|
notes.forEach(note => {
|
||||||
|
const noteEl = listContainer.createDiv({ cls: 'note-item' });
|
||||||
|
noteEl.style.display = 'flex';
|
||||||
|
noteEl.style.alignItems = 'center';
|
||||||
|
noteEl.style.padding = '8px';
|
||||||
|
noteEl.style.marginLeft = `${depth * 20}px`; // Indent by depth
|
||||||
|
noteEl.style.borderRadius = '3px';
|
||||||
|
noteEl.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
// Hover effect
|
||||||
|
noteEl.addEventListener('mouseenter', () => {
|
||||||
|
noteEl.style.backgroundColor = 'var(--background-secondary-alt)';
|
||||||
|
});
|
||||||
|
noteEl.addEventListener('mouseleave', () => {
|
||||||
|
noteEl.style.backgroundColor = 'transparent';
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkbox = noteEl.createEl('input', { type: 'checkbox' });
|
||||||
|
checkbox.checked = this.checkboxStates.get(note.file.path) || false;
|
||||||
|
checkbox.style.marginRight = '10px';
|
||||||
|
checkbox.style.cursor = 'pointer';
|
||||||
|
checkbox.addEventListener('change', () => {
|
||||||
|
this.checkboxStates.set(note.file.path, checkbox.checked);
|
||||||
|
this.updateSummary();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make the whole row clickable
|
||||||
|
noteEl.addEventListener('click', (e) => {
|
||||||
|
if (e.target !== checkbox) {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
this.checkboxStates.set(note.file.path, checkbox.checked);
|
||||||
|
this.updateSummary();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentDiv = noteEl.createDiv();
|
||||||
|
contentDiv.style.flex = '1';
|
||||||
|
contentDiv.style.display = 'flex';
|
||||||
|
contentDiv.style.flexDirection = 'column';
|
||||||
|
contentDiv.style.gap = '2px';
|
||||||
|
|
||||||
|
const titleRow = contentDiv.createDiv();
|
||||||
|
titleRow.style.display = 'flex';
|
||||||
|
titleRow.style.alignItems = 'center';
|
||||||
|
titleRow.style.gap = '8px';
|
||||||
|
|
||||||
|
const titleEl = titleRow.createEl('span');
|
||||||
|
titleEl.setText(note.file.basename);
|
||||||
|
titleEl.style.fontWeight = '500';
|
||||||
|
|
||||||
|
// Backlink indicator badge
|
||||||
|
if (note.isBacklink) {
|
||||||
|
const backlinkBadge = titleRow.createEl('span');
|
||||||
|
backlinkBadge.setText('← backlink');
|
||||||
|
backlinkBadge.style.fontSize = '0.75em';
|
||||||
|
backlinkBadge.style.padding = '2px 6px';
|
||||||
|
backlinkBadge.style.borderRadius = '10px';
|
||||||
|
backlinkBadge.style.backgroundColor = 'var(--interactive-accent)';
|
||||||
|
backlinkBadge.style.color = 'var(--text-on-accent)';
|
||||||
|
backlinkBadge.style.fontWeight = 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaEl = contentDiv.createEl('span');
|
||||||
|
metaEl.style.fontSize = '0.85em';
|
||||||
|
metaEl.style.color = 'var(--text-muted)';
|
||||||
|
const sizeKb = (note.estimatedChars / 1000).toFixed(1);
|
||||||
|
const contentType = note.isBacklink ? 'snippet' : 'full note';
|
||||||
|
metaEl.setText(`${sizeKb}k chars (${contentType}) · ${note.discoveredVia}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show message if no notes
|
||||||
|
if (this.discoveredNotes.length === 0) {
|
||||||
|
const emptyEl = listContainer.createEl('div');
|
||||||
|
emptyEl.style.textAlign = 'center';
|
||||||
|
emptyEl.style.padding = '20px';
|
||||||
|
emptyEl.style.color = 'var(--text-muted)';
|
||||||
|
emptyEl.setText('No notes discovered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSummary() {
|
||||||
|
const totalSelected = Array.from(this.checkboxStates.values()).filter(v => v).length;
|
||||||
|
const totalChars = this.discoveredNotes
|
||||||
|
.filter(n => this.checkboxStates.get(n.file.path))
|
||||||
|
.reduce((sum, n) => sum + n.estimatedChars, 0);
|
||||||
|
|
||||||
|
this.summaryEl.setText(
|
||||||
|
`✓ Selected: ${totalSelected} of ${this.discoveredNotes.length} notes (${totalChars.toLocaleString()} characters, ~${Math.ceil(totalChars / 4).toLocaleString()} tokens)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/ui/prompt-selection-modal.ts
Normal file
163
src/ui/prompt-selection-modal.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { App, Modal, Setting, TFile } from 'obsidian';
|
||||||
|
import { ProcessingType } from '../types/context';
|
||||||
|
|
||||||
|
export interface PromptSelectionConfig {
|
||||||
|
selectedPrompt?: TFile;
|
||||||
|
useCustomPrompt: boolean;
|
||||||
|
processingType?: ProcessingType; // 'distill' or 'publish'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PromptSelectionModal extends Modal {
|
||||||
|
private config: PromptSelectionConfig;
|
||||||
|
private onSubmit: (config: PromptSelectionConfig) => void;
|
||||||
|
private promptsFolder: string;
|
||||||
|
private availablePrompts: TFile[] = [];
|
||||||
|
private showProcessingType: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
promptsFolder: string,
|
||||||
|
onSubmit: (config: PromptSelectionConfig) => void,
|
||||||
|
showProcessingType: boolean = false,
|
||||||
|
defaultProcessingType: ProcessingType = 'distill'
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.promptsFolder = promptsFolder;
|
||||||
|
this.config = {
|
||||||
|
useCustomPrompt: false,
|
||||||
|
processingType: defaultProcessingType
|
||||||
|
};
|
||||||
|
this.onSubmit = onSubmit;
|
||||||
|
this.showProcessingType = showProcessingType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
|
||||||
|
contentEl.createEl('h2', { text: 'Process Context' });
|
||||||
|
|
||||||
|
contentEl.createEl('p', {
|
||||||
|
text: 'Configure how to process the gathered context with AI.',
|
||||||
|
cls: 'setting-item-description'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Processing type selection (if enabled)
|
||||||
|
if (this.showProcessingType) {
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Output format')
|
||||||
|
.setDesc('How to process the context')
|
||||||
|
.addDropdown(dropdown => {
|
||||||
|
dropdown
|
||||||
|
.addOption('distill', 'Distill to atomic notes')
|
||||||
|
.addOption('publish', 'Publish as single post')
|
||||||
|
.setValue(this.config.processingType || 'distill')
|
||||||
|
.onChange((value: ProcessingType) => {
|
||||||
|
this.config.processingType = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load available prompts
|
||||||
|
await this.loadAvailablePrompts();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
544
src/ui/recent-activity-modal.ts
Normal file
544
src/ui/recent-activity-modal.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/ui/session-list-modal.ts
Normal file
82
src/ui/session-list-modal.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { App, Modal, Setting } from 'obsidian';
|
||||||
|
import { TaskSession } from '../types/task-dispatch';
|
||||||
|
|
||||||
|
export class SessionListModal extends Modal {
|
||||||
|
private sessions: TaskSession[];
|
||||||
|
private onAttach: (session: TaskSession) => void;
|
||||||
|
private onKill: (session: TaskSession) => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
sessions: TaskSession[],
|
||||||
|
onAttach: (session: TaskSession) => void,
|
||||||
|
onKill: (session: TaskSession) => void
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.sessions = sessions;
|
||||||
|
this.onAttach = onAttach;
|
||||||
|
this.onKill = onKill;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.createEl('h2', { text: 'Active Task Sessions' });
|
||||||
|
|
||||||
|
if (this.sessions.length === 0) {
|
||||||
|
contentEl.createEl('p', { text: 'No active task sessions found.' });
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Close')
|
||||||
|
.onClick(() => this.close())
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of this.sessions) {
|
||||||
|
const elapsed = this.formatElapsed(session.startedAt);
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName(session.taskId)
|
||||||
|
.setDesc(`Started ${elapsed}`)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Attach')
|
||||||
|
.setCta()
|
||||||
|
.onClick(() => {
|
||||||
|
this.onAttach(session);
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Kill')
|
||||||
|
.setWarning()
|
||||||
|
.onClick(() => {
|
||||||
|
this.onKill(session);
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Close')
|
||||||
|
.onClick(() => this.close())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatElapsed(isoTimestamp: string): string {
|
||||||
|
if (isoTimestamp === 'unknown') return 'unknown';
|
||||||
|
const ms = Date.now() - new Date(isoTimestamp).getTime();
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
|
import { App, PluginSettingTab, Setting, Notice, DropdownComponent } from 'obsidian';
|
||||||
import type OpenAugiPlugin from '../types/plugin';
|
import type OpenAugiPlugin from '../types/plugin';
|
||||||
|
import { OpenAIService } from '../services/openai-service';
|
||||||
|
import { TerminalApp } from '../types/task-dispatch';
|
||||||
|
// Lazy-imported: detectTmuxPath lives in task-dispatch-service which uses
|
||||||
|
// Node.js modules unavailable on mobile.
|
||||||
|
|
||||||
export class OpenAugiSettingTab extends PluginSettingTab {
|
export class OpenAugiSettingTab extends PluginSettingTab {
|
||||||
plugin: OpenAugiPlugin;
|
plugin: OpenAugiPlugin;
|
||||||
|
|
@ -14,10 +18,8 @@ export class OpenAugiSettingTab extends PluginSettingTab {
|
||||||
|
|
||||||
containerEl.empty();
|
containerEl.empty();
|
||||||
|
|
||||||
containerEl.createEl('h2', {text: 'OpenAugi Settings'});
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('OpenAI API Key')
|
.setName('OpenAI API key')
|
||||||
.setDesc('Your OpenAI API key')
|
.setDesc('Your OpenAI API key')
|
||||||
.addText(text => text
|
.addText(text => text
|
||||||
.setPlaceholder('sk-...')
|
.setPlaceholder('sk-...')
|
||||||
|
|
@ -37,32 +39,577 @@ export class OpenAugiSettingTab extends PluginSettingTab {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
let modelDropdown: DropdownComponent;
|
||||||
.setName('Summaries Folder')
|
|
||||||
.setDesc('Folder path where summary files will be saved')
|
const modelSetting = new Setting(containerEl)
|
||||||
.addText(text => text
|
.setName('OpenAI Model')
|
||||||
.setPlaceholder('OpenAugi/Summaries')
|
.setDesc('Select the OpenAI model to use for processing')
|
||||||
.setValue(this.plugin.settings.summaryFolder)
|
.addDropdown(dropdown => {
|
||||||
.onChange(async (value) => {
|
modelDropdown = dropdown;
|
||||||
this.plugin.settings.summaryFolder = value;
|
// Populate dropdown from cached models
|
||||||
|
const models = this.plugin.settings.cachedModels;
|
||||||
|
models.forEach(model => {
|
||||||
|
dropdown.addOption(model, model);
|
||||||
|
});
|
||||||
|
// Ensure current selection is valid, fallback to first available
|
||||||
|
const currentModel = this.plugin.settings.defaultModel;
|
||||||
|
if (models.includes(currentModel)) {
|
||||||
|
dropdown.setValue(currentModel);
|
||||||
|
} else if (models.length > 0) {
|
||||||
|
dropdown.setValue(models[0]);
|
||||||
|
this.plugin.settings.defaultModel = models[0];
|
||||||
|
this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
dropdown.onChange(async (value) => {
|
||||||
|
this.plugin.settings.defaultModel = value;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
// Ensure directories exist after folder path changes
|
});
|
||||||
await this.plugin.fileService.ensureDirectoriesExist();
|
})
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Refresh Models')
|
||||||
|
.setDisabled(!this.plugin.settings.apiKey)
|
||||||
|
.onClick(async () => {
|
||||||
|
if (!this.plugin.settings.apiKey) {
|
||||||
|
new Notice('Please set your API key first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.setButtonText('Loading...');
|
||||||
|
button.setDisabled(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await OpenAIService.fetchAvailableModels(this.plugin.settings.apiKey);
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
new Notice('No chat models found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cached models
|
||||||
|
this.plugin.settings.cachedModels = models;
|
||||||
|
|
||||||
|
// Ensure current selection is still valid
|
||||||
|
if (!models.includes(this.plugin.settings.defaultModel)) {
|
||||||
|
this.plugin.settings.defaultModel = models[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
|
||||||
|
// Rebuild dropdown options
|
||||||
|
modelDropdown.selectEl.empty();
|
||||||
|
models.forEach(model => {
|
||||||
|
modelDropdown.addOption(model, model);
|
||||||
|
});
|
||||||
|
modelDropdown.setValue(this.plugin.settings.defaultModel);
|
||||||
|
|
||||||
|
new Notice(`Loaded ${models.length} models`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch models:', error);
|
||||||
|
new Notice(`Failed to fetch models: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
button.setButtonText('Refresh Models');
|
||||||
|
button.setDisabled(!this.plugin.settings.apiKey);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('Notes Folder')
|
.setName('Custom Model Override (Optional)')
|
||||||
.setDesc('Folder path where atomic notes will be saved')
|
.setDesc('Specify any OpenAI model name to override the selection above. Leave empty to use the selected model.')
|
||||||
.addText(text => text
|
.addText(text => text
|
||||||
.setPlaceholder('OpenAugi/Notes')
|
.setPlaceholder('e.g., gpt-4o-2024-11-20')
|
||||||
.setValue(this.plugin.settings.notesFolder)
|
.setValue(this.plugin.settings.customModelOverride)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.notesFolder = value;
|
this.plugin.settings.customModelOverride = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Summaries folder')
|
||||||
|
.setDesc('Folder path where summary files will be saved')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('OpenAugi/Summaries')
|
||||||
|
.setValue(this.plugin.settings.summaryFolder);
|
||||||
|
|
||||||
|
// Save only when input loses focus
|
||||||
|
text.inputEl.addEventListener('blur', async () => {
|
||||||
|
const value = text.getValue();
|
||||||
|
if (value !== this.plugin.settings.summaryFolder) {
|
||||||
|
this.plugin.settings.summaryFolder = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
// Ensure directories exist after folder path changes
|
||||||
|
await this.plugin.fileService.ensureDirectoriesExist();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Notes folder')
|
||||||
|
.setDesc('Folder path where atomic notes will be saved')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('OpenAugi/Notes')
|
||||||
|
.setValue(this.plugin.settings.notesFolder);
|
||||||
|
|
||||||
|
// Save only when input loses focus
|
||||||
|
text.inputEl.addEventListener('blur', async () => {
|
||||||
|
const value = text.getValue();
|
||||||
|
if (value !== this.plugin.settings.notesFolder) {
|
||||||
|
this.plugin.settings.notesFolder = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
// Ensure directories exist after folder path changes
|
||||||
|
await this.plugin.fileService.ensureDirectoriesExist();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Save only when input loses focus
|
||||||
|
text.inputEl.addEventListener('blur', async () => {
|
||||||
|
const value = text.getValue();
|
||||||
|
if (value !== this.plugin.settings.promptsFolder) {
|
||||||
|
this.plugin.settings.promptsFolder = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Published folder')
|
||||||
|
.setDesc('Folder path where published blog posts will be saved')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('OpenAugi/Published')
|
||||||
|
.setValue(this.plugin.settings.publishedFolder);
|
||||||
|
|
||||||
|
// Save only when input loses focus
|
||||||
|
text.inputEl.addEventListener('blur', async () => {
|
||||||
|
const value = text.getValue();
|
||||||
|
if (value !== this.plugin.settings.publishedFolder) {
|
||||||
|
this.plugin.settings.publishedFolder = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
// Ensure directories exist after folder path changes
|
||||||
|
await this.plugin.fileService.ensureDirectoriesExist();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if Dataview plugin is installed
|
||||||
|
// @ts-ignore - Dataview API is not typed
|
||||||
|
const dataviewPluginInstalled = this.app.plugins.plugins["dataview"] !== undefined;
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Use Dataview plugin')
|
||||||
|
.setDesc(dataviewPluginInstalled
|
||||||
|
? 'Process dataview queries in notes to find linked notes'
|
||||||
|
: 'Dataview plugin is not installed. Install it to enable this feature.')
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(this.plugin.settings.useDataviewIfAvailable)
|
||||||
|
.setDisabled(!dataviewPluginInstalled)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.useDataviewIfAvailable = value;
|
||||||
|
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();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Context Gathering Settings Header
|
||||||
|
containerEl.createEl('h3', { text: 'Context Gathering Settings' });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Default link depth')
|
||||||
|
.setDesc('Default depth for link traversal (1-3)')
|
||||||
|
.addSlider(slider => slider
|
||||||
|
.setLimits(1, 3, 1)
|
||||||
|
.setValue(this.plugin.settings.contextGatheringDefaults.linkDepth)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.contextGatheringDefaults.linkDepth = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Default max characters')
|
||||||
|
.setDesc('Default maximum characters to gather')
|
||||||
|
.addText(text => text
|
||||||
|
.setPlaceholder('100000')
|
||||||
|
.setValue(String(this.plugin.settings.contextGatheringDefaults.maxCharacters))
|
||||||
|
.onChange(async (value) => {
|
||||||
|
const num = parseInt(value);
|
||||||
|
if (!isNaN(num) && num > 0) {
|
||||||
|
this.plugin.settings.contextGatheringDefaults.maxCharacters = num;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Filter recent sections by default')
|
||||||
|
.setDesc('For journal-style notes, only include recent sections by default')
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(this.plugin.settings.contextGatheringDefaults.filterRecentSectionsOnly)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.contextGatheringDefaults.filterRecentSectionsOnly = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Include backlinks by default')
|
||||||
|
.setDesc('Also discover notes that link to discovered notes')
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(this.plugin.settings.contextGatheringDefaults.includeBacklinks)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.contextGatheringDefaults.includeBacklinks = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Backlink context lines')
|
||||||
|
.setDesc('Lines to extract around each backlink (0 = header section)')
|
||||||
|
.addSlider(slider => slider
|
||||||
|
.setLimits(0, 5, 1)
|
||||||
|
.setValue(this.plugin.settings.contextGatheringDefaults.backlinkContextLines)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.contextGatheringDefaults.backlinkContextLines = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Task Dispatch Settings Header
|
||||||
|
containerEl.createEl('h3', { text: 'Task Dispatch (deprecated)' });
|
||||||
|
containerEl.createEl('p', {
|
||||||
|
text: 'Task Dispatch launches tmux sessions directly from the plugin and is '
|
||||||
|
+ 'deprecated — it will be removed in a future release. Prefer the '
|
||||||
|
+ '"Augi" commands (Run review pass, Process dashboard, Distill selection), '
|
||||||
|
+ 'which write task files to OpenAugi/Tasks/ for the OpenAugi task watcher '
|
||||||
|
+ 'to execute. The settings below only affect the legacy Task dispatch commands.',
|
||||||
|
cls: 'setting-item-description'
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Terminal application')
|
||||||
|
.setDesc('Which terminal app to open for agent sessions')
|
||||||
|
.addDropdown(dropdown => dropdown
|
||||||
|
.addOption('iterm2', 'iTerm2')
|
||||||
|
.addOption('terminal-app', 'Terminal.app')
|
||||||
|
.setValue(this.plugin.settings.taskDispatch.terminalApp)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.taskDispatch.terminalApp = value as TerminalApp;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('tmux path')
|
||||||
|
.setDesc('Absolute path to the tmux binary. Leave empty to auto-detect.')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('Auto-detect')
|
||||||
|
.setValue(this.plugin.settings.taskDispatch.tmuxPath);
|
||||||
|
text.inputEl.addEventListener('blur', async () => {
|
||||||
|
const value = text.getValue().trim();
|
||||||
|
if (value !== this.plugin.settings.taskDispatch.tmuxPath) {
|
||||||
|
this.plugin.settings.taskDispatch.tmuxPath = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return text;
|
||||||
|
})
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Detect')
|
||||||
|
.onClick(async () => {
|
||||||
|
const { detectTmuxPath } = await import('../services/task-dispatch-service');
|
||||||
|
const found = await detectTmuxPath();
|
||||||
|
if (found) {
|
||||||
|
this.plugin.settings.taskDispatch.tmuxPath = found;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
new Notice(`tmux found: ${found}`);
|
||||||
|
this.display(); // refresh the UI to show the detected path
|
||||||
|
} else {
|
||||||
|
new Notice('tmux not found. Install with: brew install tmux');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Default working directory')
|
||||||
|
.setDesc('Directory the agent launches in. Vault-relative or absolute. Override per-task with `working_dir` in frontmatter.')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('~/projects')
|
||||||
|
.setValue(this.plugin.settings.taskDispatch.defaultWorkingDir);
|
||||||
|
text.inputEl.addEventListener('blur', async () => {
|
||||||
|
const value = text.getValue().trim();
|
||||||
|
if (value !== this.plugin.settings.taskDispatch.defaultWorkingDir) {
|
||||||
|
this.plugin.settings.taskDispatch.defaultWorkingDir = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Repo Paths ---
|
||||||
|
containerEl.createEl('h4', { text: 'Repository Paths' });
|
||||||
|
containerEl.createEl('p', {
|
||||||
|
text: 'Map short names to repo folders. Use the name in frontmatter working_dir instead of typing full paths.',
|
||||||
|
cls: 'setting-item-description'
|
||||||
|
});
|
||||||
|
const repoPathsContainer = containerEl.createDiv('repo-paths-container');
|
||||||
|
this.renderRepoPaths(repoPathsContainer);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Default agent')
|
||||||
|
.setDesc('Agent to use when task note does not specify one')
|
||||||
|
.addDropdown(dropdown => {
|
||||||
|
for (const agent of this.plugin.settings.taskDispatch.agents) {
|
||||||
|
dropdown.addOption(agent.id, agent.name);
|
||||||
|
}
|
||||||
|
dropdown.setValue(this.plugin.settings.taskDispatch.defaultAgent);
|
||||||
|
dropdown.onChange(async (value) => {
|
||||||
|
this.plugin.settings.taskDispatch.defaultAgent = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Max context characters')
|
||||||
|
.setDesc('Maximum characters to include in the context bundle sent to the agent')
|
||||||
|
.addText(text => text
|
||||||
|
.setPlaceholder('200000')
|
||||||
|
.setValue(String(this.plugin.settings.taskDispatch.maxContextChars))
|
||||||
|
.onChange(async (value) => {
|
||||||
|
const num = parseInt(value);
|
||||||
|
if (!isNaN(num) && num > 0) {
|
||||||
|
this.plugin.settings.taskDispatch.maxContextChars = num;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Context temp directory')
|
||||||
|
.setDesc('Directory for temporary context files passed to agents')
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('/tmp/openaugi')
|
||||||
|
.setValue(this.plugin.settings.taskDispatch.contextTempDir);
|
||||||
|
text.inputEl.addEventListener('blur', async () => {
|
||||||
|
const value = text.getValue();
|
||||||
|
if (value !== this.plugin.settings.taskDispatch.contextTempDir) {
|
||||||
|
this.plugin.settings.taskDispatch.contextTempDir = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advanced Settings Header
|
||||||
|
containerEl.createEl('h3', { text: 'Advanced Settings' });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Enable distill logging')
|
||||||
|
.setDesc('Log the full input context sent to AI when distilling notes. Logs are saved to OpenAugi/Logs folder.')
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(this.plugin.settings.enableDistillLogging)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.enableDistillLogging = value;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
// Ensure directories exist after folder path changes
|
|
||||||
await this.plugin.fileService.ensureDirectoriesExist();
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderRepoPaths(container: HTMLElement): void {
|
||||||
|
container.empty();
|
||||||
|
const repoPaths = this.plugin.settings.taskDispatch.repoPaths;
|
||||||
|
|
||||||
|
for (let i = 0; i < repoPaths.length; i++) {
|
||||||
|
const rp = repoPaths[i];
|
||||||
|
|
||||||
|
new Setting(container)
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('Name (e.g. my-repo)')
|
||||||
|
.setValue(rp.name);
|
||||||
|
text.inputEl.style.width = '120px';
|
||||||
|
text.inputEl.addEventListener('blur', async () => {
|
||||||
|
const value = text.getValue().trim();
|
||||||
|
if (value !== this.plugin.settings.taskDispatch.repoPaths[i].name) {
|
||||||
|
this.plugin.settings.taskDispatch.repoPaths[i].name = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return text;
|
||||||
|
})
|
||||||
|
.addText(text => {
|
||||||
|
text
|
||||||
|
.setPlaceholder('/absolute/path/to/repo')
|
||||||
|
.setValue(rp.path);
|
||||||
|
text.inputEl.style.width = '280px';
|
||||||
|
text.inputEl.addEventListener('blur', async () => {
|
||||||
|
const value = text.getValue().trim();
|
||||||
|
if (value !== this.plugin.settings.taskDispatch.repoPaths[i].path) {
|
||||||
|
this.plugin.settings.taskDispatch.repoPaths[i].path = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return text;
|
||||||
|
})
|
||||||
|
.addExtraButton(button => button
|
||||||
|
.setIcon('folder-open')
|
||||||
|
.setTooltip('Browse')
|
||||||
|
.onClick(async () => {
|
||||||
|
const chosen = await this.pickFolder();
|
||||||
|
if (chosen) {
|
||||||
|
this.plugin.settings.taskDispatch.repoPaths[i].path = chosen;
|
||||||
|
// Auto-fill name from folder basename if empty
|
||||||
|
if (!this.plugin.settings.taskDispatch.repoPaths[i].name) {
|
||||||
|
const basename = chosen.split('/').pop() || '';
|
||||||
|
this.plugin.settings.taskDispatch.repoPaths[i].name = basename;
|
||||||
|
}
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
this.renderRepoPaths(container);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addExtraButton(button => button
|
||||||
|
.setIcon('trash')
|
||||||
|
.setTooltip('Remove')
|
||||||
|
.onClick(async () => {
|
||||||
|
this.plugin.settings.taskDispatch.repoPaths.splice(i, 1);
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
this.renderRepoPaths(container);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
new Setting(container)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Add repo path')
|
||||||
|
.onClick(async () => {
|
||||||
|
this.plugin.settings.taskDispatch.repoPaths.push({ name: '', path: '' });
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
this.renderRepoPaths(container);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the native OS folder picker via Electron's dialog API.
|
||||||
|
* Tries multiple Electron require paths for compatibility across Obsidian versions.
|
||||||
|
* Returns the selected path, or null if cancelled.
|
||||||
|
*/
|
||||||
|
private async pickFolder(): Promise<string | null> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let dialog: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Modern Obsidian / Electron: @electron/remote
|
||||||
|
dialog = require('@electron/remote')?.dialog;
|
||||||
|
} catch { /* not available */ }
|
||||||
|
|
||||||
|
if (!dialog) {
|
||||||
|
try {
|
||||||
|
// Older Electron: remote on the electron module
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const electron = require('electron');
|
||||||
|
dialog = (electron as any).remote?.dialog;
|
||||||
|
} catch { /* not available */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dialog) {
|
||||||
|
new Notice('Folder picker not available — type the path manually.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ['openDirectory'],
|
||||||
|
title: 'Select repository folder'
|
||||||
|
});
|
||||||
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
|
return result.filePaths[0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
new Notice('Folder picker failed — type the path manually.');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Vault } from 'obsidian';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitizes a filename to remove characters that aren't allowed in filenames
|
* Sanitizes a filename to remove characters that aren't allowed in filenames
|
||||||
* @param filename The filename to sanitize
|
* @param filename The filename to sanitize
|
||||||
|
|
@ -7,6 +9,49 @@ export function sanitizeFilename(filename: string): string {
|
||||||
return filename.replace(/[\\/:*?"<>|]/g, ' - ');
|
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
|
* Maps between note titles and their sanitized filenames
|
||||||
* This helps ensure backlinks point to the correct files
|
* This helps ensure backlinks point to the correct files
|
||||||
|
|
|
||||||
41
styles.css
41
styles.css
|
|
@ -48,3 +48,44 @@
|
||||||
.openaugi-dot.active {
|
.openaugi-dot.active {
|
||||||
opacity: 1;
|
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;
|
||||||
|
}
|
||||||
264
tests/context-gathering-service.test.ts
Normal file
264
tests/context-gathering-service.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { ContextGatheringService } from '../src/services/context-gathering-service';
|
||||||
|
import { DistillService } from '../src/services/distill-service';
|
||||||
|
import { OpenAIService } from '../src/services/openai-service';
|
||||||
|
import { DEFAULT_SETTINGS } from '../src/types/settings';
|
||||||
|
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const VAULT_DIR = path.resolve(__dirname, 'vault');
|
||||||
|
|
||||||
|
describe('ContextGatheringService', () => {
|
||||||
|
let app: ReturnType<typeof createMockApp>;
|
||||||
|
let distillService: DistillService;
|
||||||
|
let service: ContextGatheringService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createMockApp(VAULT_DIR);
|
||||||
|
const openAIService = new OpenAIService('test-key', 'gpt-5');
|
||||||
|
distillService = new DistillService(app as any, openAIService, DEFAULT_SETTINGS);
|
||||||
|
service = new ContextGatheringService(app as any, distillService, DEFAULT_SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gatherContext - linked-notes mode', () => {
|
||||||
|
it('discovers root note and its forward links at depth 1', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = result.notes.map(n => n.file.basename);
|
||||||
|
expect(names).toContain('Root Note');
|
||||||
|
expect(names).toContain('Linked Note A');
|
||||||
|
expect(names).toContain('Linked Note B');
|
||||||
|
expect(result.totalNotes).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects depth limit', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// At depth 1, we should NOT see "Deep Note" (which is depth 2: Root → A → Deep)
|
||||||
|
const names = result.notes.map(n => n.file.basename);
|
||||||
|
expect(names).not.toContain('Deep Note');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers deeper links at depth 2', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 2,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// At depth 2, "Deep Note" should be discovered (Root → Linked Note A → Deep Note)
|
||||||
|
const names = result.notes.map(n => n.file.basename);
|
||||||
|
expect(names).toContain('Deep Note');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes backlinks when enabled', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: true,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = result.notes.map(n => n.file.basename);
|
||||||
|
// "Backlink Source" links TO Root Note, so should be discovered
|
||||||
|
expect(names).toContain('Backlink Source');
|
||||||
|
|
||||||
|
// Verify the backlink note is marked as such
|
||||||
|
const backlinkNote = result.notes.find(n => n.file.basename === 'Backlink Source');
|
||||||
|
expect(backlinkNote?.isBacklink).toBe(true);
|
||||||
|
expect(backlinkNote?.discoveredVia).toContain('backlink');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects character limit', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
// Use a very small character limit
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 2,
|
||||||
|
maxCharacters: 200, // Very small — root note alone should exceed this
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Some notes should be excluded due to size limit
|
||||||
|
const excludedNotes = result.notes.filter(n => !n.included);
|
||||||
|
expect(excludedNotes.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when root note is missing in linked-notes mode', async () => {
|
||||||
|
await expect(
|
||||||
|
service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Root note required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('folder exclusion', () => {
|
||||||
|
it('excludes notes from specified folders', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 3,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: ['Excluded Folder'],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: true,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const includedNames = result.notes
|
||||||
|
.filter(n => n.included)
|
||||||
|
.map(n => n.file.basename);
|
||||||
|
expect(includedNames).not.toContain('Should Skip');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aggregateContent', () => {
|
||||||
|
it('aggregates forward links and backlinks separately', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const gathered = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: true,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gathered.aggregatedContent).toContain('# Note:');
|
||||||
|
expect(gathered.totalCharacters).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes backlink snippets in aggregated content', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const gathered = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: true,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backlink content should be in the aggregated output
|
||||||
|
if (gathered.notes.some(n => n.isBacklink)) {
|
||||||
|
expect(gathered.aggregatedContent).toContain('# Backlink:');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gatherContext - recent-activity mode', () => {
|
||||||
|
it('throws when time window is missing', async () => {
|
||||||
|
await expect(
|
||||||
|
service.gatherContext({
|
||||||
|
sourceMode: 'recent-activity',
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Time window required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('note metadata', () => {
|
||||||
|
it('assigns correct depth to discovered notes', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 2,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootEntry = result.notes.find(n => n.file.basename === 'Root Note');
|
||||||
|
expect(rootEntry?.depth).toBe(0);
|
||||||
|
|
||||||
|
const linkedA = result.notes.find(n => n.file.basename === 'Linked Note A');
|
||||||
|
expect(linkedA?.depth).toBe(1);
|
||||||
|
|
||||||
|
const deepNote = result.notes.find(n => n.file.basename === 'Deep Note');
|
||||||
|
if (deepNote) {
|
||||||
|
expect(deepNote.depth).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes discoveredVia metadata', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootEntry = result.notes.find(n => n.file.basename === 'Root Note');
|
||||||
|
expect(rootEntry?.discoveredVia).toBe('root');
|
||||||
|
|
||||||
|
const linkedEntry = result.notes.find(n => n.file.basename === 'Linked Note A');
|
||||||
|
expect(linkedEntry?.discoveredVia).toContain('linked from');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
248
tests/distill-service.test.ts
Normal file
248
tests/distill-service.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { DistillService } from '../src/services/distill-service';
|
||||||
|
import { OpenAIService } from '../src/services/openai-service';
|
||||||
|
import { DEFAULT_SETTINGS } from '../src/types/settings';
|
||||||
|
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const VAULT_DIR = path.resolve(__dirname, 'vault');
|
||||||
|
|
||||||
|
describe('DistillService', () => {
|
||||||
|
let app: ReturnType<typeof createMockApp>;
|
||||||
|
let openAIService: OpenAIService;
|
||||||
|
let service: DistillService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createMockApp(VAULT_DIR);
|
||||||
|
openAIService = new OpenAIService('test-key', 'gpt-5');
|
||||||
|
service = new DistillService(app as any, openAIService, DEFAULT_SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLinkedNotes', () => {
|
||||||
|
it('extracts forward links from a note', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(rootFile);
|
||||||
|
|
||||||
|
const basenames = linked.map(f => f.basename);
|
||||||
|
expect(basenames).toContain('Linked Note A');
|
||||||
|
expect(basenames).toContain('Linked Note B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include the root file itself', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(rootFile);
|
||||||
|
|
||||||
|
const paths = linked.map(f => f.path);
|
||||||
|
expect(paths).not.toContain('Root Note.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates linked files', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(rootFile);
|
||||||
|
|
||||||
|
const paths = linked.map(f => f.path);
|
||||||
|
const uniquePaths = [...new Set(paths)];
|
||||||
|
expect(paths.length).toBe(uniquePaths.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts only checked items from collection notes', async () => {
|
||||||
|
const collectionFile = createTestTFile(VAULT_DIR, 'Collection Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(collectionFile);
|
||||||
|
|
||||||
|
const basenames = linked.map(f => f.basename);
|
||||||
|
// Only [x] items should be included
|
||||||
|
expect(basenames).toContain('Linked Note A');
|
||||||
|
expect(basenames).toContain('Journal Note');
|
||||||
|
// Unchecked items should NOT be included
|
||||||
|
expect(basenames).not.toContain('Linked Note B');
|
||||||
|
expect(basenames).not.toContain('Backlink Source');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles notes with no links', async () => {
|
||||||
|
const deepNote = createTestTFile(VAULT_DIR, 'Deeply Linked/Deep Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(deepNote);
|
||||||
|
expect(linked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBacklinksForFile', () => {
|
||||||
|
it('finds notes that link TO the target file', () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const backlinks = service.getBacklinksForFile(rootFile);
|
||||||
|
|
||||||
|
const basenames = backlinks.map(f => f.basename);
|
||||||
|
expect(basenames).toContain('Backlink Source');
|
||||||
|
expect(basenames).toContain('Special Characters!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for notes with no backlinks', () => {
|
||||||
|
const excludedFile = createTestTFile(VAULT_DIR, 'Excluded Folder/Should Skip.md');
|
||||||
|
const backlinks = service.getBacklinksForFile(excludedFile);
|
||||||
|
expect(backlinks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBacklinkSnippets', () => {
|
||||||
|
it('extracts context around backlink references', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const backlinkSource = createTestTFile(VAULT_DIR, 'Backlink Source.md');
|
||||||
|
|
||||||
|
const snippets = await service.getBacklinkSnippets(rootFile, backlinkSource, 0);
|
||||||
|
|
||||||
|
expect(snippets.length).toBeGreaterThan(0);
|
||||||
|
// Header section mode (0) should include the section header
|
||||||
|
expect(snippets[0].snippet).toContain('Section About Root');
|
||||||
|
expect(snippets[0].snippet).toContain('Root Note');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for files with no links to target', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const deepNote = createTestTFile(VAULT_DIR, 'Deeply Linked/Deep Note.md');
|
||||||
|
|
||||||
|
const snippets = await service.getBacklinkSnippets(rootFile, deepNote, 0);
|
||||||
|
expect(snippets).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aggregateContent', () => {
|
||||||
|
it('combines content from multiple files', async () => {
|
||||||
|
const files = [
|
||||||
|
createTestTFile(VAULT_DIR, 'Linked Note A.md'),
|
||||||
|
createTestTFile(VAULT_DIR, 'Linked Note B.md'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await service.aggregateContent(files);
|
||||||
|
|
||||||
|
expect(result.content).toContain('# Note: Linked Note A');
|
||||||
|
expect(result.content).toContain('# Note: Linked Note B');
|
||||||
|
expect(result.content).toContain('atomic note-taking');
|
||||||
|
expect(result.content).toContain('Zettelkasten');
|
||||||
|
expect(result.sourceNotes).toContain('Linked Note A');
|
||||||
|
expect(result.sourceNotes).toContain('Linked Note B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates files by path', async () => {
|
||||||
|
const fileA = createTestTFile(VAULT_DIR, 'Linked Note A.md');
|
||||||
|
const fileDuplicate = createTestTFile(VAULT_DIR, 'Linked Note A.md');
|
||||||
|
|
||||||
|
const result = await service.aggregateContent([fileA, fileDuplicate]);
|
||||||
|
|
||||||
|
// Should only appear once
|
||||||
|
const occurrences = (result.content.match(/# Note: Linked Note A/g) || []).length;
|
||||||
|
expect(occurrences).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips dataview queries from output', async () => {
|
||||||
|
const files = [createTestTFile(VAULT_DIR, 'Dataview Note.md')];
|
||||||
|
const result = await service.aggregateContent(files);
|
||||||
|
|
||||||
|
expect(result.content).not.toContain('```dataview');
|
||||||
|
expect(result.content).not.toContain('TABLE file.mtime');
|
||||||
|
expect(result.content).toContain('Regular content after the dataview block');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('journal date filtering', () => {
|
||||||
|
it('identifies journal-style notes with date headers', () => {
|
||||||
|
// Access private method via any cast
|
||||||
|
const content = '# Daily\n\n### 2026-02-25\nToday entry\n\n### 2026-02-20\nOlder entry';
|
||||||
|
const result = (service as any).isJournalStyleNote(content);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-journal notes', () => {
|
||||||
|
const content = '# Regular Note\n\nJust some content without date headers.';
|
||||||
|
const result = (service as any).isJournalStyleNote(content);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts date sections correctly', () => {
|
||||||
|
const content = '# Header\n\n### 2026-02-25\nRecent\n\n### 2026-01-15\nOlder';
|
||||||
|
const sections = (service as any).getDateSections(content);
|
||||||
|
|
||||||
|
expect(sections.length).toBe(3); // header + 2 dated sections
|
||||||
|
expect(sections[0].date).toBeNull(); // undated header
|
||||||
|
expect(sections[1].date).toBeInstanceOf(Date);
|
||||||
|
expect(sections[2].date).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters content by date range', () => {
|
||||||
|
// Pin "now" so the fixed fixture dates are deterministic regardless of the
|
||||||
|
// real current date. extractContentByDateRange uses new Date() as "now".
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-02-28T12:00:00Z'));
|
||||||
|
try {
|
||||||
|
const content = '# Daily Journal\n\n### 2026-02-25\nRecent entry\n\n### 2025-01-01\nVery old entry';
|
||||||
|
const filtered = (service as any).extractContentByDateRange(content, 30);
|
||||||
|
|
||||||
|
expect(filtered).toContain('Recent entry');
|
||||||
|
expect(filtered).not.toContain('Very old entry');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractDateFromFilename', () => {
|
||||||
|
it('extracts date from YYYY-MM-DD prefix', () => {
|
||||||
|
const date = (service as any).extractDateFromFilename('2026-02-25 My Note');
|
||||||
|
expect(date).toBeInstanceOf(Date);
|
||||||
|
expect(date.getFullYear()).toBe(2026);
|
||||||
|
expect(date.getMonth()).toBe(1); // 0-indexed
|
||||||
|
expect(date.getDate()).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-date filenames', () => {
|
||||||
|
const date = (service as any).extractDateFromFilename('My Regular Note');
|
||||||
|
expect(date).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dataview query handling', () => {
|
||||||
|
it('detects dataview queries in content', () => {
|
||||||
|
const content = 'Some text\n\n```dataview\nLIST FROM #tag\n```\n\nMore text';
|
||||||
|
expect((service as any).containsDataviewQuery(content)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for content without dataview', () => {
|
||||||
|
const content = 'Regular markdown content\n\n```javascript\nconsole.log("hi")\n```';
|
||||||
|
expect((service as any).containsDataviewQuery(content)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts dataview query strings', () => {
|
||||||
|
const content = 'Text\n\n```dataview\nLIST FROM #tag\n```\n\n```dataview\nTABLE file.name\n```';
|
||||||
|
const queries = (service as any).extractDataviewQueries(content);
|
||||||
|
expect(queries).toHaveLength(2);
|
||||||
|
expect(queries[0]).toContain('LIST FROM #tag');
|
||||||
|
expect(queries[1]).toContain('TABLE file.name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips dataview queries from content', () => {
|
||||||
|
const content = 'Before\n\n```dataview\nLIST\n```\nAfter';
|
||||||
|
const stripped = (service as any).stripDataviewQueries(content);
|
||||||
|
expect(stripped).not.toContain('```dataview');
|
||||||
|
expect(stripped).toContain('Before');
|
||||||
|
expect(stripped).toContain('After');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tag stripping', () => {
|
||||||
|
it('strips simple tags', () => {
|
||||||
|
const result = (service as any).stripTags('Some text #tag more text');
|
||||||
|
expect(result).not.toContain('#tag');
|
||||||
|
expect(result).toContain('Some text');
|
||||||
|
expect(result).toContain('more text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips nested tags', () => {
|
||||||
|
const result = (service as any).stripTags('Content #nested/tag here');
|
||||||
|
expect(result).not.toContain('#nested/tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves markdown headers', () => {
|
||||||
|
const result = (service as any).stripTags('## Header\n\nContent #tag');
|
||||||
|
expect(result).toContain('## Header');
|
||||||
|
expect(result).not.toContain('#tag');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
217
tests/file-service.test.ts
Normal file
217
tests/file-service.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { FileService } from '../src/services/file-service';
|
||||||
|
import { MockApp } from './mocks/obsidian-mock';
|
||||||
|
import { TranscriptResponse, DistillResponse } from '../src/types/transcript';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
|
||||||
|
let testCounter = 0;
|
||||||
|
|
||||||
|
describe('FileService', () => {
|
||||||
|
let app: MockApp;
|
||||||
|
let service: FileService;
|
||||||
|
let outputDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Use unique subdirectory per test to avoid parallel cleanup races
|
||||||
|
testCounter++;
|
||||||
|
outputDir = path.join(BASE_OUTPUT_DIR, `run-${testCounter}-${Date.now()}`);
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
app = new MockApp(outputDir);
|
||||||
|
service = new FileService(
|
||||||
|
app as any,
|
||||||
|
'OpenAugi/Summaries',
|
||||||
|
'OpenAugi/Notes',
|
||||||
|
'OpenAugi/Published'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ensureDirectoriesExist', () => {
|
||||||
|
it('creates all output directories', async () => {
|
||||||
|
await service.ensureDirectoriesExist();
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Summaries'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Notes'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Published'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent (safe to call multiple times)', async () => {
|
||||||
|
await service.ensureDirectoriesExist();
|
||||||
|
await service.ensureDirectoriesExist();
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Summaries'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeTranscriptFiles', () => {
|
||||||
|
const mockTranscriptData: TranscriptResponse = {
|
||||||
|
summary: 'This transcript discusses [[Atomic Notes]] and [[Knowledge Management]].',
|
||||||
|
notes: [
|
||||||
|
{ title: 'Atomic Notes', content: 'Each note should contain one idea. See [[Knowledge Management]].' },
|
||||||
|
{ title: 'Knowledge Management', content: 'Systems for organizing knowledge. Related to [[Atomic Notes]].' },
|
||||||
|
],
|
||||||
|
tasks: [
|
||||||
|
'- [ ] Review [[Atomic Notes]] methodology',
|
||||||
|
'- [ ] Set up Zettelkasten system',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates summary file with backlinks', async () => {
|
||||||
|
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||||
|
|
||||||
|
const summaryPath = path.join(outputDir, 'OpenAugi/Summaries/My Transcript - summary.md');
|
||||||
|
expect(fs.existsSync(summaryPath)).toBe(true);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(summaryPath, 'utf-8');
|
||||||
|
expect(content).toContain('Atomic Notes');
|
||||||
|
expect(content).toContain('Knowledge Management');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates atomic note files in session folder', async () => {
|
||||||
|
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||||
|
|
||||||
|
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||||
|
const sessionFolders = fs.readdirSync(notesDir);
|
||||||
|
expect(sessionFolders.length).toBe(1);
|
||||||
|
expect(sessionFolders[0]).toMatch(/^Transcript \d{4}-\d{2}-\d{2}/);
|
||||||
|
|
||||||
|
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||||
|
const noteFiles = fs.readdirSync(sessionPath);
|
||||||
|
expect(noteFiles).toContain('Atomic Notes.md');
|
||||||
|
expect(noteFiles).toContain('Knowledge Management.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes tasks section in summary', async () => {
|
||||||
|
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||||
|
|
||||||
|
const summaryPath = path.join(outputDir, 'OpenAugi/Summaries/My Transcript - summary.md');
|
||||||
|
const content = fs.readFileSync(summaryPath, 'utf-8');
|
||||||
|
expect(content).toContain('## Tasks');
|
||||||
|
expect(content).toContain('Review');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes backlinks in note content', async () => {
|
||||||
|
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||||
|
|
||||||
|
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||||
|
const sessionFolders = fs.readdirSync(notesDir);
|
||||||
|
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||||
|
|
||||||
|
const atomicContent = fs.readFileSync(path.join(sessionPath, 'Atomic Notes.md'), 'utf-8');
|
||||||
|
// Backlinks should reference sanitized filenames
|
||||||
|
expect(atomicContent).toContain('[[Knowledge Management]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes filenames with special characters', async () => {
|
||||||
|
const dataWithSpecialChars: TranscriptResponse = {
|
||||||
|
summary: 'Summary of [[Note: Special]]',
|
||||||
|
notes: [
|
||||||
|
{ title: 'Note: Special', content: 'Content with special title' },
|
||||||
|
],
|
||||||
|
tasks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.writeTranscriptFiles('Test', dataWithSpecialChars);
|
||||||
|
|
||||||
|
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||||
|
const sessionFolders = fs.readdirSync(notesDir);
|
||||||
|
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||||
|
const noteFiles = fs.readdirSync(sessionPath);
|
||||||
|
|
||||||
|
// Colon should be sanitized
|
||||||
|
expect(noteFiles.some(f => f.includes(':'))).toBe(false);
|
||||||
|
expect(noteFiles.some(f => f.includes('Note'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeDistilledFiles', () => {
|
||||||
|
const mockDistillData: DistillResponse = {
|
||||||
|
summary: 'Distilled insights about [[Topic A]] and [[Topic B]].',
|
||||||
|
notes: [
|
||||||
|
{ title: 'Topic A', content: 'Deep analysis of A' },
|
||||||
|
{ title: 'Topic B', content: 'Overview of B, see also [[Topic A]]' },
|
||||||
|
],
|
||||||
|
tasks: ['- [ ] Follow up on Topic A'],
|
||||||
|
sourceNotes: ['Root Note', 'Linked Note A'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates distilled summary file', async () => {
|
||||||
|
// Create a mock TFile for the root
|
||||||
|
const { createTestTFile } = await import('./mocks/obsidian-mock');
|
||||||
|
// We need a file that exists in the output dir, so create it first
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, 'test/My Root.md'), 'root content');
|
||||||
|
const rootFile = createTestTFile(outputDir, 'test/My Root.md');
|
||||||
|
|
||||||
|
const summaryPath = await service.writeDistilledFiles(rootFile, mockDistillData);
|
||||||
|
|
||||||
|
expect(summaryPath).toContain('My Root - distilled');
|
||||||
|
expect(fs.existsSync(path.join(outputDir, summaryPath))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates atomic notes in session folder', async () => {
|
||||||
|
const { createTestTFile } = await import('./mocks/obsidian-mock');
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, 'test/Root.md'), 'root');
|
||||||
|
const rootFile = createTestTFile(outputDir, 'test/Root.md');
|
||||||
|
|
||||||
|
await service.writeDistilledFiles(rootFile, mockDistillData);
|
||||||
|
|
||||||
|
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||||
|
const sessionFolders = fs.readdirSync(notesDir);
|
||||||
|
expect(sessionFolders.length).toBe(1);
|
||||||
|
expect(sessionFolders[0]).toMatch(/^Distill Root/);
|
||||||
|
|
||||||
|
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||||
|
const noteFiles = fs.readdirSync(sessionPath);
|
||||||
|
expect(noteFiles).toContain('Topic A.md');
|
||||||
|
expect(noteFiles).toContain('Topic B.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes source notes in summary', async () => {
|
||||||
|
const { createTestTFile } = await import('./mocks/obsidian-mock');
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, 'test/Root.md'), 'root');
|
||||||
|
const rootFile = createTestTFile(outputDir, 'test/Root.md');
|
||||||
|
|
||||||
|
const summaryPath = await service.writeDistilledFiles(rootFile, mockDistillData);
|
||||||
|
const content = fs.readFileSync(path.join(outputDir, summaryPath), 'utf-8');
|
||||||
|
|
||||||
|
expect(content).toContain('## Source Notes');
|
||||||
|
expect(content).toContain('[[Root Note]]');
|
||||||
|
expect(content).toContain('[[Linked Note A]]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writePublishedPost', () => {
|
||||||
|
it('creates published file with frontmatter', async () => {
|
||||||
|
const content = '# My Great Post\n\nThis is the blog post content.';
|
||||||
|
const filePath = await service.writePublishedPost(content, ['Source A', 'Source B'], 'default');
|
||||||
|
|
||||||
|
expect(filePath).toContain('Published');
|
||||||
|
const fullContent = fs.readFileSync(path.join(outputDir, filePath), 'utf-8');
|
||||||
|
|
||||||
|
expect(fullContent).toContain('---');
|
||||||
|
expect(fullContent).toContain('type: published-post');
|
||||||
|
expect(fullContent).toContain('status: draft');
|
||||||
|
expect(fullContent).toContain('[[Source A]]');
|
||||||
|
expect(fullContent).toContain('# My Great Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts title from first heading', async () => {
|
||||||
|
const content = '# Test Title\n\nContent here.';
|
||||||
|
const filePath = await service.writePublishedPost(content, ['Source'], 'default');
|
||||||
|
|
||||||
|
expect(filePath).toContain('Test Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to source note name when no heading', async () => {
|
||||||
|
const content = 'No heading, just content.';
|
||||||
|
const filePath = await service.writePublishedPost(content, ['My Source Note'], 'default');
|
||||||
|
|
||||||
|
expect(filePath).toContain('My Source Note');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
125
tests/filename-utils.test.ts
Normal file
125
tests/filename-utils.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { sanitizeFilename, BacklinkMapper, createFileWithCollisionHandling } from '../src/utils/filename-utils';
|
||||||
|
import { MockVault } from './mocks/obsidian-mock';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
|
||||||
|
let collisionTestDir: string;
|
||||||
|
let collisionTestCounter = 0;
|
||||||
|
|
||||||
|
describe('sanitizeFilename', () => {
|
||||||
|
it('passes through clean filenames unchanged', () => {
|
||||||
|
expect(sanitizeFilename('My Note')).toBe('My Note');
|
||||||
|
expect(sanitizeFilename('simple-name')).toBe('simple-name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces backslash', () => {
|
||||||
|
expect(sanitizeFilename('path\\to\\file')).toBe('path - to - file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces forward slash', () => {
|
||||||
|
expect(sanitizeFilename('path/to/file')).toBe('path - to - file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces colon', () => {
|
||||||
|
expect(sanitizeFilename('Note: Important')).toBe('Note - Important');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces asterisk', () => {
|
||||||
|
expect(sanitizeFilename('Note*star')).toBe('Note - star');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces question mark', () => {
|
||||||
|
expect(sanitizeFilename('Is this a note?')).toBe('Is this a note - ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces double quotes', () => {
|
||||||
|
expect(sanitizeFilename('He said "hello"')).toBe('He said - hello - ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces angle brackets', () => {
|
||||||
|
expect(sanitizeFilename('<tag>')).toBe(' - tag - ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces pipe', () => {
|
||||||
|
expect(sanitizeFilename('A | B')).toBe('A - B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple special characters', () => {
|
||||||
|
const result = sanitizeFilename('Note: "Important" <test> | value');
|
||||||
|
expect(result).not.toMatch(/[\\/:*?"<>|]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BacklinkMapper', () => {
|
||||||
|
let mapper: BacklinkMapper;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mapper = new BacklinkMapper();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps registered titles to sanitized filenames in backlinks', () => {
|
||||||
|
mapper.registerTitle('My Important Note', 'My Important Note');
|
||||||
|
mapper.registerTitle('Note: Special', 'Note - Special');
|
||||||
|
|
||||||
|
const result = mapper.processBacklinks('See [[Note: Special]] and [[My Important Note]]');
|
||||||
|
expect(result).toBe('See [[Note - Special]] and [[My Important Note]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to sanitizeFilename for unregistered titles', () => {
|
||||||
|
const result = mapper.processBacklinks('See [[Unregistered: Note]]');
|
||||||
|
expect(result).toBe('See [[Unregistered - Note]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles content with no backlinks', () => {
|
||||||
|
const result = mapper.processBacklinks('Plain text with no links');
|
||||||
|
expect(result).toBe('Plain text with no links');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple backlinks in same line', () => {
|
||||||
|
mapper.registerTitle('A', 'a-sanitized');
|
||||||
|
mapper.registerTitle('B', 'b-sanitized');
|
||||||
|
|
||||||
|
const result = mapper.processBacklinks('Links: [[A]] and [[B]]');
|
||||||
|
expect(result).toBe('Links: [[a-sanitized]] and [[b-sanitized]]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createFileWithCollisionHandling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
collisionTestCounter++;
|
||||||
|
collisionTestDir = path.join(BASE_OUTPUT_DIR, `collision-${collisionTestCounter}-${Date.now()}`);
|
||||||
|
fs.mkdirSync(collisionTestDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates file at original path when no collision', async () => {
|
||||||
|
const vault = new MockVault(collisionTestDir);
|
||||||
|
const result = await createFileWithCollisionHandling(vault as any, 'test-note.md', 'Hello');
|
||||||
|
expect(result).toBe('test-note.md');
|
||||||
|
|
||||||
|
const content = fs.readFileSync(path.join(collisionTestDir, 'test-note.md'), 'utf-8');
|
||||||
|
expect(content).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends -1 when file already exists', async () => {
|
||||||
|
const vault = new MockVault(collisionTestDir);
|
||||||
|
fs.writeFileSync(path.join(collisionTestDir, 'note.md'), 'existing');
|
||||||
|
|
||||||
|
const result = await createFileWithCollisionHandling(vault as any, 'note.md', 'New content');
|
||||||
|
expect(result).toBe('note-1.md');
|
||||||
|
|
||||||
|
const content = fs.readFileSync(path.join(collisionTestDir, 'note-1.md'), 'utf-8');
|
||||||
|
expect(content).toBe('New content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments counter for multiple collisions', async () => {
|
||||||
|
const vault = new MockVault(collisionTestDir);
|
||||||
|
fs.writeFileSync(path.join(collisionTestDir, 'note.md'), 'v1');
|
||||||
|
fs.writeFileSync(path.join(collisionTestDir, 'note-1.md'), 'v2');
|
||||||
|
fs.writeFileSync(path.join(collisionTestDir, 'note-2.md'), 'v3');
|
||||||
|
|
||||||
|
const result = await createFileWithCollisionHandling(vault as any, 'note.md', 'v4');
|
||||||
|
expect(result).toBe('note-3.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
287
tests/mocks/obsidian-mock.ts
Normal file
287
tests/mocks/obsidian-mock.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
/**
|
||||||
|
* Filesystem-backed mock of the Obsidian API.
|
||||||
|
* Reads/writes real markdown files from a test vault directory so we can
|
||||||
|
* test DistillService, FileService, ContextGatheringService, etc. without
|
||||||
|
* running Obsidian.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { TFile } from './obsidian-module';
|
||||||
|
|
||||||
|
// ─── Link Parsing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ParsedLink {
|
||||||
|
link: string; // The raw link text (e.g., "Note A" or "Note A|alias")
|
||||||
|
original: string; // The full match including brackets
|
||||||
|
position: { start: { line: number; col: number }; end: { line: number; col: number } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse [[wikilinks]] from markdown content */
|
||||||
|
function parseWikilinks(content: string): ParsedLink[] {
|
||||||
|
const links: ParsedLink[] = [];
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
||||||
|
const line = lines[lineNum];
|
||||||
|
const regex = /\[\[(.*?)\]\]/g;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
|
const rawLink = match[1];
|
||||||
|
// Strip alias: [[path|alias]] → path
|
||||||
|
const linkPath = rawLink.includes('|') ? rawLink.split('|')[0] : rawLink;
|
||||||
|
links.push({
|
||||||
|
link: linkPath,
|
||||||
|
original: match[0],
|
||||||
|
position: {
|
||||||
|
start: { line: lineNum, col: match.index },
|
||||||
|
end: { line: lineNum, col: match.index + match[0].length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockTFile helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Create a TFile from a real filesystem path relative to vault root */
|
||||||
|
function createMockTFile(vaultRoot: string, relativePath: string): TFile {
|
||||||
|
const absPath = path.join(vaultRoot, relativePath);
|
||||||
|
let mtime = Date.now();
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(absPath);
|
||||||
|
mtime = stat.mtimeMs;
|
||||||
|
} catch { /* file may not exist yet for output tests */ }
|
||||||
|
const file = new TFile(relativePath, mtime);
|
||||||
|
file.stat.size = (() => {
|
||||||
|
try { return fs.statSync(absPath).size; } catch { return 0; }
|
||||||
|
})();
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockVaultAdapter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MockVaultAdapter {
|
||||||
|
constructor(private vaultRoot: string) {}
|
||||||
|
|
||||||
|
async exists(filePath: string): Promise<boolean> {
|
||||||
|
return fs.existsSync(path.join(this.vaultRoot, filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(filePath: string): Promise<{ mtime: number; ctime: number; size: number } | null> {
|
||||||
|
const absPath = path.join(this.vaultRoot, filePath);
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(absPath);
|
||||||
|
return { mtime: stat.mtimeMs, ctime: stat.ctimeMs, size: stat.size };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockVault ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class MockVault {
|
||||||
|
adapter: MockVaultAdapter;
|
||||||
|
private vaultRoot: string;
|
||||||
|
|
||||||
|
constructor(vaultRoot: string) {
|
||||||
|
this.vaultRoot = vaultRoot;
|
||||||
|
this.adapter = new MockVaultAdapter(vaultRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a file's content */
|
||||||
|
async read(file: TFile): Promise<string> {
|
||||||
|
const absPath = path.join(this.vaultRoot, file.path);
|
||||||
|
return fs.readFileSync(absPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a file */
|
||||||
|
async create(filePath: string, content: string): Promise<TFile> {
|
||||||
|
const absPath = path.join(this.vaultRoot, filePath);
|
||||||
|
const dir = path.dirname(absPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(absPath, content, 'utf-8');
|
||||||
|
return createMockTFile(this.vaultRoot, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a folder */
|
||||||
|
async createFolder(folderPath: string): Promise<void> {
|
||||||
|
const absPath = path.join(this.vaultRoot, folderPath);
|
||||||
|
if (!fs.existsSync(absPath)) {
|
||||||
|
fs.mkdirSync(absPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all markdown files in the vault */
|
||||||
|
getMarkdownFiles(): TFile[] {
|
||||||
|
const files: TFile[] = [];
|
||||||
|
const walk = (dir: string, relative: string) => {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const relPath = relative ? `${relative}/${entry.name}` : entry.name;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(path.join(dir, entry.name), relPath);
|
||||||
|
} else if (entry.name.endsWith('.md')) {
|
||||||
|
files.push(createMockTFile(this.vaultRoot, relPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(this.vaultRoot, '');
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a file by its exact path */
|
||||||
|
getAbstractFileByPath(filePath: string): TFile | null {
|
||||||
|
const absPath = path.join(this.vaultRoot, filePath);
|
||||||
|
if (fs.existsSync(absPath)) {
|
||||||
|
return createMockTFile(this.vaultRoot, filePath);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockMetadataCache ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class MockMetadataCache {
|
||||||
|
/** resolvedLinks[sourcePath][targetPath] = linkCount */
|
||||||
|
resolvedLinks: Record<string, Record<string, number>> = {};
|
||||||
|
private vaultRoot: string;
|
||||||
|
private vault: MockVault;
|
||||||
|
|
||||||
|
constructor(vaultRoot: string, vault: MockVault) {
|
||||||
|
this.vaultRoot = vaultRoot;
|
||||||
|
this.vault = vault;
|
||||||
|
this.buildLinkGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the resolved links graph by scanning all markdown files */
|
||||||
|
private buildLinkGraph(): void {
|
||||||
|
const files = this.vault.getMarkdownFiles();
|
||||||
|
for (const file of files) {
|
||||||
|
const absPath = path.join(this.vaultRoot, file.path);
|
||||||
|
const content = fs.readFileSync(absPath, 'utf-8');
|
||||||
|
const links = parseWikilinks(content);
|
||||||
|
|
||||||
|
if (!this.resolvedLinks[file.path]) {
|
||||||
|
this.resolvedLinks[file.path] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
const resolved = this.resolveLink(link.link, file.path);
|
||||||
|
if (resolved) {
|
||||||
|
this.resolvedLinks[file.path][resolved.path] =
|
||||||
|
(this.resolvedLinks[file.path][resolved.path] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a link path to a TFile (mimics Obsidian's shortest-path resolution) */
|
||||||
|
private resolveLink(linkPath: string, sourcePath: string): TFile | null {
|
||||||
|
// Add .md extension if not present
|
||||||
|
let searchPath = linkPath;
|
||||||
|
if (!searchPath.endsWith('.md')) {
|
||||||
|
searchPath += '.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact path first
|
||||||
|
const exactFile = this.vault.getAbstractFileByPath(searchPath);
|
||||||
|
if (exactFile) return exactFile;
|
||||||
|
|
||||||
|
// Try relative to source file's directory
|
||||||
|
const sourceDir = path.dirname(sourcePath);
|
||||||
|
const relativePath = sourceDir === '.' ? searchPath : `${sourceDir}/${searchPath}`;
|
||||||
|
const relativeFile = this.vault.getAbstractFileByPath(relativePath);
|
||||||
|
if (relativeFile) return relativeFile;
|
||||||
|
|
||||||
|
// Obsidian-style: search all files for basename match
|
||||||
|
const baseName = searchPath.split('/').pop();
|
||||||
|
if (baseName) {
|
||||||
|
const allFiles = this.vault.getMarkdownFiles();
|
||||||
|
const match = allFiles.find(f => f.path.endsWith(baseName) || f.path.endsWith(`/${baseName}`));
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get cached metadata for a file (links extracted from content) */
|
||||||
|
getFileCache(file: TFile): { links?: ParsedLink[]; embeds?: ParsedLink[] } | null {
|
||||||
|
const absPath = path.join(this.vaultRoot, file.path);
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(absPath, 'utf-8');
|
||||||
|
const links = parseWikilinks(content);
|
||||||
|
// For simplicity, embeds use the same format as links (Obsidian uses ![[embed]])
|
||||||
|
const embedRegex = /!\[\[(.*?)\]\]/g;
|
||||||
|
const embeds: ParsedLink[] = [];
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
||||||
|
let match;
|
||||||
|
while ((match = embedRegex.exec(lines[lineNum])) !== null) {
|
||||||
|
const rawLink = match[1];
|
||||||
|
const linkPath = rawLink.includes('|') ? rawLink.split('|')[0] : rawLink;
|
||||||
|
embeds.push({
|
||||||
|
link: linkPath,
|
||||||
|
original: match[0],
|
||||||
|
position: {
|
||||||
|
start: { line: lineNum, col: match.index },
|
||||||
|
end: { line: lineNum, col: match.index + match[0].length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { links, embeds: embeds.length > 0 ? embeds : undefined };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a link path to a TFile (public API matching Obsidian's) */
|
||||||
|
getFirstLinkpathDest(linkPath: string, sourcePath: string): TFile | null {
|
||||||
|
return this.resolveLink(linkPath, sourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockApp ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class MockApp {
|
||||||
|
vault: MockVault;
|
||||||
|
metadataCache: MockMetadataCache;
|
||||||
|
workspace: { onLayoutReady: (cb: () => void) => void; getActiveFile: () => null; getLeaf: () => any };
|
||||||
|
plugins: { plugins: Record<string, any> };
|
||||||
|
fileManager: { processFrontMatter: () => Promise<void> };
|
||||||
|
|
||||||
|
constructor(vaultRoot: string) {
|
||||||
|
this.vault = new MockVault(vaultRoot);
|
||||||
|
this.metadataCache = new MockMetadataCache(vaultRoot, this.vault);
|
||||||
|
this.workspace = {
|
||||||
|
onLayoutReady: (cb) => cb(),
|
||||||
|
getActiveFile: () => null,
|
||||||
|
getLeaf: () => ({ openFile: async () => {} }),
|
||||||
|
};
|
||||||
|
this.plugins = { plugins: {} };
|
||||||
|
this.fileManager = { processFrontMatter: async () => {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper to create a fresh test vault ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a MockApp pointed at a vault directory.
|
||||||
|
* Use for tests that need the full Obsidian API mock.
|
||||||
|
*/
|
||||||
|
export function createMockApp(vaultRoot: string): MockApp {
|
||||||
|
return new MockApp(vaultRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a TFile from a vault-relative path (for use in test assertions).
|
||||||
|
*/
|
||||||
|
export function createTestTFile(vaultRoot: string, relativePath: string): TFile {
|
||||||
|
return createMockTFile(vaultRoot, relativePath);
|
||||||
|
}
|
||||||
82
tests/mocks/obsidian-module.ts
Normal file
82
tests/mocks/obsidian-module.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
/**
|
||||||
|
* Stub module that replaces `import { ... } from 'obsidian'` in tests.
|
||||||
|
* Provides minimal class/type stubs so service code can import without error.
|
||||||
|
* The real mock implementations (MockVault, MockApp, etc.) are in obsidian-mock.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class TFile {
|
||||||
|
path: string;
|
||||||
|
basename: string;
|
||||||
|
extension: string;
|
||||||
|
stat: { mtime: number; ctime: number; size: number };
|
||||||
|
|
||||||
|
constructor(path: string, mtime?: number) {
|
||||||
|
this.path = path;
|
||||||
|
this.extension = path.split('.').pop() || '';
|
||||||
|
this.basename = path.split('/').pop()?.replace(`.${this.extension}`, '') || '';
|
||||||
|
this.stat = { mtime: mtime || Date.now(), ctime: mtime || Date.now(), size: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TAbstractFile {
|
||||||
|
path: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Vault {}
|
||||||
|
|
||||||
|
export class MetadataCache {}
|
||||||
|
|
||||||
|
export class App {}
|
||||||
|
|
||||||
|
export class Plugin {}
|
||||||
|
|
||||||
|
export class Component {}
|
||||||
|
|
||||||
|
export class Modal {
|
||||||
|
app: any;
|
||||||
|
constructor(app: any) { this.app = app; }
|
||||||
|
open() {}
|
||||||
|
close() {}
|
||||||
|
onOpen() {}
|
||||||
|
onClose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginSettingTab {
|
||||||
|
app: any;
|
||||||
|
plugin: any;
|
||||||
|
constructor(app: any, plugin: any) { this.app = app; this.plugin = plugin; }
|
||||||
|
display() {}
|
||||||
|
hide() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Setting {
|
||||||
|
constructor(_el: any) {}
|
||||||
|
setName(_n: string) { return this; }
|
||||||
|
setDesc(_d: string) { return this; }
|
||||||
|
addText(_cb: any) { return this; }
|
||||||
|
addToggle(_cb: any) { return this; }
|
||||||
|
addDropdown(_cb: any) { return this; }
|
||||||
|
addButton(_cb: any) { return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Notice {
|
||||||
|
constructor(message: string, _timeout?: number) {
|
||||||
|
// Silent in tests — uncomment for debugging:
|
||||||
|
// console.log('[Notice]', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileSystemAdapter {
|
||||||
|
private basePath: string;
|
||||||
|
constructor(basePath?: string) { this.basePath = basePath || ''; }
|
||||||
|
getBasePath(): string { return this.basePath; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Platform = {
|
||||||
|
isMobile: false,
|
||||||
|
isDesktop: true,
|
||||||
|
isDesktopApp: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MarkdownView {}
|
||||||
|
export class WorkspaceLeaf {}
|
||||||
235
tests/openai-service.test.ts
Normal file
235
tests/openai-service.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { OpenAIService } from '../src/services/openai-service';
|
||||||
|
|
||||||
|
describe('OpenAIService', () => {
|
||||||
|
let service: OpenAIService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new OpenAIService('test-api-key', 'gpt-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('stores API key and model', () => {
|
||||||
|
// Access private fields via any cast (testing internals)
|
||||||
|
expect((service as any).apiKey).toBe('test-api-key');
|
||||||
|
expect((service as any).model).toBe('gpt-5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCustomContext (via prompt building)', () => {
|
||||||
|
// We test this through the prompt generation methods since extractCustomContext is private
|
||||||
|
|
||||||
|
it('includes context: section in transcript prompt', () => {
|
||||||
|
const content = 'Some transcript\n\ncontext:\nFocus on tech topics\n\nMore content';
|
||||||
|
const prompt = (service as any).getPrompt(content);
|
||||||
|
expect(prompt).toContain('USER CONTEXT');
|
||||||
|
expect(prompt).toContain('Focus on tech topics');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes fenced context: section in transcript prompt', () => {
|
||||||
|
const content = 'Some content\n\n```context:\nOnly extract action items\n```\n\nMore stuff';
|
||||||
|
const prompt = (service as any).getPrompt(content);
|
||||||
|
expect(prompt).toContain('USER CONTEXT');
|
||||||
|
expect(prompt).toContain('Only extract action items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles content without context section', () => {
|
||||||
|
const content = 'Just a regular transcript with no special context';
|
||||||
|
const prompt = (service as any).getPrompt(content);
|
||||||
|
expect(prompt).not.toContain('USER CONTEXT');
|
||||||
|
expect(prompt).toContain('Just a regular transcript');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPrompt', () => {
|
||||||
|
it('includes the transcript content', () => {
|
||||||
|
const prompt = (service as any).getPrompt('My voice note about productivity');
|
||||||
|
expect(prompt).toContain('Transcript:\nMy voice note about productivity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes atomic notes instructions', () => {
|
||||||
|
const prompt = (service as any).getPrompt('test');
|
||||||
|
expect(prompt).toContain('Atomic Notes');
|
||||||
|
expect(prompt).toContain('self-contained');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes task extraction instructions', () => {
|
||||||
|
const prompt = (service as any).getPrompt('test');
|
||||||
|
expect(prompt).toContain('Tasks');
|
||||||
|
expect(prompt).toContain('actionable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes AUGI command handling', () => {
|
||||||
|
const prompt = (service as any).getPrompt('test');
|
||||||
|
expect(prompt).toContain('AUGI');
|
||||||
|
expect(prompt).toContain('auggie');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDistillPrompt', () => {
|
||||||
|
it('includes content to distill', () => {
|
||||||
|
const prompt = (service as any).getDistillPrompt('Note content here');
|
||||||
|
expect(prompt).toContain('Content to Distill:\nNote content here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom prompt when provided', () => {
|
||||||
|
const custom = 'You are a custom processor. Do something special.';
|
||||||
|
const prompt = (service as any).getDistillPrompt('content', custom);
|
||||||
|
expect(prompt).toContain(custom);
|
||||||
|
// Should NOT contain the default distill instructions
|
||||||
|
expect(prompt).not.toContain('expert knowledge curator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default prompt when no custom prompt', () => {
|
||||||
|
const prompt = (service as any).getDistillPrompt('content');
|
||||||
|
expect(prompt).toContain('expert knowledge curator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends custom context from content', () => {
|
||||||
|
const content = 'Notes here\n\ncontext:\nFocus on architecture\n\nMore notes';
|
||||||
|
const prompt = (service as any).getDistillPrompt(content);
|
||||||
|
expect(prompt).toContain('USER CONTEXT');
|
||||||
|
expect(prompt).toContain('Focus on architecture');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPublishPrompt', () => {
|
||||||
|
it('includes content to transform', () => {
|
||||||
|
const prompt = (service as any).getPublishPrompt('Blog content');
|
||||||
|
expect(prompt).toContain('Content to Transform:\nBlog content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default publish prompt', () => {
|
||||||
|
const prompt = (service as any).getPublishPrompt('content');
|
||||||
|
expect(prompt).toContain('publishable blog post');
|
||||||
|
expect(prompt).toContain('PRESERVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom prompt when provided', () => {
|
||||||
|
const custom = 'Write a newsletter instead.';
|
||||||
|
const prompt = (service as any).getPublishPrompt('content', custom);
|
||||||
|
expect(prompt).toContain('Write a newsletter instead.');
|
||||||
|
expect(prompt).not.toContain('publishable blog post');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseTranscript', () => {
|
||||||
|
it('throws when API key is not set', async () => {
|
||||||
|
const noKeyService = new OpenAIService('', 'gpt-5');
|
||||||
|
await expect(noKeyService.parseTranscript('content')).rejects.toThrow('OpenAI API key not set');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls OpenAI API with correct parameters', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
summary: 'Test summary',
|
||||||
|
notes: [{ title: 'Note 1', content: 'Content 1' }],
|
||||||
|
tasks: ['- [ ] Task 1']
|
||||||
|
}),
|
||||||
|
refusal: null
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const result = await service.parseTranscript('Test transcript');
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||||
|
const [url, options] = fetchSpy.mock.calls[0];
|
||||||
|
expect(url).toBe('https://api.openai.com/v1/chat/completions');
|
||||||
|
expect((options as any).method).toBe('POST');
|
||||||
|
expect(JSON.parse((options as any).body).model).toBe('gpt-5');
|
||||||
|
|
||||||
|
expect(result.summary).toBe('Test summary');
|
||||||
|
expect(result.notes).toHaveLength(1);
|
||||||
|
expect(result.tasks).toHaveLength(1);
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on API error response', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'Unauthorized',
|
||||||
|
json: async () => ({ error: { message: 'Invalid API key' } })
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
await expect(service.parseTranscript('content')).rejects.toThrow('OpenAI API error');
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('distillContent', () => {
|
||||||
|
it('throws when API key is not set', async () => {
|
||||||
|
const noKeyService = new OpenAIService('', 'gpt-5');
|
||||||
|
await expect(noKeyService.distillContent('content')).rejects.toThrow('OpenAI API key not set');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses structured response correctly', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
summary: 'Distilled summary about [[Topic A]]',
|
||||||
|
notes: [
|
||||||
|
{ title: 'Topic A', content: 'Deep dive into A' },
|
||||||
|
{ title: 'Topic B', content: 'Overview of B' },
|
||||||
|
],
|
||||||
|
tasks: []
|
||||||
|
}),
|
||||||
|
refusal: null
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const result = await service.distillContent('aggregated content');
|
||||||
|
|
||||||
|
expect(result.summary).toContain('Topic A');
|
||||||
|
expect(result.notes).toHaveLength(2);
|
||||||
|
expect(result.sourceNotes).toEqual([]); // Initialized as empty
|
||||||
|
expect(result.tasks).toEqual([]);
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('publishContent', () => {
|
||||||
|
it('returns plain text content', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: '# My Blog Post\n\nThis is the published content.',
|
||||||
|
refusal: null
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const result = await service.publishContent('raw notes');
|
||||||
|
|
||||||
|
expect(result).toContain('# My Blog Post');
|
||||||
|
expect(result).toContain('published content');
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
634
tests/task-dispatch-service.test.ts
Normal file
634
tests/task-dispatch-service.test.ts
Normal file
|
|
@ -0,0 +1,634 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { resolveRepoPath, TaskDispatchService } from '../src/services/task-dispatch-service';
|
||||||
|
import { DEFAULT_SETTINGS, OpenAugiSettings } from '../src/types/settings';
|
||||||
|
import { RepoPath } from '../src/types/task-dispatch';
|
||||||
|
import { TFile } from './mocks/obsidian-module';
|
||||||
|
|
||||||
|
// ─── Mock child_process.exec to avoid real tmux/osascript calls ──────────────
|
||||||
|
|
||||||
|
const mockExecAsync = vi.fn();
|
||||||
|
vi.mock('child_process', () => ({
|
||||||
|
exec: (...args: any[]) => {
|
||||||
|
// promisify(exec) wraps exec into a function that returns a promise.
|
||||||
|
// We intercept the callback-style call and delegate to mockExecAsync.
|
||||||
|
const cb = args[args.length - 1];
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
mockExecAsync(args[0], args[1])
|
||||||
|
.then((result: any) => cb(null, result))
|
||||||
|
.catch((err: any) => cb(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createMockApp(vaultBasePath: string) {
|
||||||
|
return {
|
||||||
|
vault: {
|
||||||
|
adapter: { getBasePath: () => vaultBasePath },
|
||||||
|
read: vi.fn(),
|
||||||
|
getMarkdownFiles: vi.fn().mockReturnValue([]),
|
||||||
|
},
|
||||||
|
metadataCache: {
|
||||||
|
getFileCache: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockDistillService() {
|
||||||
|
return {
|
||||||
|
getLinkedNotes: vi.fn().mockResolvedValue([]),
|
||||||
|
aggregateContent: vi.fn().mockResolvedValue({ content: '', sourceNotes: [] }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSettings(overrides: Partial<OpenAugiSettings['taskDispatch']> = {}): OpenAugiSettings {
|
||||||
|
return {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
taskDispatch: {
|
||||||
|
...DEFAULT_SETTINGS.taskDispatch,
|
||||||
|
tmuxPath: '/usr/bin/tmux',
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTFile(relativePath: string, frontmatter?: Record<string, any>): { file: TFile; frontmatter: Record<string, any> | undefined } {
|
||||||
|
const file = new TFile(relativePath);
|
||||||
|
return { file, frontmatter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveRepoPath (pure function)', () => {
|
||||||
|
const repoPaths: RepoPath[] = [
|
||||||
|
{ name: 'my-repo', path: '/Users/chris/repos/my-repo' },
|
||||||
|
{ name: 'Other Project', path: '/opt/projects/other' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('matches by name (case-insensitive)', () => {
|
||||||
|
expect(resolveRepoPath('my-repo', repoPaths)).toBe('/Users/chris/repos/my-repo');
|
||||||
|
expect(resolveRepoPath('MY-REPO', repoPaths)).toBe('/Users/chris/repos/my-repo');
|
||||||
|
expect(resolveRepoPath('My-Repo', repoPaths)).toBe('/Users/chris/repos/my-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown names', () => {
|
||||||
|
expect(resolveRepoPath('nonexistent', repoPaths)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty repo paths', () => {
|
||||||
|
expect(resolveRepoPath('anything', [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for null/undefined repo paths', () => {
|
||||||
|
expect(resolveRepoPath('anything', null as any)).toBeNull();
|
||||||
|
expect(resolveRepoPath('anything', undefined as any)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TaskDispatchService', () => {
|
||||||
|
let app: ReturnType<typeof createMockApp>;
|
||||||
|
let distillService: ReturnType<typeof createMockDistillService>;
|
||||||
|
let settings: OpenAugiSettings;
|
||||||
|
let service: TaskDispatchService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = createMockApp('/Users/chris/vault');
|
||||||
|
distillService = createMockDistillService();
|
||||||
|
settings = makeSettings();
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── shellEscape ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('shellEscape', () => {
|
||||||
|
const escape = (str: string) => (service as any).shellEscape(str);
|
||||||
|
|
||||||
|
it('wraps in single quotes', () => {
|
||||||
|
expect(escape('hello')).toBe("'hello'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes single quotes in the string', () => {
|
||||||
|
expect(escape("it's")).toBe("'it'\\''s'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
expect(escape('')).toBe("''");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles strings with spaces and special chars', () => {
|
||||||
|
expect(escape('hello world $HOME')).toBe("'hello world $HOME'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple single quotes', () => {
|
||||||
|
expect(escape("a'b'c")).toBe("'a'\\''b'\\''c'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getTaskId ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getTaskId', () => {
|
||||||
|
const getTaskId = (file: TFile) => (service as any).getTaskId(file);
|
||||||
|
|
||||||
|
it('reads task_id from frontmatter', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'abc-123' },
|
||||||
|
});
|
||||||
|
expect(getTaskId(file)).toBe('abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads task-id (hyphenated) from frontmatter', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { 'task-id': 'def-456' },
|
||||||
|
});
|
||||||
|
expect(getTaskId(file)).toBe('def-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no frontmatter', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue(null);
|
||||||
|
expect(getTaskId(file)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when frontmatter has no task_id', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { title: 'Hello' },
|
||||||
|
});
|
||||||
|
expect(getTaskId(file)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts numeric task_id to string', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 42 },
|
||||||
|
});
|
||||||
|
expect(getTaskId(file)).toBe('42');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getWorkingDir ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getWorkingDir', () => {
|
||||||
|
const getWorkingDir = (file: TFile) => (service as any).getWorkingDir(file);
|
||||||
|
|
||||||
|
it('uses working_dir frontmatter as absolute path', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { working_dir: '/absolute/path' },
|
||||||
|
});
|
||||||
|
expect(getWorkingDir(file)).toBe('/absolute/path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses working-dir (hyphenated) frontmatter', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { 'working-dir': '/other/path' },
|
||||||
|
});
|
||||||
|
expect(getWorkingDir(file)).toBe('/other/path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves named repo path from frontmatter', () => {
|
||||||
|
settings.taskDispatch.repoPaths = [
|
||||||
|
{ name: 'my-repo', path: '/Users/chris/repos/my-repo' },
|
||||||
|
];
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { working_dir: 'my-repo' },
|
||||||
|
});
|
||||||
|
expect(getWorkingDir(file)).toBe('/Users/chris/repos/my-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves relative path against vault root', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { working_dir: 'projects/foo' },
|
||||||
|
});
|
||||||
|
expect(getWorkingDir(file)).toBe('/Users/chris/vault/projects/foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to defaultWorkingDir setting', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
settings.taskDispatch.defaultWorkingDir = '/default/dir';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
expect(getWorkingDir(file)).toBe('/default/dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves relative defaultWorkingDir against vault root', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
settings.taskDispatch.defaultWorkingDir = 'OpenAugi/Tasks';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
expect(getWorkingDir(file)).toBe('/Users/chris/vault/OpenAugi/Tasks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to HOME when no working dir configured', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
settings.taskDispatch.defaultWorkingDir = '';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
expect(getWorkingDir(file)).toBe(process.env.HOME);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getAgentConfig ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getAgentConfig', () => {
|
||||||
|
const getAgentConfig = () => (service as any).getAgentConfig();
|
||||||
|
|
||||||
|
it('returns the configured default agent', () => {
|
||||||
|
const config = getAgentConfig();
|
||||||
|
expect(config.id).toBe('claude-code');
|
||||||
|
expect(config.command).toBe('claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first agent if default not found', () => {
|
||||||
|
settings.taskDispatch.defaultAgent = 'nonexistent';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
const config = getAgentConfig();
|
||||||
|
expect(config.id).toBe('claude-code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── assembleContext ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('assembleContext', () => {
|
||||||
|
it('includes task ID, note body, and instructions', async () => {
|
||||||
|
const file = new TFile('Notes/Task 1.md');
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: test-1\n---\n\nDo the thing.\n\n## Details\nMore info.');
|
||||||
|
distillService.getLinkedNotes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 'test-1');
|
||||||
|
|
||||||
|
expect(context).toContain('# Task: test-1');
|
||||||
|
expect(context).toContain('Do the thing.');
|
||||||
|
expect(context).toContain('## Details');
|
||||||
|
expect(context).toContain('More info.');
|
||||||
|
// Instructions section
|
||||||
|
expect(context).toContain('## Instructions');
|
||||||
|
expect(context).toContain('Task file: Notes/Task 1.md');
|
||||||
|
expect(context).toContain('Task ID: test-1');
|
||||||
|
expect(context).toContain('MCP append_results tool');
|
||||||
|
expect(context).toContain('## Results section');
|
||||||
|
expect(context).toContain('[[wikilinks]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips frontmatter from note body', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: t1\nworking_dir: /foo\n---\n\nBody text.');
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 't1');
|
||||||
|
|
||||||
|
expect(context).not.toContain('task_id: t1');
|
||||||
|
expect(context).not.toContain('working_dir: /foo');
|
||||||
|
expect(context).toContain('Body text.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes linked context when present', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: t1\n---\n\nMain body.');
|
||||||
|
|
||||||
|
const linkedFile = new TFile('Notes/Reference.md');
|
||||||
|
distillService.getLinkedNotes.mockResolvedValue([linkedFile]);
|
||||||
|
distillService.aggregateContent.mockResolvedValue({
|
||||||
|
content: '# Note: Reference\n\nLinked content here.',
|
||||||
|
sourceNotes: ['Reference'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 't1');
|
||||||
|
|
||||||
|
expect(context).toContain('## Linked Context');
|
||||||
|
expect(context).toContain('Linked content here.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits linked context section when no linked notes', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: t1\n---\n\nBody.');
|
||||||
|
distillService.getLinkedNotes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 't1');
|
||||||
|
|
||||||
|
expect(context).not.toContain('## Linked Context');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates context at maxContextChars', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
const longBody = 'A'.repeat(500);
|
||||||
|
app.vault.read.mockResolvedValue(`---\ntask_id: t1\n---\n\n${longBody}`);
|
||||||
|
distillService.getLinkedNotes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
settings.taskDispatch.maxContextChars = 100;
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 't1');
|
||||||
|
|
||||||
|
expect(context.length).toBeLessThanOrEqual(100 + 50); // 100 + truncation message
|
||||||
|
expect(context).toContain('...(context truncated at character limit)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── writeContextFile / cleanupContextFile ───────────────────────────────
|
||||||
|
|
||||||
|
describe('writeContextFile and cleanupContextFile', () => {
|
||||||
|
const tmpDir = path.join('/tmp', `openaugi-test-${process.pid}`);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
settings.taskDispatch.contextTempDir = tmpDir;
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* ok */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes context to file and returns path', async () => {
|
||||||
|
const filePath = await (service as any).writeContextFile('abc', 'hello world');
|
||||||
|
|
||||||
|
expect(filePath).toBe(path.join(tmpDir, 'task-abc-context.md'));
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true);
|
||||||
|
expect(fs.readFileSync(filePath, 'utf-8')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates directory if it does not exist', async () => {
|
||||||
|
const deepDir = path.join(tmpDir, 'nested', 'dir');
|
||||||
|
settings.taskDispatch.contextTempDir = deepDir;
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
const filePath = await (service as any).writeContextFile('xyz', 'content');
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
fs.rmSync(deepDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up context file', async () => {
|
||||||
|
await (service as any).writeContextFile('cleanup-test', 'data');
|
||||||
|
const filePath = path.join(tmpDir, 'task-cleanup-test-context.md');
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true);
|
||||||
|
|
||||||
|
(service as any).cleanupContextFile('cleanup-test');
|
||||||
|
expect(fs.existsSync(filePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanupContextFile does not throw for missing file', () => {
|
||||||
|
expect(() => (service as any).cleanupContextFile('nonexistent')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── tmuxSessionExists ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('tmuxSessionExists', () => {
|
||||||
|
it('returns true when tmux has-session succeeds', async () => {
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
const result = await (service as any).tmuxSessionExists('/usr/bin/tmux', 'task-abc');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when tmux has-session fails', async () => {
|
||||||
|
mockExecAsync.mockRejectedValueOnce(new Error('no session'));
|
||||||
|
const result = await (service as any).tmuxSessionExists('/usr/bin/tmux', 'task-abc');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── createTmuxSession ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('createTmuxSession', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock fs.promises.mkdir
|
||||||
|
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates tmux session then sends agent command with context as user prompt', async () => {
|
||||||
|
// new-session
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// capture-pane for waitForShellReady
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '$ ', stderr: '' });
|
||||||
|
// send-keys
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
const agentConfig = {
|
||||||
|
id: 'claude-code',
|
||||||
|
name: 'Claude Code',
|
||||||
|
command: 'claude',
|
||||||
|
contextFlag: '--append-system-prompt',
|
||||||
|
};
|
||||||
|
|
||||||
|
await (service as any).createTmuxSession(
|
||||||
|
'/usr/bin/tmux', 'task-test', agentConfig, '/tmp/ctx.md', '/home/user/project'
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0] as string);
|
||||||
|
|
||||||
|
// Verify new-session was called
|
||||||
|
expect(calls.some(c => c.includes('new-session -d -s'))).toBe(true);
|
||||||
|
|
||||||
|
// Verify send-keys passes context file via $(cat ...) as user prompt
|
||||||
|
const sendKeysCmd = calls.find(c => c.includes('send-keys'));
|
||||||
|
expect(sendKeysCmd).toBeTruthy();
|
||||||
|
expect(sendKeysCmd).toContain('$(cat');
|
||||||
|
expect(sendKeysCmd).toContain('/tmp/ctx.md');
|
||||||
|
expect(sendKeysCmd).toContain('Enter');
|
||||||
|
// Should NOT contain the contextFlag — context is the user prompt now
|
||||||
|
expect(sendKeysCmd).not.toContain('--append-system-prompt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── listActiveSessions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('listActiveSessions', () => {
|
||||||
|
it('parses tmux list-sessions output', async () => {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
mockExecAsync.mockResolvedValueOnce({
|
||||||
|
stdout: `task-abc ${timestamp}\ntask-def ${timestamp}\nnon-task-session ${timestamp}\n`,
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = await service.listActiveSessions();
|
||||||
|
|
||||||
|
expect(sessions).toHaveLength(2);
|
||||||
|
expect(sessions[0].taskId).toBe('abc');
|
||||||
|
expect(sessions[0].tmuxSessionName).toBe('task-abc');
|
||||||
|
expect(sessions[1].taskId).toBe('def');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out non-task sessions', async () => {
|
||||||
|
mockExecAsync.mockResolvedValueOnce({
|
||||||
|
stdout: 'my-other-session 1234567890\n',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = await service.listActiveSessions();
|
||||||
|
expect(sessions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when tmux is not running', async () => {
|
||||||
|
mockExecAsync.mockRejectedValueOnce(new Error('no server running'));
|
||||||
|
|
||||||
|
const sessions = await service.listActiveSessions();
|
||||||
|
expect(sessions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── launchOrAttach ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('launchOrAttach', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for notes without task_id', async () => {
|
||||||
|
const file = new TFile('Notes/Regular.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
|
||||||
|
await service.launchOrAttach(file as any);
|
||||||
|
|
||||||
|
expect(mockExecAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches to existing session', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'existing' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// has-session succeeds (session exists)
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// osascript for openTerminal
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
await service.launchOrAttach(file as any);
|
||||||
|
|
||||||
|
// Should NOT call new-session
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]);
|
||||||
|
expect(calls.some((c: string) => c.includes('new-session'))).toBe(false);
|
||||||
|
// Should call osascript to attach
|
||||||
|
expect(calls.some((c: string) => c.includes('osascript'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new session for fresh task', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'fresh' },
|
||||||
|
});
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: fresh\n---\n\nDo stuff.');
|
||||||
|
|
||||||
|
// Ensure contextTempDir exists for writeContextFile
|
||||||
|
const tmpDir = path.join('/tmp', `openaugi-launch-test-${process.pid}`);
|
||||||
|
settings.taskDispatch.contextTempDir = tmpDir;
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
// has-session fails (no existing session)
|
||||||
|
mockExecAsync.mockRejectedValueOnce(new Error('no session'));
|
||||||
|
// new-session
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// capture-pane (waitForShellReady)
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '$ ', stderr: '' });
|
||||||
|
// send-keys
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// osascript (openTerminal)
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
await service.launchOrAttach(file as any);
|
||||||
|
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]);
|
||||||
|
expect(calls.some((c: string) => c.includes('new-session'))).toBe(true);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* ok */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── killSession ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('killSession', () => {
|
||||||
|
it('does nothing for notes without task_id', async () => {
|
||||||
|
const file = new TFile('Notes/Regular.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
|
||||||
|
await service.killSession(file as any);
|
||||||
|
expect(mockExecAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills existing session and cleans up context file', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'kill-me' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// has-session succeeds
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// kill-session
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
await service.killSession(file as any);
|
||||||
|
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]);
|
||||||
|
expect(calls.some((c: string) => c.includes('kill-session'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies when no active session found', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'no-session' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// has-session fails
|
||||||
|
mockExecAsync.mockRejectedValueOnce(new Error('no session'));
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await service.killSession(file as any);
|
||||||
|
|
||||||
|
// Should NOT call kill-session
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]);
|
||||||
|
expect(calls.some((c: string) => c.includes('kill-session'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── openTerminal ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('openTerminal', () => {
|
||||||
|
it('opens iTerm2 when configured', async () => {
|
||||||
|
settings.taskDispatch.terminalApp = 'iterm2';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
await service.openTerminal('task-test');
|
||||||
|
|
||||||
|
const cmd = mockExecAsync.mock.calls[0][0] as string;
|
||||||
|
expect(cmd).toContain('iTerm2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens Terminal.app when configured', async () => {
|
||||||
|
settings.taskDispatch.terminalApp = 'terminal-app';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
await service.openTerminal('task-test');
|
||||||
|
|
||||||
|
const cmd = mockExecAsync.mock.calls[0][0] as string;
|
||||||
|
expect(cmd).toContain('Terminal');
|
||||||
|
expect(cmd).not.toContain('iTerm2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
189
tests/task-file-service.test.ts
Normal file
189
tests/task-file-service.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
TaskFileService,
|
||||||
|
TASKS_FOLDER,
|
||||||
|
buildTaskFileContent,
|
||||||
|
stripFrontmatter,
|
||||||
|
taskFileSlug,
|
||||||
|
taskFileTimestamp,
|
||||||
|
} from '../src/services/task-file-service';
|
||||||
|
import { createMockApp, MockApp } from './mocks/obsidian-mock';
|
||||||
|
|
||||||
|
// Mirrors FRONTMATTER_RE in the parent repo's task_watcher.py — the reader
|
||||||
|
// side of the task-file contract. If a file doesn't match this, the watcher
|
||||||
|
// never sees its frontmatter.
|
||||||
|
const WATCHER_FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n/;
|
||||||
|
|
||||||
|
// ─── Pure helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('stripFrontmatter', () => {
|
||||||
|
it('removes a leading frontmatter block', () => {
|
||||||
|
expect(stripFrontmatter('---\nfoo: bar\n---\nBody text.')).toBe('Body text.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves content without frontmatter untouched', () => {
|
||||||
|
expect(stripFrontmatter('Just a note.')).toBe('Just a note.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strip --- separators mid-document', () => {
|
||||||
|
const content = 'Intro.\n\n---\n\nMore.';
|
||||||
|
expect(stripFrontmatter(content)).toBe(content);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('taskFileSlug', () => {
|
||||||
|
it('lowercases and hyphenates', () => {
|
||||||
|
expect(taskFileSlug('run the review pass')).toBe('run-the-review-pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips special characters', () => {
|
||||||
|
expect(taskFileSlug('distill Journal 2026-07-07!')).toBe('distill-journal-2026-07-07');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long titles without a trailing hyphen', () => {
|
||||||
|
const slug = taskFileSlug('a'.repeat(45) + ' bcdef ghijk');
|
||||||
|
expect(slug.length).toBeLessThanOrEqual(50);
|
||||||
|
expect(slug.endsWith('-')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "task" for empty input', () => {
|
||||||
|
expect(taskFileSlug('!!!')).toBe('task');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('taskFileTimestamp', () => {
|
||||||
|
it('formats as YYYYMMDD-HHMMSS', () => {
|
||||||
|
const ts = taskFileTimestamp(new Date(2026, 6, 7, 9, 5, 3));
|
||||||
|
expect(ts).toBe('20260707-090503');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── buildTaskFileContent ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('buildTaskFileContent', () => {
|
||||||
|
const content = buildTaskFileContent({
|
||||||
|
title: 'run the review pass',
|
||||||
|
context: 'Triggered via the plugin.',
|
||||||
|
instruction: 'run the review pass',
|
||||||
|
task: 'Read OpenAugi/AGENT/review-pass.md and execute: run the review pass.',
|
||||||
|
sourceNote: 'OpenAugi plugin',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces frontmatter the watcher can parse, with status pending', () => {
|
||||||
|
const match = content.match(WATCHER_FRONTMATTER_RE);
|
||||||
|
expect(match).toBeTruthy();
|
||||||
|
expect(match![1]).toContain('status: pending');
|
||||||
|
expect(match![1]).toContain('source_block_id: obsidian-plugin');
|
||||||
|
expect(match![1]).toContain('source_note: "[[OpenAugi plugin]]"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains all template sections in order', () => {
|
||||||
|
const sections = [
|
||||||
|
'# run the review pass',
|
||||||
|
'## Context',
|
||||||
|
'## User instruction',
|
||||||
|
'## Task',
|
||||||
|
'## Human Todo',
|
||||||
|
'## Results',
|
||||||
|
];
|
||||||
|
let lastIndex = -1;
|
||||||
|
for (const section of sections) {
|
||||||
|
const idx = content.indexOf(section);
|
||||||
|
expect(idx, `missing section: ${section}`).toBeGreaterThan(lastIndex);
|
||||||
|
lastIndex = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('block-quotes the user instruction', () => {
|
||||||
|
expect(content).toContain('> run the review pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the context verbatim under ## Context', () => {
|
||||||
|
const multiline = buildTaskFileContent({
|
||||||
|
title: 't',
|
||||||
|
context: 'Line one.\n\n- bullet\n- another',
|
||||||
|
instruction: 'i',
|
||||||
|
task: 'do it',
|
||||||
|
sourceNote: 'Note',
|
||||||
|
});
|
||||||
|
expect(multiline).toContain('## Context\n\nLine one.\n\n- bullet\n- another\n\n## User instruction');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── TaskFileService (fs-backed vault) ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TaskFileService', () => {
|
||||||
|
let vaultRoot: string;
|
||||||
|
let app: MockApp;
|
||||||
|
let service: TaskFileService;
|
||||||
|
const now = new Date(2026, 6, 7, 14, 30, 0);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vaultRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openaugi-taskfile-'));
|
||||||
|
app = createMockApp(vaultRoot);
|
||||||
|
service = new TaskFileService(app as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(vaultRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function readTask(relPath: string): string {
|
||||||
|
return fs.readFileSync(path.join(vaultRoot, relPath), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates the OpenAugi/Tasks folder if missing', async () => {
|
||||||
|
expect(fs.existsSync(path.join(vaultRoot, TASKS_FOLDER))).toBe(false);
|
||||||
|
await service.createReviewPassTask(now);
|
||||||
|
expect(fs.existsSync(path.join(vaultRoot, TASKS_FOLDER))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a pending review-pass task with the expected filename', async () => {
|
||||||
|
const taskPath = await service.createReviewPassTask(now);
|
||||||
|
|
||||||
|
expect(taskPath).toBe(`${TASKS_FOLDER}/run-the-review-pass-20260707-143000.md`);
|
||||||
|
const content = readTask(taskPath);
|
||||||
|
expect(content.match(WATCHER_FRONTMATTER_RE)![1]).toContain('status: pending');
|
||||||
|
expect(content).toContain('> run the review pass');
|
||||||
|
expect(content).toContain('Read OpenAugi/AGENT/review-pass.md and execute: run the review pass.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a process-dashboard task with its own instruction', async () => {
|
||||||
|
const taskPath = await service.createProcessDashboardTask(now);
|
||||||
|
|
||||||
|
expect(taskPath).toBe(`${TASKS_FOLDER}/process-the-dashboard-20260707-143000.md`);
|
||||||
|
const content = readTask(taskPath);
|
||||||
|
expect(content).toContain('> process the dashboard');
|
||||||
|
expect(content).toContain('Read OpenAugi/AGENT/review-pass.md and execute: process the dashboard.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a distill task with the given content as ## Context', async () => {
|
||||||
|
const selection = 'Idea one about capture.\n\nIdea two about routing.';
|
||||||
|
const taskPath = await service.createDistillTask(selection, 'Journal 2026-07-07', now);
|
||||||
|
|
||||||
|
expect(taskPath).toBe(`${TASKS_FOLDER}/distill-journal-2026-07-07-20260707-143000.md`);
|
||||||
|
const content = readTask(taskPath);
|
||||||
|
expect(content).toContain('source_note: "[[Journal 2026-07-07]]"');
|
||||||
|
expect(content).toContain(`## Context\n\n${selection}\n\n## User instruction`);
|
||||||
|
expect(content).toContain('> distill this per OpenAugi/AGENT/distill-lens.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite when two tasks land in the same second', async () => {
|
||||||
|
const first = await service.createReviewPassTask(now);
|
||||||
|
const second = await service.createReviewPassTask(now);
|
||||||
|
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(fs.existsSync(path.join(vaultRoot, first))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(vaultRoot, second))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never sets a repo/working_dir key — the watcher defaults to the vault', async () => {
|
||||||
|
const taskPath = await service.createReviewPassTask(now);
|
||||||
|
const frontmatter = readTask(taskPath).match(WATCHER_FRONTMATTER_RE)![1];
|
||||||
|
expect(frontmatter).not.toContain('repo:');
|
||||||
|
expect(frontmatter).not.toContain('working_dir');
|
||||||
|
});
|
||||||
|
});
|
||||||
10
tests/vault/Backlink Source.md
Normal file
10
tests/vault/Backlink Source.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Backlink Source
|
||||||
|
|
||||||
|
## Section About Root
|
||||||
|
|
||||||
|
This section references the [[Root Note]] because it is important for testing backlink discovery.
|
||||||
|
|
||||||
|
## Unrelated Section
|
||||||
|
|
||||||
|
This section has nothing to do with the root note.
|
||||||
|
It discusses other topics entirely.
|
||||||
8
tests/vault/Collection Note.md
Normal file
8
tests/vault/Collection Note.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Collection of Notes
|
||||||
|
|
||||||
|
This is a collection note with checkboxes.
|
||||||
|
|
||||||
|
- [x] [[Linked Note A]]
|
||||||
|
- [ ] [[Linked Note B]]
|
||||||
|
- [x] [[Journal Note]]
|
||||||
|
- [ ] [[Backlink Source]]
|
||||||
11
tests/vault/Context Note.md
Normal file
11
tests/vault/Context Note.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Note with Custom Context
|
||||||
|
|
||||||
|
This note has custom context instructions for AI processing.
|
||||||
|
|
||||||
|
Some content about software architecture patterns.
|
||||||
|
|
||||||
|
```context:
|
||||||
|
Only extract notes about design patterns. Ignore implementation details.
|
||||||
|
```
|
||||||
|
|
||||||
|
More content about specific patterns like Observer, Strategy, and Factory.
|
||||||
14
tests/vault/Dataview Note.md
Normal file
14
tests/vault/Dataview Note.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Notes with Dataview
|
||||||
|
|
||||||
|
This note contains a dataview query that should be stripped from output.
|
||||||
|
|
||||||
|
```dataview
|
||||||
|
TABLE file.mtime as "Modified"
|
||||||
|
FROM "/"
|
||||||
|
SORT file.mtime DESC
|
||||||
|
LIMIT 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Regular content after the dataview block.
|
||||||
|
|
||||||
|
Also links to [[Linked Note A]].
|
||||||
8
tests/vault/Deeply Linked/Deep Note.md
Normal file
8
tests/vault/Deeply Linked/Deep Note.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Deep Note
|
||||||
|
|
||||||
|
This note is at depth 2 from the root:
|
||||||
|
Root Note → Linked Note A → Deep Note
|
||||||
|
|
||||||
|
It contains insights about deep work and focused concentration.
|
||||||
|
|
||||||
|
No further links from here (leaf node).
|
||||||
4
tests/vault/Excluded Folder/Should Skip.md
Normal file
4
tests/vault/Excluded Folder/Should Skip.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Should Be Excluded
|
||||||
|
|
||||||
|
This note lives in a folder that should be excluded from discovery.
|
||||||
|
It should never appear in gathered context.
|
||||||
17
tests/vault/Journal Note.md
Normal file
17
tests/vault/Journal Note.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Daily Journal
|
||||||
|
|
||||||
|
### 2026-02-25
|
||||||
|
Worked on setting up automated tests for the Obsidian plugin.
|
||||||
|
Made good progress on the mock layer.
|
||||||
|
|
||||||
|
### 2026-02-20
|
||||||
|
Started thinking about test architecture.
|
||||||
|
Reviewed several approaches for testing Electron apps.
|
||||||
|
|
||||||
|
### 2026-01-15
|
||||||
|
This is an old entry that should be filtered out by time window.
|
||||||
|
Did some unrelated work on another project.
|
||||||
|
|
||||||
|
### 2025-12-01
|
||||||
|
Very old entry. Should definitely be filtered out.
|
||||||
|
Working on something completely different.
|
||||||
7
tests/vault/Linked Note A.md
Normal file
7
tests/vault/Linked Note A.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Linked Note A
|
||||||
|
|
||||||
|
This note contains ideas about atomic note-taking.
|
||||||
|
|
||||||
|
Key insight: each note should contain exactly one idea.
|
||||||
|
|
||||||
|
See also [[Deeply Linked/Deep Note]] for more depth.
|
||||||
10
tests/vault/Linked Note B.md
Normal file
10
tests/vault/Linked Note B.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Linked Note B
|
||||||
|
|
||||||
|
This note discusses knowledge management systems.
|
||||||
|
|
||||||
|
The Zettelkasten method emphasizes:
|
||||||
|
- Atomic notes
|
||||||
|
- Linking between ideas
|
||||||
|
- Building a network of knowledge
|
||||||
|
|
||||||
|
Related: [[Linked Note A]]
|
||||||
8
tests/vault/Root Note.md
Normal file
8
tests/vault/Root Note.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
This is the root note for testing linked note discovery.
|
||||||
|
|
||||||
|
It links to [[Linked Note A]] and [[Linked Note B]].
|
||||||
|
|
||||||
|
It also has an embed: ![[Linked Note A]]
|
||||||
|
|
||||||
|
context:
|
||||||
|
Focus on extracting key concepts about testing
|
||||||
6
tests/vault/Special Characters!.md
Normal file
6
tests/vault/Special Characters!.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Note with Special: Characters* in "Title"
|
||||||
|
|
||||||
|
This note tests filename sanitization.
|
||||||
|
It contains characters that are invalid in filenames: \ / : * ? " < > |
|
||||||
|
|
||||||
|
Links to [[Root Note]].
|
||||||
45
tests/version-consistency.test.ts
Normal file
45
tests/version-consistency.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Guard test for the release contract. Obsidian resolves the "latest version"
|
||||||
|
// from manifest.json at the repo root, then downloads the GitHub release whose
|
||||||
|
// tag equals that version. If manifest.json / package.json / versions.json ever
|
||||||
|
// disagree, releases go out inconsistent — which is how the 0.6.0 release shipped
|
||||||
|
// with a stale package.json and a MISSING versions.json, and (plausibly) how the
|
||||||
|
// plugin got auto-removed from the community store. This test makes that drift a
|
||||||
|
// red build instead of a production incident. See docs/PUBLISHING.md.
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const readJson = (rel: string) =>
|
||||||
|
JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
|
||||||
|
const SEMVER = /^\d+\.\d+\.\d+$/; // x.y.z only — no "v" prefix, no pre-release suffix
|
||||||
|
|
||||||
|
describe('version consistency (release contract)', () => {
|
||||||
|
const manifest = readJson('manifest.json');
|
||||||
|
const pkg = readJson('package.json');
|
||||||
|
const versions = readJson('versions.json');
|
||||||
|
|
||||||
|
it('manifest.json version is valid semver (x.y.z, no v prefix)', () => {
|
||||||
|
expect(manifest.version).toMatch(SEMVER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('package.json version matches manifest.json', () => {
|
||||||
|
expect(pkg.version).toBe(manifest.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('versions.json contains the current manifest version', () => {
|
||||||
|
expect(Object.keys(versions)).toContain(manifest.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('versions.json maps the current version to manifest.minAppVersion', () => {
|
||||||
|
expect(versions[manifest.version]).toBe(manifest.minAppVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every versions.json key is valid semver', () => {
|
||||||
|
for (const v of Object.keys(versions)) {
|
||||||
|
expect(v, `versions.json key "${v}"`).toMatch(SEMVER);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,5 +21,9 @@
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"tests/**/*.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"0.6.0": "1.8.9"
|
||||||
|
}
|
||||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// Mock the obsidian module globally so imports don't fail
|
||||||
|
'obsidian': path.resolve(__dirname, 'tests/mocks/obsidian-module.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue