Compare commits

...

39 commits

Author SHA1 Message Date
Chris Lettieri
cc62c4d591 release tooling: scripts/release.sh + version-consistency guard test
Codify the release process so we stop drifting version files and stop
leaving the repo advertising a version with no published release (the
inconsistency that plausibly got the plugin auto-removed from the store).

- scripts/release.sh: one-shot, self-verifying release. Bumps manifest.json,
  package.json AND versions.json in lockstep, runs tests+build, pushes,
  tags (no v prefix), waits for CI, PUBLISHES the draft immediately, then
  verifies root manifest == released asset == tag. Includes a store-listing
  health check.
- tests/version-consistency.test.ts: guard test — fails the build if the
  three version files ever disagree (catches a forgotten versions.json bump
  before tagging).
- docs/PUBLISHING.md: rewritten script-first; adds versions.json (was
  omitted), the publish-immediately rule, and a 'if de-listed from the
  store' runbook (ask #plugin-dev, community.obsidian.md portal, the
  'entry already exists' gotcha).
- CLAUDE.md: quick-summary now points at the script and the three files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:04:53 -04:00
Chris Lettieri
09892d2ef3 chore: sync package.json to 0.6.0; add versions.json
package.json version drifted from manifest; add the missing versions.json
(version -> minAppVersion) that version-bump.mjs and community-store
submission both require. Neither affects Obsidian's update check for the
current install path, but both were inconsistent locations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:48:24 -04:00
Chris Lettieri
cac28bd98d docs: 0.6.0 release notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:34:12 -04:00
Chris Lettieri
6ff04f0f33 chore: bump version to 0.6.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:33:34 -04:00
Chris Lettieri
cbc0c1b471 test: make distill date-range filter test deterministic
The 'filters content by date range' test used fixed fixture dates
(### 2026-02-25) inside a 30-day window, so it passed only while the
real clock was within 30 days of that date and began failing as the
current date advanced. extractContentByDateRange is correct; it uses
new Date() as "now". Pin the system clock with vi.setSystemTime so the
fixed fixture dates evaluate deterministically regardless of the wall
clock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 08:27:40 -04:00
Chris Lettieri
521399efd3 M3b: Augi task-file commands; deprecate Task Dispatch
Add TaskFileService, which writes `status: pending` task files to
OpenAugi/Tasks/ via the Obsidian vault API — no shell-out, no HTTP, no
Node modules, so it works on mobile too. The file format mirrors the
parent repo's templates/task-template.md contract consumed by
task_watcher.py.

New commands (all platforms):
- Augi: Run review pass       -> "run the review pass"
- Augi: Process dashboard     -> "process the dashboard"
- Augi: Distill selection     -> selection/active-note body becomes the
                                 ## Context; distill-lens instruction

Deprecate the legacy Task Dispatch feature (it launches tmux itself,
bypassing the watcher). Kept functional for community users without the
Python watcher; steer new use to task files via docs banner, settings UI
label, and a @deprecated marker. To be removed over a release or two.

Docs: new docs/AGENT_TASKS.md; README + CODEBASE_MAP updated to lead with
the task-file flow. Tests: 18 new, including a watcher-frontmatter-regex
compat check and same-second collision handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:26:30 -04:00
Chris Lettieri
6e9ed65d40 Fix mobile compatibility: dynamic import for task dispatch
TaskDispatchService uses Node.js modules (child_process, fs, path) that
are unavailable on Obsidian mobile. Switch to dynamic import with
Platform.isMobile guard so the plugin loads cleanly on mobile while
keeping full task dispatch functionality on desktop.

- Use `import type` + dynamic `import()` for TaskDispatchService
- Guard task dispatch commands behind Platform.isMobile check
- Lazy-import detectTmuxPath in settings tab
- Bump version to 0.5.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:53:33 -05:00
Chris Lettieri
117b33eb95 Bump version to 0.5.1, re-enable mobile support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:37:09 -05:00
Chris Lettieri
3b73ce2295 Fix @types/node peer dependency for vitest compatibility
Bump @types/node from ^16 to ^20 to satisfy vitest@3.2.4 peer dependency
requirement, fixing CI build failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:54:54 -05:00
Chris Lettieri
41e96f86c9 readme 2026-02-25 17:42:39 -05:00
Chris Lettieri
33ea6eb397 readme 2026-02-25 17:42:03 -05:00
Chris Lettieri
8072306a89 user prompt 2026-02-25 17:34:31 -05:00
Chris Lettieri
850c270434 use mcp to write back 2026-02-25 17:27:49 -05:00
Chris Lettieri
b7d4a3b225 repo setting 2026-02-25 17:27:26 -05:00
Chris Lettieri
9498668076 task dispatch working 2026-02-25 08:18:41 -05:00
Chris Lettieri
a69b52281d testing docs 2026-02-25 08:04:57 -05:00
Chris Lettieri
2d3c5d75ec mock api testing 2026-02-25 08:03:28 -05:00
Chris Lettieri
f23b331902 wait for shell to be ready 2026-02-25 08:03:19 -05:00
Chris Lettieri
8dd3310151 fix prompt command, no script 2026-02-25 07:59:22 -05:00
Chris Lettieri
9580d24974 context pipe from file, shell script launch 2026-02-25 07:54:44 -05:00
Chris Lettieri
00086fe2fd set working dir 2026-02-24 21:25:04 -05:00
Chris Lettieri
3b49d555fe find tmux 2026-02-24 21:20:27 -05:00
Chris Lettieri
55465575aa tmux detect 2026-02-24 21:14:26 -05:00
Chris Lettieri
ae9f90acad task id naming 2026-02-24 21:10:28 -05:00
Chris Lettieri
7660c798b5 task dispatch 2026-02-24 21:05:29 -05:00
Chris Lettieri
f0a8816653 Bump version to 0.4.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:05:03 -05:00
Chris Lettieri
13f1aa4f86 close modal after copy 2026-02-22 16:03:56 -05:00
Chris Lettieri
cb0d4089a4 fix dataview context on table 2026-02-22 16:03:35 -05:00
Chris Lettieri
4123d93c4e copy to clipboard context 2026-02-22 15:45:52 -05:00
Chris Lettieri
5777473171 Add backlink context extraction (0.4.0)
- Bidirectional link traversal: discover notes that link TO discovered notes
- Header section extraction for backlinks (not full content)
- "Include backlinks" toggle (enabled by default)
- Visual "← backlink" badge in selection modal
- Output prefixes: "# Note:" for forward links, "# Backlink:" for backlinks
- Deduplicate snippets when multiple links in same section
2026-01-30 07:34:04 -05:00
Chris Lettieri
250d6bb207 support for backlink context collection 2026-01-29 08:41:25 -05:00
Chris Lettieri
513b007a6a strip tags from context saved 2026-01-14 17:30:26 -05:00
Chris Lettieri
f4c842253a markdown 2026-01-12 09:20:03 -05:00
Chris Lettieri
5b05510041 Sync package.json version with manifest.json (0.3.1) 2026-01-12 09:16:42 -05:00
Chris Lettieri
535373f0cb claude docs 2026-01-12 09:03:32 -05:00
Chris Lettieri
ca59ca6eb9 ordering of new models 2026-01-12 09:03:23 -05:00
Chris Lettieri
b2ce0fc9d8 refresh to get latest open ai models 2026-01-12 08:48:33 -05:00
Chris Lettieri
34639232f0 claude docs 2026-01-12 08:40:22 -05:00
Chris Lettieri
ea97ae7882 hide dataview query from context we capture 2026-01-12 08:40:17 -05:00
52 changed files with 7145 additions and 542 deletions

4
.gitignore vendored
View file

@ -22,3 +22,7 @@ data.json
.DS_Store .DS_Store
*.env *.env
# Test output artifacts
tests/vault-output/
PLAN.md

View file

@ -1,17 +1,14 @@
# OpenAugi Obsidian Plugin - Technical Overview # OpenAugi Obsidian Plugin - Technical Overview
## Project Purpose ## Project Purpose
OpenAugi is an Obsidian plugin that transforms voice notes and linked notes into organized, atomic notes using AI (GPT-4). It helps users process unstructured thoughts into a structured "second brain" by breaking down content into self-contained ideas. 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 ## Architecture Overview
### Core Technologies
- **Language**: TypeScript
- **Build Tool**: esbuild
- **Target Platform**: Obsidian (Electron-based)
- **AI Service**: OpenAI GPT-4.1-2025-04-14
- **Node Version**: 18+
### Project Structure ### Project Structure
``` ```
/ /
@ -52,24 +49,41 @@ OpenAugi is an Obsidian plugin that transforms voice notes and linked notes into
## Development Guidelines ## Development Guidelines
### Build Commands ### 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 ```bash
# Development build with hot reload # Development build with hot reload
npm run dev npm run dev
# Production build # Production build (includes typecheck)
npm run build npm run build
# Type checking
npm run typecheck
``` ```
There is no standalone `typecheck` script — `npm run build` runs `tsc -noEmit -skipLibCheck` before bundling.
### Code Standards ### Code Standards
- TypeScript with strict mode enabled - TypeScript with strict mode enabled
- ESLint configuration for code quality - ESLint configuration for code quality
- No external runtime dependencies (only Obsidian API) - No external runtime dependencies (only Obsidian API)
### Testing ### Testing
Currently no automated tests. Manual testing through Obsidian's developer console.
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 ## API Integration
@ -124,9 +138,19 @@ Currently no automated tests. Manual testing through Obsidian's developer consol
- Enable verbose logging in development - Enable verbose logging in development
### Publishing ### Publishing
1. Update version in `manifest.json` and `package.json` See [docs/PUBLISHING.md](docs/PUBLISHING.md) for the complete release process,
2. Build production bundle: `npm run build` including community-store listing health and how to relist if de-listed.
3. Create release with `main.js`, `manifest.json`, and `styles.css`
**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 ## Important Considerations
@ -136,9 +160,8 @@ Currently no automated tests. Manual testing through Obsidian's developer consol
- Maintain backwards compatibility with existing notes - Maintain backwards compatibility with existing notes
- Test with various note structures and edge cases - Test with various note structures and edge cases
## Future Enhancements # Testing
- Support for additional AI models
- Batch processing optimization My local testing vault is in: /Users/chris/zk-for-testing
- Enhanced Dataview query support
- Customizable output templates Add any notes to /Users/chris/Documents/DEV-TESTING/Test to capture edge cases when relevant.
- Integration with other Obsidian plugins

517
README.md
View file

@ -1,443 +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.
**✨ NEW: Unified Context Gathering System** - Intelligently discover notes (up to 3 levels deep), review with checkboxes, and choose to distill into atomic notes OR publish as a polished blog post. One flexible system, multiple outputs. [Read the full guide →](CONTEXT_GATHERING.md) 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.)
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. Join the [Discord](https://discord.gg/d26BVBrnRP). Parent [repo](https://github.com/bitsofchris/openaugi).
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.
Let Open Augi process and organize your thoughts so you can go further, faster.
Join the [Discord](https://discord.gg/d26BVBrnRP).
Parent [repo](https://github.com/bitsofchris/openaugi).
## Setup
1. Install the plugin from the Obsidian Community Plugins or manually
2. Go to Settings → OpenAugi
3. Enter your OpenAI API key
4. Set your preferred folders for summaries and atomic notes
5. Save settings
## Main Commands
OpenAugi offers commands for different workflows - from voice transcripts to unified context gathering:
### 1. Parse Transcript
This command processes a voice transcript or any text and organizes it into atomic notes, tasks, and a summary.
**Usage:**
1. Import your voice transcript into Obsidian as a markdown file
2. Open the transcript file
3. Hit `CMD+P` (or `CTRL+P` on Windows/Linux) to open the command palette
4. Run `OpenAugi: Parse Transcript`
The plugin will:
- Break down your transcript into separate atomic notes (1 idea per note)
- Extract any tasks mentioned
- Create a summary note with links to the atomic notes
- Link related concepts automatically
**Special Commands in Transcripts:**
Using "auggie" as a special token during your voice note can improve accuracy of the agentic behaviors.
- Say "auggie this is a task" to explicitly mark something as a task
- Say "auggie make a new note about X" to create a specific note
- Say "auggie summarize this" to get a summary of recent thoughts
- Say "auggie this is a journal entry" to format text as a journal entry
--- ---
## 🆕 Unified Context Gathering Commands ## What It Does
**NEW: Flexible, powerful context gathering with link traversal, checkboxes, and dual output modes (distill OR publish).** ### 1. Context Engineering
These commands use OpenAugi's unified context gathering system - a three-stage pipeline that gives you full control: Gather precisely the right context from your vault — not too much, not too little.
1. **Configure** - Choose source (linked notes or recent activity), depth, filters - **Link traversal** — Follow wikilinks up to 3 levels deep (breadth-first)
2. **Review** - See discovered notes in checkbox list, toggle individual notes on/off - **Backlink discovery** — Find notes that reference your notes, not just notes you link to
3. **Process** - Choose to distill into atomic notes OR publish as a single blog post - **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
[📖 Read the complete Context Gathering Guide](CONTEXT_GATHERING.md) ### 2. Agent Tasks
### Process Notes 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.
**Best for:** Processing curated sets of linked notes, creating blog posts from research, topic-focused synthesis - **Augi: Run review pass** — Route new blocks, refresh views, surface Dashboard nominations
- **Augi: Process dashboard** — Execute your nomination answers only
- **Augi: Distill selection** — Distill the current selection (or active note) through the distill lens
- **File-based trigger** — Pure vault API, no shell or HTTP, works on Obsidian Mobile with a synced vault
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
**How it works:**
1. Open any note with links to content you want to process 1. Open any note with links to content you want to process
2. Run `OpenAugi: Process notes` 2. Run **Process notes**
3. Configure discovery: 3. Configure depth, filters, and character limits
- **Link depth**: 1-3 levels (breadth-first traversal) 4. Review discovered notes with checkboxes
- **Max characters**: Default 100k (prevents overflow) 5. Choose: **Distill** (atomic notes), **Publish** (blog post), or **Save** (raw context)
- **Folder exclusions**: Skip Templates, Archive, etc.
- **Journal filtering**: Extract only recent sections from journal notes
4. Review discovered notes in checkbox list
5. See preview with stats (notes, characters, tokens)
6. Choose output: **Distill to atomic notes** OR **Publish as single post**
7. Optionally select custom prompt lens
**Outputs:**
- **Distill**: Atomic notes in `OpenAugi/Notes/`, summary in `OpenAugi/Summaries/`
- **Publish**: Single blog post in `OpenAugi/Published/` with frontmatter
**Example use case:**
```markdown
# Q4 2024 Learning.md
Links to process:
- [[Book: Building a Second Brain]]
- [[Course: Knowledge Management]]
- [[Project Insights]]
Run "Process notes" → Depth 2 → Publish as blog post
→ Get: "What I Learned About Knowledge Management - Published 2025-10-13.md"
```
### Process Recent Activity
**Best for:** Weekly reviews, activity summaries, periodic reflection posts
**How it works:**
1. Run `OpenAugi: Process recent activity`
2. Configure time window:
- **Last N days** (quick: 1, 7, 30 days)
- **Specific date range** (exact: 2025-01-01 to 2025-01-31)
3. Same review → preview → process flow as above
**Example use case:**
```
Weekly review every Sunday:
1. Run "Process recent activity"
2. Set to "Last 7 days"
3. Exclude "Archive", "Templates"
4. Enable "Recent sections only" for journal filtering
5. Uncheck meeting notes, keep insights
6. Publish as blog post
→ Get: Weekly reflection ready for blog
```
### Save Context
**Best for:** Gathering research without AI processing, creating reference documents, debugging context
**How it works:**
1. Same configuration and review flow
2. But skips AI processing entirely
3. Saves raw aggregated content to `OpenAugi/Context YYYY-MM-DD.md`
**Example use case:**
```
Gather all project documentation:
1. Create note with links to all specs, decisions, notes
2. Run "Save context" → Depth 3
3. Get single markdown file with everything aggregated
→ Use for offline reading, sharing, manual synthesis
```
### Key Features
**Link depth traversal** - Go up to 3 levels deep (breadth-first search)
**Checkbox review** - Toggle individual notes before processing
**Character limits** - Prevents token overflow (default: 100k)
**Dual output modes** - Distill (atomic notes) OR Publish (blog post)
**Journal filtering** - Extract only recent sections from journal notes
**Raw context saving** - Skip AI, just aggregate content
**Custom prompts** - Use lenses for focused processing
--- ---
## Legacy Commands ## Commands
These commands still work but are now superseded by the unified context gathering system above. | 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 |
### 2. Distill Linked Notes (Legacy) ---
This command analyzes a set of linked notes and synthesizes them into a coherent set of atomic notes. ## Agent Tasks
**Usage:** 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.
1. Create a root note that links to other notes you want to distill
- Links can be regular Obsidian links like `[[Note Name]]`
- You can also use dataview queries (if the dataview plugin is installed)
2. Open this root note
3. Hit `CMD+P` (or `CTRL+P` on Windows/Linux) to open the command palette
4. Run `OpenAugi: Distill Linked Notes`
5. **NEW**: Select a custom prompt "lens" or use the default prompt
The plugin will: See [Agent Tasks docs](docs/AGENT_TASKS.md) for the full reference, including the task-file format.
- Show a prompt selection modal where you can choose a processing lens
- Gather all linked notes
- Analyze their content together using your selected lens
- Create new atomic notes that synthesize and organize the information
- Generate a summary that connects the key concepts
- Extract any actionable tasks found across the notes
## Custom Prompt Lenses **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
**NEW**: OpenAugi now supports custom prompt templates that act as "lenses" to focus processing on specific aspects of your notes. ### Task Dispatch (deprecated)
### How Custom Prompts Work 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).
Custom prompts allow you to guide OpenAugi's AI processing with specific perspectives or focus areas. When you run distillation commands, you can select from your custom prompts to process notes through different "lenses" - extracting different types of insights from the same content. ---
### Using Custom Prompts ## Context Gathering
1. When you run "Distill Linked Notes", you'll see a prompt selection modal The context gathering pipeline is a three-stage flow:
2. Choose from any prompt in your prompts folder (default: `OpenAugi/Prompts`)
3. The selected prompt replaces the default processing instructions while keeping the structured output format
4. Or select "Use default prompt" for general-purpose processing
### Understanding the Prompt System 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
#### Default Prompt Features:
When no custom prompt is selected, OpenAugi uses its built-in default prompt that: - Breadth-first link traversal up to 3 levels
- Acts as an "expert knowledge curator" - Bidirectional: forward links + backlinks at each depth
- Creates atomic notes focusing on distinct concepts and ideas - Journal-style date filtering
- Deduplicates and merges overlapping information - Dataview query support
- Extracts genuinely actionable tasks - Custom prompt lenses
- Generates comprehensive summaries highlighting connections
#### How Custom Prompts Replace the Default ---
When you select a custom prompt:
1. **Your custom prompt replaces** the default instruction section
2. **The system always adds**:
- Any custom context from your notes (if present)
- JSON output structure requirements
- The actual note content to process
This means your custom prompt changes HOW the content is analyzed, but not the OUTPUT format. ## Configuration
### Creating Your Own Prompts Settings are in **Settings → OpenAugi**.
To create a custom prompt: **Core:**
- OpenAI API key (required for AI processing)
- Output folders: Summaries, Notes, Published, Prompts
1. Create a new markdown file in your prompts folder **Context Gathering:**
2. Write your instructions following this structure: - Default link depth (1-3)
- Start with a brief description of the lens perspective - Max characters (default: 100k)
- Include sections for: - Include backlinks (default: on)
- Creating atomic notes (with your specific focus) - Journal section filtering
- Extracting tasks (if relevant to your lens)
- Creating a summary (with your desired emphasis)
3. Save the file with a descriptive name (e.g., "Technical Documentation.md")
#### Template Structure **Task Dispatch (deprecated):**
```markdown - Terminal app (iTerm2 or Terminal.app)
You are an expert [your role] helping users [your purpose]. - tmux path (auto-detected or manual)
- Default working directory
- Repository path mappings
- Default agent CLI
- Max context characters (default: 200k)
# Instructions **Recent Activity:**
[Your specific focus and approach] - Days to look back (default: 7)
- Date header format
- Folder exclusions
### 1. Create Atomic Notes ---
- [Your specific criteria for what makes a good atomic note]
- [How to identify relevant concepts for your lens]
- [Any special formatting or emphasis]
### 2. Extract Tasks
- [What counts as a task in your context]
- [How to format and prioritize tasks]
### 3. Create a Summary
- [What to emphasize in the summary]
- [How to structure the summary for your use case]
```
### Example Prompts Included
- **Research Focus**: Extracts academic insights, methodologies, and research questions
- **Project Management**: Focuses on deliverables, timelines, and actionable tasks
- **Personal Reflection**: Captures personal insights, emotions, and growth moments
### Tips for Writing Effective Prompts
- Be specific about what types of information to extract
- Maintain the structure of atomic notes, tasks, and summary
- Use clear, directive language
- Keep the focus narrow for best results
- Remember that Obsidian backlinks should still be used to connect ideas
- Test your prompt with various content types to ensure consistency
### Important Notes
- The JSON output structure is always preserved regardless of the prompt
- Custom prompts only affect the instructional content, not the response format
- Test your prompts with different types of notes to ensure they work as expected
- Custom prompts work in addition to (not instead of) any custom context in your notes
- If no prompt files exist in your prompts folder, only the default option will be available
## Custom Context
You can guide how OpenAugi processes your content by adding a custom context section to your notes:
```context:
Only extract items related to project goals and key decisions.
Focus on extracting the "why" behind each decision.
```
**For Transcript Parsing:**
Add this section to your transcript file before processing.
**For Distillation:**
Add this section to your root note before running the distill command.
**Example Use Cases:**
- `Focus only on extracting research findings and methodology details`
- `Extract only content related to project risks and mitigation strategies`
- `Identify and highlight conflicting viewpoints across these notes`
- `Focus on extracting personal insights and reflections, ignoring factual content`
The custom context allows you to narrow the focus of processing to extract specific types of information relevant to your current needs.
## Use Cases
### Daily/Weekly Reviews
Use "Process Recent Activity" to automatically summarize your work:
- Set to 1 day for daily reviews
- Set to 7 days for weekly reviews
- Automatically captures all your recent thoughts and work
- Perfect for identifying patterns and progress
### Project Summaries
Use "Distill Linked Notes" with a project hub note:
- Create a note that links to all project-related notes
- Run distillation to get a comprehensive project overview
- Extract all tasks and decisions across the project
### Research Synthesis
Combine both commands for research workflows:
- Use "Process Recent Activity" to review recent research notes
- Use "Distill Linked Notes" on topic-specific collections
- Add custom context to focus on findings, methodologies, or insights
### Journal Processing
Take advantage of journal-style note support:
- Keep daily journal entries with date headers
- Use "Process Recent Activity" to extract recent insights
- Only relevant date sections are processed, keeping context focused
## Requirements ## Requirements
Note: this requires an OpenAI API key to work.
Your content is sent directly to OpenAI for processing using the best model for this task. The cost to use this plugin depends on the API credits consumed. For me ~5 minutes of voice note is about 2-3 cents of processing. - **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)
## Configuration Settings ---
OpenAugi provides several configuration options in Settings → OpenAugi: ## Get Involved
### Basic Settings OpenAugi is about augmented intelligence — using AI to help you think faster and do more, not to think for you.
- **OpenAI API Key**: Your API key for processing (required)
- **Summaries Folder**: Where summary files are saved (default: `OpenAugi/Summaries`)
- **Notes Folder**: Where atomic notes are saved (default: `OpenAugi/Notes`)
- **Prompts Folder**: Where custom prompt templates are stored (default: `OpenAugi/Prompts`)
- **Published Folder**: Where published blog posts are saved (default: `OpenAugi/Published`)
- **Use Dataview**: Enable processing of dataview queries in distillation
### Context Gathering Settings 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).
Configure defaults for the unified context gathering system:
- **Default Link Depth**: Initial depth for link traversal (1-3, default: 1)
- **Default Max Characters**: Character limit before stopping discovery (default: 100,000)
- **Filter Recent Sections by Default**: Automatically enable journal section filtering (default: On)
### Recent Activity Settings
Configure defaults for recent activity processing:
- **Default Days to Look Back**: How many days of activity to include by default (default: 7)
- **Filter Journal Sections**: When enabled, only includes recent sections from journal-style notes
- **Date Header Format**: Customize the format for date headers in journal notes (default: `### YYYY-MM-DD`)
- Examples: `## DD/MM/YYYY`, `#### YYYY.MM.DD`, `### [YYYY-MM-DD]`
- Must include YYYY (year), MM (month), and DD (day) placeholders
- **Exclude Folders**: Comma-separated list of folders to skip (default: `Templates, Archive, OpenAugi`)
### Advanced Settings
- **Enable Distill Logging**: Logs the full context sent to AI for debugging (saved to `OpenAugi/Logs`)
## Journal-Style Notes Support
OpenAugi has special support for journal-style notes that use date headers. When processing recent activity:
1. **Automatic Detection**: Notes with date headers matching your configured format are automatically detected
2. **Smart Filtering**: Only sections with dates within your specified time window are included
3. **Flexible Formats**: Configure your preferred date header format in settings
Example journal note:
```markdown
# My Journal
### 2024-01-20
Today's thoughts and activities...
### 2024-01-19
Yesterday's reflections...
### 2024-01-10
Older content that may be filtered out...
```
When using "Process Recent Activity" with a 7-day window, only the recent sections would be processed.
## Output Structure
OpenAugi creates organized outputs for better note management:
### Summary Files
Placed in your designated summary folder (`OpenAugi/Summaries` by default):
- **Transcript summaries**: `[original-name] - summary.md`
- **Distilled notes**: `[root-note-name] - distilled.md`
- **Recent activity**: `Recent Activity YYYY-MM-DD - [first-note-title].md`
Each summary contains:
- A concise overview of the processed content
- Links to all generated atomic notes
- Extracted tasks with context
- Source note references (for distillations)
### Atomic Notes
Organized in session-specific folders within your notes folder (`OpenAugi/Notes` by default):
- **Transcript sessions**: `Transcript YYYY-MM-DD HH-mm-ss/`
- **Distill sessions**: `Distill [RootNoteName] YYYY-MM-DD HH-mm-ss/`
- **Recent activity sessions**: `Recent Activity YYYY-MM-DD HH-mm-ss/`
Each atomic note contains:
- A single, self-contained idea
- Relevant context and supporting details
- Backlinks to related concepts
- Clear, descriptive titles
### Collection Notes
Saved note selections for future processing (`OpenAugi/Collections` by default):
- **Format**: `Recent Activity YYYY-MM-DD HH-mm-ss.md`
- Contains checkbox lists of selected notes
- Preserves selection criteria and configuration
- Can be processed later using "Distill Linked Notes"
## Advanced Features
### Checkbox-Based Collections
OpenAugi recognizes and processes checkbox-style note collections:
```markdown
- [x] [[Note to include]]
- [ ] [[Note to exclude]]
- [x] [[Another included note]]
```
When running "Distill Linked Notes" on such a collection, only checked items are processed.
### Session Folders
All atomic notes from a single processing session are kept together in timestamped folders, making it easy to:
- Review all outputs from a specific session
- Move or archive related notes together
- Track the evolution of your ideas over time
- Avoid mixing notes from different contexts
# Get involved, let's build augmented intelligence
This plugin is meant to solve my own problems around using Obsidian as my second brain and AI for organizing my notes.
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), 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. Read more at the [parent repo](https://github.com/bitsofchris/openaugi).

94
docs/AGENT_TASKS.md Normal file
View 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
View 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
View 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 13 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
View 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
View 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`.

View 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.

View file

@ -1,9 +1,9 @@
{ {
"id": "openaugi", "id": "openaugi",
"name": "OpenAugi", "name": "OpenAugi",
"version": "0.3.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

1637
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,24 +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"
} }
} }

114
scripts/release.sh Executable file
View 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"

View file

@ -1,17 +1,23 @@
import { Plugin, Notice, TFile } 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 { DistillService } from './services/distill-service';
import { ContextGatheringService } from './services/context-gathering-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 { SessionListModal } from './ui/session-list-modal';
import { sanitizeFilename, createFileWithCollisionHandling } from './utils/filename-utils'; import { sanitizeFilename, createFileWithCollisionHandling } from './utils/filename-utils';
import { PromptSelectionModal, PromptSelectionConfig } from './ui/prompt-selection-modal'; import { PromptSelectionModal, PromptSelectionConfig } from './ui/prompt-selection-modal';
import { ContextGatheringModal } from './ui/context-gathering-modal'; import { ContextGatheringModal } from './ui/context-gathering-modal';
import { ContextSelectionModal } from './ui/context-selection-modal'; import { ContextSelectionModal } from './ui/context-selection-modal';
import { ContextPreviewModal } from './ui/context-preview-modal'; import { ContextPreviewModal } from './ui/context-preview-modal';
import { CommandOptions, ContextGatheringConfig, GatheredContext, DiscoveredNote } from './types/context'; import { CommandOptions, ContextGatheringConfig, GatheredContext, DiscoveredNote } from './types/context';
import { TaskSession } from './types/task-dispatch';
/** /**
* A simple tokeinzer to estimate the number of tokens * A simple tokeinzer to estimate the number of tokens
@ -29,6 +35,8 @@ export default class OpenAugiPlugin extends Plugin {
fileService: FileService; fileService: FileService;
distillService: DistillService; distillService: DistillService;
contextGatheringService: ContextGatheringService; contextGatheringService: ContextGatheringService;
taskFileService: TaskFileService;
taskDispatchService: TaskDispatchService | null;
loadingIndicator: LoadingIndicator; loadingIndicator: LoadingIndicator;
async onload() { async onload() {
@ -112,6 +120,97 @@ export default class OpenAugiPlugin extends Plugin {
} }
}); });
// 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));
} }
@ -144,6 +243,67 @@ export default class OpenAugiPlugin extends Plugin {
this.distillService, this.distillService,
this.settings 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));
} }
/** /**
@ -316,7 +476,31 @@ export default class OpenAugiPlugin extends Plugin {
} }
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() {
@ -404,10 +588,9 @@ export default class OpenAugiPlugin extends Plugin {
this.loadingIndicator?.show('Aggregating content...'); this.loadingIndicator?.show('Aggregating content...');
// Re-aggregate with only selected notes // Re-aggregate with only selected notes (preserving backlink info)
const files = selectedNotes.map(n => n.file); const aggregated = await this.contextGatheringService.aggregateContent(
const aggregated = await this.distillService.aggregateContent( selectedNotes,
files,
context.config.filterRecentSectionsOnly ? context.config.journalSectionDays : undefined context.config.filterRecentSectionsOnly ? context.config.journalSectionDays : undefined
); );
@ -652,4 +835,5 @@ export default class OpenAugiPlugin extends Plugin {
throw error; throw error;
} }
} }
} }

View file

@ -34,11 +34,7 @@ export class ContextGatheringService {
if (!config.rootNote) { if (!config.rootNote) {
throw new Error('Root note required for linked-notes mode'); throw new Error('Root note required for linked-notes mode');
} }
discoveredNotes = await this.discoverLinkedNotes( discoveredNotes = await this.discoverLinkedNotes(config);
config.rootNote,
config.linkDepth,
config.maxCharacters
);
} else { } else {
if (!config.timeWindow) { if (!config.timeWindow) {
throw new Error('Time window required for recent-activity mode'); throw new Error('Time window required for recent-activity mode');
@ -72,18 +68,22 @@ export class ContextGatheringService {
/** /**
* Discover notes by traversing links up to specified depth using BFS * Discover notes by traversing links up to specified depth using BFS
* @param rootNote The starting note * Traverses both forward links and backlinks at each level
* @param maxDepth Maximum depth to traverse (1-3) * @param config The context gathering configuration
* @param maxCharacters Stop when this many characters have been gathered
* @returns Array of discovered notes with metadata * @returns Array of discovered notes with metadata
*/ */
private async discoverLinkedNotes( private async discoverLinkedNotes(
rootNote: TFile, config: ContextGatheringConfig
maxDepth: number,
maxCharacters: number
): Promise<DiscoveredNote[]> { ): 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 discovered = new Map<string, DiscoveredNote>();
const queue: Array<{ file: TFile; depth: number; via: string }> = []; 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 // Start with root note
const rootContent = await this.app.vault.read(rootNote); const rootContent = await this.app.vault.read(rootNote);
@ -92,29 +92,66 @@ export class ContextGatheringService {
depth: 0, depth: 0,
discoveredVia: 'root', discoveredVia: 'root',
estimatedChars: rootContent.length, estimatedChars: rootContent.length,
included: true included: true,
isBacklink: false
}); });
// Get direct links from root // Get direct forward links from root
const rootLinks = await this.distillService.getLinkedNotes(rootNote); const rootLinks = await this.distillService.getLinkedNotes(rootNote);
for (const link of rootLinks) { for (const link of rootLinks) {
queue.push({ file: link, depth: 1, via: rootNote.basename }); 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 // BFS traversal
let totalChars = rootContent.length; let totalChars = rootContent.length;
while (queue.length > 0) { while (queue.length > 0) {
const { file, depth, via } = queue.shift()!; const { file, depth, via, isBacklink } = queue.shift()!;
// Skip if already discovered // Skip if already discovered
if (discovered.has(file.path)) { if (discovered.has(file.path)) {
continue; continue;
} }
// Read content to check size let chars: number;
const content = await this.app.vault.read(file); let snippetContent: string | undefined;
const chars = content.length;
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 // Check if adding this note would exceed character limit
if (totalChars + chars > maxCharacters) { if (totalChars + chars > maxCharacters) {
@ -122,9 +159,11 @@ export class ContextGatheringService {
discovered.set(file.path, { discovered.set(file.path, {
file, file,
depth, depth,
discoveredVia: `linked from [[${via}]]`, discoveredVia: isBacklink ? `backlink from [[${via}]]` : `linked from [[${via}]]`,
estimatedChars: chars, estimatedChars: chars,
included: false // Excluded due to size limit included: false, // Excluded due to size limit
isBacklink,
backlinkSnippet: isBacklink ? snippetContent : undefined
}); });
continue; continue;
} }
@ -133,21 +172,39 @@ export class ContextGatheringService {
discovered.set(file.path, { discovered.set(file.path, {
file, file,
depth, depth,
discoveredVia: `linked from [[${via}]]`, discoveredVia: isBacklink ? `backlink from [[${via}]]` : `linked from [[${via}]]`,
estimatedChars: chars, estimatedChars: chars,
included: true included: true,
isBacklink,
backlinkSnippet: isBacklink ? snippetContent : undefined
}); });
console.log(`[OpenAugi] Discovered: ${file.basename} | isBacklink: ${isBacklink} | via: ${via} | depth: ${depth}`);
totalChars += chars; totalChars += chars;
// If we haven't reached max depth, get links from this note // If we haven't reached max depth, get links from this note
if (depth < maxDepth) { if (depth < maxDepth) {
try { try {
// Get forward links
const linkedNotes = await this.distillService.getLinkedNotes(file); const linkedNotes = await this.distillService.getLinkedNotes(file);
for (const linkedNote of linkedNotes) { for (const linkedNote of linkedNotes) {
// Don't queue if already discovered // Don't queue if already queued or discovered
if (!discovered.has(linkedNote.path)) { if (!queued.has(linkedNote.path) && !discovered.has(linkedNote.path)) {
queue.push({ file: linkedNote, depth: depth + 1, via: file.basename }); 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) { } catch (error) {
@ -190,7 +247,8 @@ export class ContextGatheringService {
depth: 0, // No depth concept for recent activity depth: 0, // No depth concept for recent activity
discoveredVia: 'recent activity', discoveredVia: 'recent activity',
estimatedChars: content.length, estimatedChars: content.length,
included: true included: true,
isBacklink: false
}); });
} }
@ -223,17 +281,45 @@ export class ContextGatheringService {
/** /**
* Aggregate content from notes * 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 notes Notes to aggregate
* @param filterJournalDays Optional days back for journal section filtering * @param filterJournalDays Optional days back for journal section filtering
* @returns Aggregated content and source note names * @returns Aggregated content and source note names
*/ */
private async aggregateContent( async aggregateContent(
notes: DiscoveredNote[], notes: DiscoveredNote[],
filterJournalDays?: number filterJournalDays?: number
): Promise<{ content: string; sourceNotes: string[] }> { ): Promise<{ content: string; sourceNotes: string[] }> {
// Reuse existing aggregateContent from DistillService // Separate forward links and backlinks
const files = notes.map(n => n.file); const forwardLinks = notes.filter(n => !n.isBacklink);
return await this.distillService.aggregateContent(files, filterJournalDays); 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]
};
} }
/** /**

View file

@ -173,6 +173,27 @@ ${content}
return content.includes("```dataview"); 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 * Extract dataview queries from content
* @param content The content to extract queries from * @param content The content to extract queries from
@ -204,7 +225,7 @@ ${content}
// @ts-ignore - Dataview API is not typed // @ts-ignore - Dataview API is not typed
const dataviewPlugin = this.app.plugins.plugins["dataview"]; const dataviewPlugin = this.app.plugins.plugins["dataview"];
if (!dataviewPlugin?.api) { if (!dataviewPlugin?.api) {
console.log("Dataview plugin not available"); console.log("[OpenAugi] Dataview plugin not available");
return []; return [];
} }
@ -212,38 +233,36 @@ ${content}
const files: TFile[] = []; const files: TFile[] = [];
try { try {
// Use dataview API to execute query // Use the structured query API (dvApi.query) which returns Link objects
const queryResult = await dvApi.queryMarkdown(query, sourcePath); // 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) { if (queryResult.successful && queryResult.value) {
// Process based on query type if (queryResult.value.type === "list") {
if (typeof queryResult.value === "object" && queryResult.value !== null && queryResult.value.type === "list") {
// Handle LIST query result as object
for (const item of queryResult.value.values) { for (const item of queryResult.value.values) {
if (item.type === "file" && item.path) { if (item && typeof item === "object" && "path" in item && typeof item.path === "string") {
const file = this.app.vault.getAbstractFileByPath(item.path); const file = this.app.vault.getAbstractFileByPath(item.path);
if (file instanceof TFile) { if (file instanceof TFile) {
files.push(file); files.push(file);
} }
} }
} }
} else if (typeof queryResult.value === "object" && queryResult.value !== null && queryResult.value.type === "table") { } else if (queryResult.value.type === "table") {
// Handle TABLE query result as object
for (const row of queryResult.value.values) { for (const row of queryResult.value.values) {
if (row[0]?.path) { const firstCol = row[0];
const file = this.app.vault.getAbstractFileByPath(row[0].path); if (firstCol && typeof firstCol === "object" && "path" in firstCol && typeof firstCol.path === "string") {
const file = this.app.vault.getAbstractFileByPath(firstCol.path);
if (file instanceof TFile) { if (file instanceof TFile) {
files.push(file); files.push(file);
} }
} }
} }
} else if (typeof queryResult.value === "string") {
// Extract all kinds of links that might appear in dataview output
this.extractLinksFromString(queryResult.value, sourcePath, files);
} }
} }
} catch (error) { } catch (error) {
console.error("Error executing dataview query:", error); console.error("[OpenAugi] Error executing dataview query:", error);
} }
return files; return files;
@ -456,6 +475,109 @@ ${content}
return uniqueFiles; 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 * Convert date format string to regex pattern
* @param format The date format string (e.g., "### YYYY-MM-DD") * @param format The date format string (e.g., "### YYYY-MM-DD")
@ -671,6 +793,9 @@ ${content}
let content = await this.app.vault.read(file); let content = await this.app.vault.read(file);
// Strip dataview queries from output content
content = this.stripDataviewQueries(content);
// Apply time filtering if specified // Apply time filtering if specified
if (timeWindowDays !== undefined && timeWindowDays > 0) { if (timeWindowDays !== undefined && timeWindowDays > 0) {
content = this.extractContentByDateRange(content, timeWindowDays); content = this.extractContentByDateRange(content, timeWindowDays);

View file

@ -22,6 +22,90 @@ export class OpenAIService {
this.model = model; 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 * Extract custom context instructions from content if they exist
* @param content The content to extract the context from * @param content The content to extract the context from

View 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, "'\\''")}'`;
}
}

View 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
);
}
}

View file

@ -29,6 +29,10 @@ export interface ContextGatheringConfig {
// For recent activity: how many days back to filter journal sections // For recent activity: how many days back to filter journal sections
journalSectionDays?: number; 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
} }
/** /**
@ -37,9 +41,14 @@ export interface ContextGatheringConfig {
export interface DiscoveredNote { export interface DiscoveredNote {
file: TFile; file: TFile;
depth: number; // 0 = root, 1 = direct link, 2 = second level, etc. depth: number; // 0 = root, 1 = direct link, 2 = second level, etc.
discoveredVia: string; // "root" | "linked from [[Note]]" | "recent activity" discoveredVia: string; // "root" | "linked from [[Note]]" | "backlink from [[Note]]" | "recent activity"
estimatedChars: number; estimatedChars: number;
included: boolean; // User can toggle in checkbox modal 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
} }
/** /**

View file

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

View file

@ -1,4 +1,5 @@
import { App } from 'obsidian'; import { App } from 'obsidian';
import { TaskDispatchSettings } from './task-dispatch';
export interface RecentActivitySettings { export interface RecentActivitySettings {
daysBack: number; daysBack: number;
@ -11,12 +12,15 @@ export interface ContextGatheringDefaults {
linkDepth: number; linkDepth: number;
maxCharacters: number; maxCharacters: number;
filterRecentSectionsOnly: boolean; 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; defaultModel: string;
customModelOverride: string; customModelOverride: string;
cachedModels: string[];
summaryFolder: string; summaryFolder: string;
notesFolder: string; notesFolder: string;
promptsFolder: string; promptsFolder: string;
@ -25,12 +29,14 @@ export interface OpenAugiSettings {
enableDistillLogging: boolean; enableDistillLogging: boolean;
recentActivityDefaults: RecentActivitySettings; recentActivityDefaults: RecentActivitySettings;
contextGatheringDefaults: ContextGatheringDefaults; contextGatheringDefaults: ContextGatheringDefaults;
taskDispatch: TaskDispatchSettings;
} }
export const DEFAULT_SETTINGS: OpenAugiSettings = { export const DEFAULT_SETTINGS: OpenAugiSettings = {
apiKey: '', apiKey: '',
defaultModel: 'gpt-5', defaultModel: 'gpt-5.2',
customModelOverride: '', 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', promptsFolder: 'OpenAugi/Prompts',
@ -46,6 +52,25 @@ export const DEFAULT_SETTINGS: OpenAugiSettings = {
contextGatheringDefaults: { contextGatheringDefaults: {
linkDepth: 1, linkDepth: 1,
maxCharacters: 100000, maxCharacters: 100000,
filterRecentSectionsOnly: true 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: []
} }
}; };

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

View file

@ -33,7 +33,9 @@ export class ContextGatheringModal extends Modal {
excludeFolders: settings.recentActivityDefaults.excludeFolders, excludeFolders: settings.recentActivityDefaults.excludeFolders,
filterRecentSectionsOnly: settings.contextGatheringDefaults.filterRecentSectionsOnly, filterRecentSectionsOnly: settings.contextGatheringDefaults.filterRecentSectionsOnly,
dateHeaderFormat: settings.recentActivityDefaults.dateHeaderFormat, dateHeaderFormat: settings.recentActivityDefaults.dateHeaderFormat,
journalSectionDays: settings.recentActivityDefaults.daysBack journalSectionDays: settings.recentActivityDefaults.daysBack,
includeBacklinks: settings.contextGatheringDefaults.includeBacklinks,
backlinkContextLines: settings.contextGatheringDefaults.backlinkContextLines
}; };
} }
@ -193,6 +195,69 @@ export class ContextGatheringModal extends Modal {
} }
}); });
}); });
// 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() { private renderRecentActivitySettings() {

View file

@ -1,4 +1,4 @@
import { App, Modal, Setting } from 'obsidian'; import { App, Modal, Notice, Setting } from 'obsidian';
import { GatheredContext } from '../types/context'; import { GatheredContext } from '../types/context';
export class ContextPreviewModal extends Modal { export class ContextPreviewModal extends Modal {
@ -129,6 +129,15 @@ export class ContextPreviewModal extends Modal {
.setButtonText('Back') .setButtonText('Back')
.onClick(() => this.close()) .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 .addButton(button => button
.setButtonText('Save Raw Context') .setButtonText('Save Raw Context')
.setTooltip('Save the gathered context as a note without AI processing') .setTooltip('Save the gathered context as a note without AI processing')

View file

@ -187,15 +187,33 @@ export class ContextSelectionModal extends Modal {
contentDiv.style.flexDirection = 'column'; contentDiv.style.flexDirection = 'column';
contentDiv.style.gap = '2px'; contentDiv.style.gap = '2px';
const titleEl = contentDiv.createEl('span'); 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.setText(note.file.basename);
titleEl.style.fontWeight = '500'; 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'); const metaEl = contentDiv.createEl('span');
metaEl.style.fontSize = '0.85em'; metaEl.style.fontSize = '0.85em';
metaEl.style.color = 'var(--text-muted)'; metaEl.style.color = 'var(--text-muted)';
const sizeKb = (note.estimatedChars / 1000).toFixed(1); const sizeKb = (note.estimatedChars / 1000).toFixed(1);
metaEl.setText(`${sizeKb}k chars · ${note.discoveredVia}`); const contentType = note.isBacklink ? 'snippet' : 'full note';
metaEl.setText(`${sizeKb}k chars (${contentType}) · ${note.discoveredVia}`);
}); });
}); });

View 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`;
}
}

View file

@ -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;
@ -35,17 +39,77 @@ export class OpenAugiSettingTab extends PluginSettingTab {
}) })
); );
new Setting(containerEl) let modelDropdown: DropdownComponent;
const modelSetting = new Setting(containerEl)
.setName('OpenAI Model') .setName('OpenAI Model')
.setDesc('Select the OpenAI model to use for processing') .setDesc('Select the OpenAI model to use for processing')
.addDropdown(dropdown => dropdown .addDropdown(dropdown => {
.addOption('gpt-5', 'GPT-5') modelDropdown = dropdown;
.addOption('gpt-5-mini', 'GPT-5 Mini') // Populate dropdown from cached models
.addOption('gpt-5-nano', 'GPT-5 Nano') const models = this.plugin.settings.cachedModels;
.setValue(this.plugin.settings.defaultModel) models.forEach(model => {
.onChange(async (value) => { 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; this.plugin.settings.defaultModel = value;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
});
})
.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);
}
}) })
); );
@ -265,6 +329,158 @@ export class OpenAugiSettingTab extends PluginSettingTab {
}) })
); );
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 // Advanced Settings Header
containerEl.createEl('h3', { text: 'Advanced Settings' }); containerEl.createEl('h3', { text: 'Advanced Settings' });
@ -279,4 +495,121 @@ export class OpenAugiSettingTab extends PluginSettingTab {
}) })
); );
} }
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;
}
} }

View 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');
});
});
});

View 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
View 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');
});
});
});

View 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');
});
});

View 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);
}

View 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 {}

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

View 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');
});
});
});

View 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');
});
});

View 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.

View 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]]

View 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.

View 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]].

View 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).

View 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.

View 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.

View 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.

View 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
View 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

View 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]].

View 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);
}
});
});

View file

@ -21,5 +21,9 @@
"include": [ "include": [
"**/*.ts", "**/*.ts",
"src/**/*.ts" "src/**/*.ts"
],
"exclude": [
"tests/**/*.ts",
"vitest.config.ts"
] ]
} }

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.6.0": "1.8.9"
}

15
vitest.config.ts Normal file
View 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'],
},
});