mirror of
https://github.com/bitsofchris/openaugi-obsidian-plugin.git
synced 2026-07-22 12:40:27 +00:00
Compare commits
29 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc62c4d591 | ||
|
|
09892d2ef3 | ||
|
|
cac28bd98d | ||
|
|
6ff04f0f33 | ||
|
|
cbc0c1b471 | ||
|
|
521399efd3 | ||
|
|
6e9ed65d40 | ||
|
|
117b33eb95 | ||
|
|
3b73ce2295 | ||
|
|
41e96f86c9 | ||
|
|
33ea6eb397 | ||
|
|
8072306a89 | ||
|
|
850c270434 | ||
|
|
b7d4a3b225 | ||
|
|
9498668076 | ||
|
|
a69b52281d | ||
|
|
2d3c5d75ec | ||
|
|
f23b331902 | ||
|
|
8dd3310151 | ||
|
|
9580d24974 | ||
|
|
00086fe2fd | ||
|
|
3b49d555fe | ||
|
|
55465575aa | ||
|
|
ae9f90acad | ||
|
|
7660c798b5 | ||
|
|
f0a8816653 | ||
|
|
13f1aa4f86 | ||
|
|
cb0d4089a4 | ||
|
|
4123d93c4e |
47 changed files with 6142 additions and 622 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -22,3 +22,7 @@ data.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
|
# Test output artifacts
|
||||||
|
tests/vault-output/
|
||||||
|
PLAN.md
|
||||||
|
|
|
||||||
46
CLAUDE.md
46
CLAUDE.md
|
|
@ -49,24 +49,41 @@ Read the docs/CODEBASE_MAP.md to understand the project at a high level. Be sure
|
||||||
## 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
|
||||||
|
|
||||||
|
|
@ -121,16 +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
|
||||||
See [docs/PUBLISHING.md](docs/PUBLISHING.md) for the complete release process.
|
See [docs/PUBLISHING.md](docs/PUBLISHING.md) for the complete release process,
|
||||||
|
including community-store listing health and how to relist if de-listed.
|
||||||
|
|
||||||
**Quick summary:**
|
**Just run the script** (it bumps all three version files, publishes, and verifies):
|
||||||
1. Update version in `manifest.json` and `package.json`
|
```bash
|
||||||
2. Commit and push to master
|
# write docs/release-notes/X.Y.Z.md first, then:
|
||||||
3. Tag with matching version: `git tag -a X.Y.Z -m "X.Y.Z" && git push origin X.Y.Z`
|
./scripts/release.sh X.Y.Z
|
||||||
4. Generate release notes (Claude: compare commits since last tag, focus on user-facing changes)
|
```
|
||||||
5. Edit draft release on GitHub and publish
|
|
||||||
|
|
||||||
*** Be sure to update `manifest.json` version number as part of PR ***
|
**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
|
||||||
|
|
||||||
|
|
|
||||||
550
README.md
550
README.md
|
|
@ -1,476 +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)
|
|
||||||
✅ **Backlink context** - Also gather context from notes that link TO your notes
|
|
||||||
✅ **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
|
|
||||||
|
|
||||||
### Backlink Context Extraction
|
|
||||||
|
|
||||||
**NEW**: Gather richer context by including notes that reference your discovered notes.
|
|
||||||
|
|
||||||
When you discuss an idea in multiple places and link back to a central note, OpenAugi can now extract those references automatically. Instead of just following forward links, it also finds notes that link TO each discovered note and extracts the header section containing the reference.
|
|
||||||
|
|
||||||
**How to use:**
|
|
||||||
1. Run "Process notes" on any note
|
|
||||||
2. Backlinks are included by default (toggle off with **"Include backlinks"** if needed)
|
|
||||||
3. Optionally adjust **"Backlink context"** (0 = header section, 1-5 = lines before/after)
|
|
||||||
4. Review discovered notes - backlinks show with a "← backlink" badge
|
|
||||||
5. Backlink snippets appear in a separate "# Backlinks" section in the output
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```
|
|
||||||
You have:
|
|
||||||
- [[Project Alpha]] - your main project note
|
|
||||||
- [[Meeting Notes 2025-01-15]] - contains "discussed [[Project Alpha]] timeline"
|
|
||||||
- [[Ideas]] - contains "this reminds me of [[Project Alpha]]"
|
|
||||||
|
|
||||||
With backlinks enabled:
|
|
||||||
- Project Alpha's forward links are included (full content)
|
|
||||||
- Meeting Notes snippet: "discussed [[Project Alpha]] timeline"
|
|
||||||
- Ideas snippet: "this reminds me of [[Project Alpha]]"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Settings:**
|
|
||||||
- **Include backlinks by default** - Enabled by default; toggle in Settings → Context Gathering
|
|
||||||
- **Backlink context lines** - Default lines to extract (0 = header section, 1-5 = fixed lines)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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)
|
|
||||||
- **Include Backlinks by Default**: Also discover notes that link to discovered notes (default: On)
|
|
||||||
- **Backlink Context Lines**: Lines to extract around each backlink reference (0 = header section, default: 0)
|
|
||||||
|
|
||||||
### 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
94
docs/AGENT_TASKS.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
---
|
||||||
|
name: Agent Tasks (Augi commands)
|
||||||
|
description: Plugin commands that queue work for the OpenAugi task watcher by writing pending task files to OpenAugi/Tasks/ via the vault API.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Tasks (Augi commands)
|
||||||
|
|
||||||
|
**When to use:** You want an agent to run the review pass, process the
|
||||||
|
Dashboard, or distill a chunk of your writing — triggered from inside
|
||||||
|
Obsidian, on desktop or mobile.
|
||||||
|
|
||||||
|
**How it works:** The vault filesystem is the API. Each command writes a
|
||||||
|
markdown task file with `status: pending` frontmatter into
|
||||||
|
`OpenAugi/Tasks/`. The [OpenAugi task watcher](https://github.com/bitsofchris/openaugi)
|
||||||
|
(`openaugi up`) polls that folder and launches a Claude agent session for
|
||||||
|
each pending task. The plugin never shells out and never makes an HTTP
|
||||||
|
call — it only writes a file, so the same commands work on Obsidian
|
||||||
|
Mobile against a synced vault.
|
||||||
|
|
||||||
|
The `openaugi review` CLI, the `zzz:` capture grammar, and these commands
|
||||||
|
all converge on the same task-file contract, defined authoritatively in
|
||||||
|
the parent repo at `src/openaugi/templates/task-template.md`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- The [OpenAugi](https://github.com/bitsofchris/openaugi) Python package
|
||||||
|
running its task watcher (`openaugi up`) against your vault. Without it,
|
||||||
|
task files sit in `OpenAugi/Tasks/` as pending until a watcher picks
|
||||||
|
them up.
|
||||||
|
- Agent skill files in your vault under `OpenAugi/AGENT/` (created by
|
||||||
|
`openaugi init`): `review-pass.md` for the review commands,
|
||||||
|
`distill-lens.md` for distill.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Instruction queued | Scope |
|
||||||
|
|---------|--------------------|-------|
|
||||||
|
| **Augi: Run review pass** | `run the review pass` | Full loop: route new blocks → refresh views → Dashboard nominations |
|
||||||
|
| **Augi: Process dashboard** | `process the dashboard` | Execute Dashboard nomination answers only, no new-block routing |
|
||||||
|
| **Augi: Distill selection** | `distill this per OpenAugi/AGENT/distill-lens.md` | Current selection, or the active note's body when nothing is selected |
|
||||||
|
|
||||||
|
For **Distill selection**, the selected text (or note body, frontmatter
|
||||||
|
stripped) is copied verbatim into the task file's `## Context` section —
|
||||||
|
the plugin acts purely as a scope selector; the agent distills exactly
|
||||||
|
that content and nothing else.
|
||||||
|
|
||||||
|
## The task file
|
||||||
|
|
||||||
|
Example produced by **Augi: Run review pass**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
status: pending
|
||||||
|
source_block_id: obsidian-plugin
|
||||||
|
source_note: "[[OpenAugi plugin]]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# run the review pass
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Triggered via the OpenAugi plugin command "Augi: Run review pass" at 20260707-143000.
|
||||||
|
|
||||||
|
## User instruction
|
||||||
|
|
||||||
|
> run the review pass
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Read OpenAugi/AGENT/review-pass.md and execute: run the review pass.
|
||||||
|
|
||||||
|
## Human Todo
|
||||||
|
|
||||||
|
## Results
|
||||||
|
```
|
||||||
|
|
||||||
|
The watcher hydrates the file (adds `task_id`, `created`,
|
||||||
|
`tmux_session`, flips `status: active`, renames to `TASK-*.md`) and
|
||||||
|
launches the agent. When the agent finishes it fills in `## Results` and
|
||||||
|
sets `status: done` — the task file doubles as the record of what
|
||||||
|
happened.
|
||||||
|
|
||||||
|
No `repo`/`working_dir` key is written, so the watcher defaults the
|
||||||
|
agent's working directory to the vault — correct for review and distill
|
||||||
|
work.
|
||||||
|
|
||||||
|
## Relationship to Task Dispatch (deprecated)
|
||||||
|
|
||||||
|
The older [Task Dispatch](TASK_DISPATCH.md) feature launches tmux
|
||||||
|
sessions directly from the plugin. That is a parallel execution path
|
||||||
|
that drifts from the watcher (different session names, duplicate repo
|
||||||
|
settings) and requires desktop + tmux + macOS. It is deprecated and will
|
||||||
|
be removed over a release or two; use the Augi commands with the task
|
||||||
|
watcher instead.
|
||||||
|
|
@ -13,7 +13,11 @@ A comprehensive reference for navigating and extending this Obsidian plugin.
|
||||||
| Link traversal & aggregation | [services/distill-service.ts](../src/services/distill-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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -31,7 +35,15 @@ src/
|
||||||
│ ├── openai-service.ts # OpenAI API calls
|
│ ├── openai-service.ts # OpenAI API calls
|
||||||
│ ├── file-service.ts # File creation & output
|
│ ├── file-service.ts # File creation & output
|
||||||
│ ├── distill-service.ts # Content aggregation, link traversal
|
│ ├── distill-service.ts # Content aggregation, link traversal
|
||||||
│ └── context-gathering-service.ts # Unified discovery orchestration
|
│ ├── 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/
|
├── ui/
|
||||||
│ ├── settings-tab.ts # Settings panel
|
│ ├── settings-tab.ts # Settings panel
|
||||||
│ ├── loading-indicator.ts # Status bar spinner
|
│ ├── loading-indicator.ts # Status bar spinner
|
||||||
|
|
@ -39,6 +51,7 @@ src/
|
||||||
│ ├── context-selection-modal.ts # Stage 2: Checkbox selection
|
│ ├── context-selection-modal.ts # Stage 2: Checkbox selection
|
||||||
│ ├── context-preview-modal.ts # Stage 3: Preview & action
|
│ ├── context-preview-modal.ts # Stage 3: Preview & action
|
||||||
│ ├── prompt-selection-modal.ts # Custom prompt picker
|
│ ├── prompt-selection-modal.ts # Custom prompt picker
|
||||||
|
│ ├── session-list-modal.ts # Task dispatch session list
|
||||||
│ └── recent-activity-modal.ts # (Legacy)
|
│ └── recent-activity-modal.ts # (Legacy)
|
||||||
└── utils/
|
└── utils/
|
||||||
└── filename-utils.ts # Sanitization, backlink mapping
|
└── filename-utils.ts # Sanitization, backlink mapping
|
||||||
|
|
@ -148,6 +161,52 @@ gatherContext(config: ContextGatheringConfig): Promise<GatheredContext>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 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
|
## Types Reference
|
||||||
|
|
||||||
### Settings (`types/settings.ts`)
|
### Settings (`types/settings.ts`)
|
||||||
|
|
@ -248,6 +307,12 @@ Commands are registered in `main.ts`:
|
||||||
| `openaugi-process-notes` | Process notes | Unified flow: linked notes |
|
| `openaugi-process-notes` | Process notes | Unified flow: linked notes |
|
||||||
| `openaugi-process-recent` | Process recent activity | Unified flow: recent activity |
|
| `openaugi-process-recent` | Process recent activity | Unified flow: recent activity |
|
||||||
| `openaugi-save-context` | Save context | Save raw aggregated content |
|
| `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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,146 +1,170 @@
|
||||||
# Publishing a New Release
|
# Publishing a New Release
|
||||||
|
|
||||||
This guide walks through the complete process of publishing a new OpenAugi plugin 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.
|
||||||
|
|
||||||
## Pre-Release Checklist
|
---
|
||||||
|
|
||||||
Before starting the release process:
|
## TL;DR — use the script
|
||||||
|
|
||||||
- [ ] All features/fixes are merged to master
|
|
||||||
- [ ] Code builds without errors (`npm run build`)
|
|
||||||
- [ ] Type checking passes (`npm run typecheck`)
|
|
||||||
- [ ] Manual testing completed in test vault (`/Users/chris/zk-for-testing`)
|
|
||||||
- [ ] CODEBASE_MAP.md is up to date with any architectural changes
|
|
||||||
|
|
||||||
## Release Process
|
|
||||||
|
|
||||||
### Step 1: Bump the Version
|
|
||||||
|
|
||||||
Update the version number in **both** files (they must match):
|
|
||||||
|
|
||||||
1. `manifest.json` - Update the `"version"` field
|
|
||||||
2. `package.json` - Update the `"version"` field
|
|
||||||
|
|
||||||
Use semantic versioning:
|
|
||||||
- **MAJOR** (1.0.0): Breaking changes
|
|
||||||
- **MINOR** (0.1.0): New features, backwards compatible
|
|
||||||
- **PATCH** (0.0.1): Bug fixes, backwards compatible
|
|
||||||
|
|
||||||
### Step 2: Commit the Version Bump
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add manifest.json package.json
|
# 1. Write the release notes first (they become the GitHub release body):
|
||||||
git commit -m "Bump version to X.Y.Z"
|
# docs/release-notes/X.Y.Z.md
|
||||||
git push origin master
|
# 2. Then run:
|
||||||
|
./scripts/release.sh X.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 3: Create and Push the Git Tag
|
`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.
|
||||||
|
|
||||||
The tag **must** match the version in `manifest.json` exactly.
|
Everything below explains *why* it does what it does, and the manual fallback.
|
||||||
|
|
||||||
```bash
|
## The three things Obsidian actually checks
|
||||||
git tag -a X.Y.Z -m "X.Y.Z"
|
|
||||||
git push origin X.Y.Z
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
1. **`manifest.json` at the repo-root HEAD** — Obsidian reads only the `version`
|
||||||
- `-a` creates an [annotated tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging#_creating_tags) (required for Obsidian releases)
|
field here to decide "what's the latest version."
|
||||||
- `-m` specifies the tag message - must match the version number
|
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.
|
||||||
|
|
||||||
### Step 4: Wait for GitHub Actions
|
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.)
|
||||||
|
|
||||||
The workflow will automatically:
|
## Version files — all THREE must move together
|
||||||
1. Build the plugin
|
|
||||||
2. Create a draft release with the built artifacts
|
|
||||||
|
|
||||||
Monitor progress at: https://github.com/bitsofchris/openaugi-obsidian-plugin/actions
|
| 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** |
|
||||||
|
|
||||||
### Step 5: Generate Release Notes
|
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.
|
||||||
|
|
||||||
**Claude should generate release notes** by comparing the new tag to the previous release:
|
> 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.
|
||||||
|
|
||||||
1. Look at all commits since the last release tag
|
## Pre-release checklist
|
||||||
2. Identify user-facing changes (features, fixes, improvements)
|
|
||||||
3. Create release notes in this format:
|
- [ ] 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
|
```markdown
|
||||||
## TL;DR
|
# X.Y.Z — <one-line theme>
|
||||||
[One-sentence summary of the most important change(s)]
|
|
||||||
|
|
||||||
## What's New
|
## Added / Changed / Fixed / Deprecated
|
||||||
|
- <user-facing change>: <why the user cares>
|
||||||
|
|
||||||
### Features
|
## Notes
|
||||||
- [Feature 1]: [Brief description of what it does and why users care]
|
- <breaking changes or upgrade steps, if any>
|
||||||
- [Feature 2]: [Brief description]
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- [Fix 1]: [What was broken and how it's fixed]
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
- [Improvement 1]: [What's better now]
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
[For any new features, include brief usage instructions]
|
|
||||||
|
|
||||||
## Upgrade Notes
|
|
||||||
|
|
||||||
[Any breaking changes or things users need to know when upgrading]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Write this out to a markdown file.
|
## Manual fallback (if you can't use the script)
|
||||||
|
|
||||||
### Step 6: Publish the Release
|
Only if `release.sh` can't run. Do the steps **in this order** and don't stop
|
||||||
|
before publishing:
|
||||||
1. Go to https://github.com/bitsofchris/openaugi-obsidian-plugin/releases
|
|
||||||
2. Find the draft release created by the workflow
|
|
||||||
3. Click **Edit**
|
|
||||||
4. Paste the generated release notes
|
|
||||||
5. Click **Publish release**
|
|
||||||
|
|
||||||
### Step 7: Update Documentation
|
|
||||||
|
|
||||||
Ensure all docs reflect the new release:
|
|
||||||
|
|
||||||
- [ ] `docs/CODEBASE_MAP.md` - Architecture changes
|
|
||||||
- [ ] `CLAUDE.md` - Any new development guidelines
|
|
||||||
- [ ] `README.md` - User-facing documentation (if applicable)
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full release flow (example for version 0.4.0)
|
# 1. Bump ALL THREE (or run: npm version X.Y.Z — see note below)
|
||||||
npm run build
|
# manifest.json .version, package.json .version, versions.json add "X.Y.Z":"<minAppVersion>"
|
||||||
npm run typecheck
|
npm test && npm run build # guard test must be green
|
||||||
|
|
||||||
# Commit version bump
|
git add manifest.json package.json versions.json docs/release-notes/X.Y.Z.md
|
||||||
git add manifest.json package.json
|
git commit -m "Release X.Y.Z"
|
||||||
git commit -m "Bump version to 0.4.0"
|
|
||||||
git push origin master
|
git push origin master
|
||||||
|
|
||||||
# Tag and push
|
git tag -a X.Y.Z -m "X.Y.Z" # annotated, no 'v' prefix
|
||||||
git tag -a 0.4.0 -m "0.4.0"
|
git push origin X.Y.Z # triggers .github/workflows/release.yml → draft
|
||||||
git push origin 0.4.0
|
|
||||||
|
|
||||||
# Then: Edit draft release on GitHub and publish
|
# 2. Wait for the workflow, then PUBLISH RIGHT AWAY (don't leave it a draft):
|
||||||
|
gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status
|
||||||
|
gh release edit X.Y.Z --draft=false --latest --notes-file docs/release-notes/X.Y.Z.md
|
||||||
|
|
||||||
|
# 3. Verify root manifest, released asset, and tag all say X.Y.Z:
|
||||||
|
curl -sL https://raw.githubusercontent.com/bitsofchris/openaugi-obsidian-plugin/master/manifest.json | grep version
|
||||||
|
curl -sL https://github.com/bitsofchris/openaugi-obsidian-plugin/releases/download/X.Y.Z/manifest.json | grep version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `npm version X.Y.Z` also works: `.npmrc` sets `tag-version-prefix=""` (so the
|
||||||
|
> tag has no `v`), and the `version` npm script runs `version-bump.mjs` which
|
||||||
|
> updates `manifest.json` + `versions.json`, while npm bumps `package.json`. It
|
||||||
|
> commits and tags for you — but it does **not** push or publish, so you still
|
||||||
|
> owe steps 1–3 above (push, wait, publish, verify). The script does all of it.
|
||||||
|
|
||||||
|
## Community-store listing health
|
||||||
|
|
||||||
|
The plugin is listed in Obsidian's store via
|
||||||
|
[`obsidianmd/obsidian-releases`](https://github.com/obsidianmd/obsidian-releases).
|
||||||
|
That repo's `community-plugins.json` is now an **automated mirror** of Obsidian's
|
||||||
|
internal list (commits read `chore: Mirror community plugins and themes`), so you
|
||||||
|
can't fix listing by PR'ing that file — it gets overwritten.
|
||||||
|
|
||||||
|
Check whether we're currently listed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sL https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json | grep -i openaugi
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Listed** → core Settings → Community plugins → "Check for updates" works for users.
|
||||||
|
- **Not listed** → core update check can't see us; users must use **BRAT** or a
|
||||||
|
manual install. See the relisting steps below.
|
||||||
|
|
||||||
|
### If the plugin gets removed from the store
|
||||||
|
|
||||||
|
This happened on **2026-07-07** (removed in mirror commit `85db6f23`, bundled with
|
||||||
|
several unrelated plugins — an automated batch removal). The mirror commits carry
|
||||||
|
no per-plugin reason and Obsidian doesn't file an issue on your repo, so:
|
||||||
|
|
||||||
|
1. **Fix consistency first** (so a resubmit passes validation): run a clean
|
||||||
|
release with `release.sh` — root manifest version must equal a *published*
|
||||||
|
release with the three assets, `versions.json` present, `README`/`LICENSE`
|
||||||
|
present. (All true as of 0.6.0.)
|
||||||
|
2. **Ask Obsidian why + request reinstatement** — don't blindly resubmit an
|
||||||
|
auto-removal. Post in the Obsidian Discord **`#plugin-dev`** channel (fastest;
|
||||||
|
plugin-review team is active there) or the forum **Developers: Plugin & API**
|
||||||
|
category. Cite: repo URL, removal date `2026-07-07`, mirror commit `85db6f23`.
|
||||||
|
3. **If they tell you to resubmit:** the current path is the web portal at
|
||||||
|
**community.obsidian.md** → sign in with your Obsidian account → link GitHub →
|
||||||
|
Plugins → New plugin → enter the repo URL. (The old "PR to
|
||||||
|
community-plugins.json" flow is dead now that the file is bot-mirrored.)
|
||||||
|
4. **Gotcha — "An entry already exists for this repository":** because the old
|
||||||
|
entry is archived rather than deleted, resubmitting the same repo URL can be
|
||||||
|
rejected. Ask a moderator in `#plugin-dev` to delete the archived entry, then
|
||||||
|
resubmit.
|
||||||
|
|
||||||
|
Sources: Obsidian plugin submission docs; obsidian-releases repo (mirror
|
||||||
|
architecture); forum thread "Cannot resubmit plugin after archiving: 'An entry
|
||||||
|
already exists for this repository'".
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Tag already exists
|
**Tag already exists / wrong version tagged**
|
||||||
```bash
|
```bash
|
||||||
# Delete local tag
|
|
||||||
git tag -d X.Y.Z
|
git tag -d X.Y.Z
|
||||||
# Delete remote tag (if pushed)
|
git push origin --delete X.Y.Z # if already pushed
|
||||||
git push origin --delete X.Y.Z
|
# fix versions, re-run ./scripts/release.sh X.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
### Wrong version tagged
|
**Workflow failed** — `gh run view <id>`, fix, delete the tag, re-run the script.
|
||||||
1. Delete the incorrect tag (see above)
|
|
||||||
2. Fix the version in manifest.json/package.json
|
|
||||||
3. Commit and re-tag
|
|
||||||
|
|
||||||
### Workflow failed
|
**Release stuck as draft** — `gh release edit X.Y.Z --draft=false --latest`.
|
||||||
1. Check the Actions tab for error details
|
|
||||||
2. Fix the issue
|
|
||||||
3. Delete the tag and re-push after fixing
|
|
||||||
|
|
|
||||||
160
docs/TASK_DISPATCH.md
Normal file
160
docs/TASK_DISPATCH.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Task Dispatch
|
||||||
|
|
||||||
|
> **⚠️ Deprecated.** Task Dispatch launches tmux sessions directly from the
|
||||||
|
> plugin — a parallel execution path that bypasses the OpenAugi task
|
||||||
|
> watcher and will drift from it (different session names, duplicate repo
|
||||||
|
> settings). It still works, but it will be removed over a release or two.
|
||||||
|
> New workflows should use the **Augi commands** ([Agent Tasks](AGENT_TASKS.md)),
|
||||||
|
> which write task files to `OpenAugi/Tasks/` for the task watcher to
|
||||||
|
> execute — no tmux or macOS dependency in the plugin, and they work on
|
||||||
|
> mobile. If you rely on Task Dispatch without the Python watcher, nothing
|
||||||
|
> breaks today; plan to install [OpenAugi](https://github.com/bitsofchris/openaugi)
|
||||||
|
> and run `openaugi up` before it is removed.
|
||||||
|
|
||||||
|
Task Dispatch lets you launch AI coding agents directly from Obsidian task notes. Each task gets its own tmux session with full context from your vault — the task note body, all linked notes, and access to the OpenAugi MCP server for searching your vault.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **tmux** — Install with `brew install tmux`. The plugin auto-detects the binary, or you can set the path manually in settings.
|
||||||
|
- **An agent CLI** — Claude Code (`claude`) is the default. Any CLI agent that accepts a system prompt flag works.
|
||||||
|
- **macOS** — Terminal opening uses AppleScript (iTerm2 or Terminal.app).
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Create a task note with `task_id` in frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
task_id: fix-auth-bug
|
||||||
|
working_dir: my-repo
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Fix the authentication bug where sessions expire after 5 minutes.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The auth middleware is in `src/middleware/auth.ts`. The JWT expiry is hardcoded.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the command **Task dispatch: Launch or attach** (from the command palette).
|
||||||
|
3. A terminal window opens with the agent already running, pre-loaded with context from your note and all linked notes.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When you launch a task:
|
||||||
|
|
||||||
|
1. **Context assembly** — The plugin reads the task note body (stripped of frontmatter), then gathers all linked notes via the same link traversal used by Distill. Everything is concatenated into a single context bundle.
|
||||||
|
2. **Temp file** — The context is written to a temp file (default: `/tmp/openaugi/task-{id}-context.md`).
|
||||||
|
3. **tmux session** — A new tmux session named `task-{id}` is created in the configured working directory.
|
||||||
|
4. **Agent launch** — The agent CLI is invoked with the context file, e.g.:
|
||||||
|
```
|
||||||
|
cd '/path/to/repo' && claude --append-system-prompt-file '/tmp/openaugi/task-fix-auth-bug-context.md' "..."
|
||||||
|
```
|
||||||
|
5. **Terminal opens** — iTerm2 or Terminal.app opens attached to the session.
|
||||||
|
|
||||||
|
If the session already exists, the plugin just attaches to it (no duplicate sessions).
|
||||||
|
|
||||||
|
## Task Note Frontmatter
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `task_id` | Yes | Unique identifier for the task. Also accepts `task-id`. |
|
||||||
|
| `working_dir` | No | Where the agent starts. Can be a **repo name** (mapped in settings), an **absolute path**, or a **vault-relative path**. Falls back to the default working directory setting. |
|
||||||
|
|
||||||
|
### Working Directory Resolution
|
||||||
|
|
||||||
|
The `working_dir` value is resolved in this order:
|
||||||
|
|
||||||
|
1. **Named repo** — If it matches a name in your configured Repository Paths, the mapped absolute path is used. Case-insensitive.
|
||||||
|
2. **Absolute path** — If it starts with `/`, used as-is.
|
||||||
|
3. **Vault-relative path** — Otherwise, resolved against the vault root.
|
||||||
|
4. **Default** — If `working_dir` is absent, the Default Working Directory setting is used.
|
||||||
|
5. **Fallback** — If nothing is configured, falls back to `$HOME`.
|
||||||
|
|
||||||
|
The directory is created automatically if it doesn't exist.
|
||||||
|
|
||||||
|
### Example: Using Repo Names
|
||||||
|
|
||||||
|
After configuring repository paths in settings:
|
||||||
|
|
||||||
|
| Name | Path |
|
||||||
|
|------|------|
|
||||||
|
| my-app | /Users/chris/repos/my-app |
|
||||||
|
| api-server | /Users/chris/repos/api-server |
|
||||||
|
| infra | /Users/chris/repos/infrastructure |
|
||||||
|
|
||||||
|
Your frontmatter just needs the short name:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
task_id: add-caching
|
||||||
|
working_dir: api-server
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Task dispatch: Launch or attach** | Creates a new session or attaches to an existing one for the current task note. |
|
||||||
|
| **Task dispatch: Kill session** | Kills the tmux session for the current task note and cleans up the temp context file. |
|
||||||
|
| **Task dispatch: List active sessions** | Opens a modal showing all active `task-*` tmux sessions with options to attach or kill each. |
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
All settings are under **Settings > OpenAugi > Task Dispatch**.
|
||||||
|
|
||||||
|
### Terminal Application
|
||||||
|
|
||||||
|
Which app opens when attaching to a session.
|
||||||
|
|
||||||
|
- **iTerm2** (default)
|
||||||
|
- **Terminal.app**
|
||||||
|
|
||||||
|
### tmux Path
|
||||||
|
|
||||||
|
Absolute path to the tmux binary. Leave blank to auto-detect (checks `/opt/homebrew/bin/tmux`, `/usr/local/bin/tmux`, `/usr/bin/tmux`, then `which tmux`). Use the **Detect** button to find it automatically.
|
||||||
|
|
||||||
|
### Default Working Directory
|
||||||
|
|
||||||
|
The directory the agent starts in when a task note doesn't specify `working_dir`. Can be vault-relative (e.g., `OpenAugi/Tasks`) or absolute. Default: `OpenAugi/Tasks`.
|
||||||
|
|
||||||
|
### Repository Paths
|
||||||
|
|
||||||
|
Map short names to absolute folder paths. Each entry has:
|
||||||
|
|
||||||
|
- **Name** — The short name you'll use in frontmatter `working_dir` (e.g., `my-repo`).
|
||||||
|
- **Path** — The absolute filesystem path (e.g., `/Users/chris/repos/my-repo`).
|
||||||
|
- **Browse** — Opens the native folder picker to select a directory. Auto-fills the name from the folder basename if empty.
|
||||||
|
|
||||||
|
### Default Agent
|
||||||
|
|
||||||
|
Which agent CLI to use. Default: Claude Code (`claude`).
|
||||||
|
|
||||||
|
### Max Context Characters
|
||||||
|
|
||||||
|
Upper limit on the context bundle size. Content beyond this is truncated. Default: `200,000`.
|
||||||
|
|
||||||
|
### Context Temp Directory
|
||||||
|
|
||||||
|
Where temporary context files are written. Default: `/tmp/openaugi`. Files are cleaned up when sessions are killed.
|
||||||
|
|
||||||
|
## Agent Configuration
|
||||||
|
|
||||||
|
The default agent is Claude Code with `--append-system-prompt` as the context flag. The agent config has four fields:
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `id` | `claude-code` | Internal identifier. |
|
||||||
|
| `name` | `Claude Code` | Display name in settings. |
|
||||||
|
| `command` | `claude` | The CLI command to run. |
|
||||||
|
| `contextFlag` | `--append-system-prompt` | The CLI flag for injecting context. If the flag ends with `-file` (e.g., `--append-system-prompt-file`), the temp file path is passed directly. Otherwise, the file content is expanded inline via `$(cat ...)`. |
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Link related context** — Any notes linked from your task note (`[[Design Doc]]`, `[[API Spec]]`) are automatically included in the context bundle. Structure your task notes with relevant links.
|
||||||
|
- **Keep task IDs unique** — The tmux session name is derived from `task_id`. Duplicates will collide.
|
||||||
|
- **Session persistence** — tmux sessions survive Obsidian restarts. Use "List active sessions" to find and reattach to running agents.
|
||||||
|
- **MCP access** — The agent's context includes a note that the OpenAugi MCP server is available for vault searches, so agents can look up additional information beyond what's in the initial context.
|
||||||
190
docs/TESTING.md
Normal file
190
docs/TESTING.md
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
Automated tests for the OpenAugi plugin. Run these before every release and when developing new features.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests once
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Watch mode (re-runs on file changes)
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests run in under 1 second. No Obsidian app, no API keys, no manual setup needed.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The plugin runs inside Obsidian's Electron environment, but our tests don't need Obsidian at all. Instead:
|
||||||
|
|
||||||
|
1. **Mock Obsidian API** (`tests/mocks/`) — A filesystem-backed mock that simulates `Vault`, `MetadataCache`, `App`, and `TFile` by reading real markdown files and parsing `[[wikilinks]]` to build link graphs.
|
||||||
|
|
||||||
|
2. **Test vault** (`tests/vault/`) — A small set of markdown files committed to the repo that represent various note structures (links, backlinks, journal dates, dataview blocks, checkboxes, etc.).
|
||||||
|
|
||||||
|
3. **Vitest** — Fast TypeScript test runner. Config in `vitest.config.ts`.
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
| File | What it tests |
|
||||||
|
|------|--------------|
|
||||||
|
| `tests/filename-utils.test.ts` | `sanitizeFilename`, `BacklinkMapper`, file collision handling |
|
||||||
|
| `tests/openai-service.test.ts` | Prompt construction, context extraction, API response parsing |
|
||||||
|
| `tests/distill-service.test.ts` | Link extraction, backlinks, content aggregation, journal filtering, dataview stripping |
|
||||||
|
| `tests/file-service.test.ts` | File/folder creation, session folders, summaries, published posts |
|
||||||
|
| `tests/context-gathering-service.test.ts` | BFS link traversal, depth limits, backlink discovery, character limits, folder exclusion |
|
||||||
|
|
||||||
|
## Adding Tests for a New Feature
|
||||||
|
|
||||||
|
### 1. Decide which test file
|
||||||
|
|
||||||
|
Match your feature to the service it lives in:
|
||||||
|
|
||||||
|
| If you changed... | Add tests to... |
|
||||||
|
|-------------------|-----------------|
|
||||||
|
| `src/utils/filename-utils.ts` | `tests/filename-utils.test.ts` |
|
||||||
|
| `src/services/openai-service.ts` | `tests/openai-service.test.ts` |
|
||||||
|
| `src/services/distill-service.ts` | `tests/distill-service.test.ts` |
|
||||||
|
| `src/services/file-service.ts` | `tests/file-service.test.ts` |
|
||||||
|
| `src/services/context-gathering-service.ts` | `tests/context-gathering-service.test.ts` |
|
||||||
|
| New service | Create `tests/your-service.test.ts` |
|
||||||
|
|
||||||
|
### 2. Add test vault fixtures (if needed)
|
||||||
|
|
||||||
|
If your feature needs specific note content to test against, add markdown files to `tests/vault/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/vault/
|
||||||
|
├── Root Note.md # Forward links to A and B
|
||||||
|
├── Linked Note A.md # Links to Deep Note
|
||||||
|
├── Linked Note B.md # Links back to A
|
||||||
|
├── Backlink Source.md # Links TO Root Note
|
||||||
|
├── Journal Note.md # Date headers (### YYYY-MM-DD)
|
||||||
|
├── Dataview Note.md # ```dataview blocks
|
||||||
|
├── Collection Note.md # Checkbox links [x] / [ ]
|
||||||
|
├── Context Note.md # context: section
|
||||||
|
├── Special Characters!.md # Filename sanitization edge case
|
||||||
|
├── Deeply Linked/
|
||||||
|
│ └── Deep Note.md # Depth-2 traversal
|
||||||
|
└── Excluded Folder/
|
||||||
|
└── Should Skip.md # Folder exclusion
|
||||||
|
```
|
||||||
|
|
||||||
|
After adding a new fixture file, the mock `MetadataCache` automatically picks it up — it scans all `.md` files and parses their `[[links]]` on initialization.
|
||||||
|
|
||||||
|
### 3. Write the test
|
||||||
|
|
||||||
|
Pattern for **pure unit tests** (no Obsidian needed):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { myFunction } from '../src/utils/my-utils';
|
||||||
|
|
||||||
|
describe('myFunction', () => {
|
||||||
|
it('does the expected thing', () => {
|
||||||
|
expect(myFunction('input')).toBe('output');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern for **integration tests** (using mock Obsidian API):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { MyService } from '../src/services/my-service';
|
||||||
|
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const VAULT_DIR = path.resolve(__dirname, 'vault');
|
||||||
|
|
||||||
|
describe('MyService', () => {
|
||||||
|
let app: ReturnType<typeof createMockApp>;
|
||||||
|
let service: MyService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createMockApp(VAULT_DIR);
|
||||||
|
service = new MyService(app as any, /* other deps */);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers linked notes', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const result = await service.someMethod(rootFile);
|
||||||
|
expect(result).toContain('expected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern for **testing OpenAI calls** (mock `fetch`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
it('calls API correctly', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{ message: { content: '{"summary":"test"}', refusal: null } }]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const result = await service.someApiCall('input');
|
||||||
|
expect(result.summary).toBe('test');
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern for **file output tests** (uses temp directories):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { MockApp } from './mocks/obsidian-mock';
|
||||||
|
|
||||||
|
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
|
||||||
|
let outputDir: string;
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
counter++;
|
||||||
|
outputDir = path.join(BASE_OUTPUT_DIR, `run-${counter}-${Date.now()}`);
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
// Point MockApp at the output dir, not the fixture vault
|
||||||
|
app = new MockApp(outputDir);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run and verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's NOT Tested (and why)
|
||||||
|
|
||||||
|
| Area | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| UI modals | Tightly coupled to Obsidian DOM — test manually |
|
||||||
|
| Task dispatch (tmux) | Requires system-level tmux — test manually |
|
||||||
|
| Dataview plugin queries | Requires running Dataview plugin — mock returns empty |
|
||||||
|
| Real OpenAI API calls | Costs money — we mock `fetch` instead |
|
||||||
|
|
||||||
|
## Mock Obsidian API Reference
|
||||||
|
|
||||||
|
The mock (`tests/mocks/obsidian-mock.ts`) supports:
|
||||||
|
|
||||||
|
| Obsidian API | Mock behavior |
|
||||||
|
|-------------|---------------|
|
||||||
|
| `vault.read(file)` | Reads from filesystem |
|
||||||
|
| `vault.create(path, content)` | Writes to filesystem |
|
||||||
|
| `vault.createFolder(path)` | `mkdir -p` |
|
||||||
|
| `vault.getMarkdownFiles()` | Walks directory tree |
|
||||||
|
| `vault.getAbstractFileByPath(path)` | `fs.existsSync` lookup |
|
||||||
|
| `vault.adapter.exists(path)` | `fs.existsSync` |
|
||||||
|
| `vault.adapter.stat(path)` | `fs.statSync` |
|
||||||
|
| `metadataCache.getFileCache(file)` | Parses `[[links]]` from file content |
|
||||||
|
| `metadataCache.getFirstLinkpathDest(link, source)` | Resolves links by basename matching |
|
||||||
|
| `metadataCache.resolvedLinks` | Pre-built link graph from all vault files |
|
||||||
|
|
||||||
|
If a test needs an API that isn't mocked, add it to `tests/mocks/obsidian-mock.ts`.
|
||||||
26
docs/release-notes/0.6.0.md
Normal file
26
docs/release-notes/0.6.0.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 0.6.0 — Agent tasks via the vault (task-file trigger)
|
||||||
|
|
||||||
|
## Added — Augi commands
|
||||||
|
|
||||||
|
Three new commands queue work for the [OpenAugi task watcher](https://github.com/bitsofchris/openaugi) by writing a `status: pending` task file to `OpenAugi/Tasks/` through the Obsidian vault API. No shell-out, no HTTP, no Node modules — so they work on **Obsidian Mobile** against a synced vault.
|
||||||
|
|
||||||
|
- **Augi: Run review pass** — queues a full review pass (route new blocks → refresh views → surface Dashboard nominations).
|
||||||
|
- **Augi: Process dashboard** — queues execution of your Dashboard nomination answers only.
|
||||||
|
- **Augi: Distill selection** — queues a distill of the current editor selection (or the active note's body when nothing is selected) through the distill lens. The plugin is the scope selector; the selected text becomes the task's context verbatim.
|
||||||
|
|
||||||
|
The task-file format is shared with the `openaugi review` CLI and the `zzz:` capture grammar — one contract, defined in the parent repo's `templates/task-template.md`.
|
||||||
|
|
||||||
|
**Requires** the OpenAugi task watcher running against your vault (`openaugi up`).
|
||||||
|
|
||||||
|
## Deprecated — Task Dispatch
|
||||||
|
|
||||||
|
The legacy **Task Dispatch** feature (Launch/attach, Kill session, List active sessions) launches tmux sessions directly from the plugin, bypassing the task watcher. It is now **deprecated** and will be removed over the next release or two. It still works for existing users; the settings section and docs steer new use to the Augi commands + task watcher instead.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- New `docs/AGENT_TASKS.md` covering the commands, the task-file contract, and the migration path.
|
||||||
|
- README and CODEBASE_MAP updated to lead with the task-file flow.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
New commands are additive and backward compatible; nothing existing was removed.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "openaugi",
|
"id": "openaugi",
|
||||||
"name": "OpenAugi",
|
"name": "OpenAugi",
|
||||||
"version": "0.4.0",
|
"version": "0.6.0",
|
||||||
"minAppVersion": "1.8.9",
|
"minAppVersion": "1.8.9",
|
||||||
"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.",
|
"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",
|
||||||
|
|
|
||||||
1637
package-lock.json
generated
1637
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +1,27 @@
|
||||||
{
|
{
|
||||||
"name": "open-augi",
|
"name": "open-augi",
|
||||||
"version": "0.4.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
114
scripts/release.sh
Executable file
|
|
@ -0,0 +1,114 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# release.sh — publish a new OpenAugi plugin version, safely and verifiably.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/release.sh X.Y.Z
|
||||||
|
#
|
||||||
|
# Why this exists: doing releases by hand drifted manifest.json / package.json /
|
||||||
|
# versions.json out of sync (0.6.0 shipped with a stale package.json and no
|
||||||
|
# versions.json) and left a window where the repo advertised a version that had
|
||||||
|
# no published release — which is plausibly why the plugin was auto-removed from
|
||||||
|
# the community store. This script bumps all three files in lockstep, then does
|
||||||
|
# push → tag → wait-for-CI → PUBLISH → verify in one shot so that window never
|
||||||
|
# opens. See docs/PUBLISHING.md.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="bitsofchris/openaugi-obsidian-plugin"
|
||||||
|
die() { printf '\n\033[31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
|
||||||
|
ok() { printf '\033[32m✓ %s\033[0m\n' "$*"; }
|
||||||
|
step(){ printf '\n\033[1m▸ %s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
[[ -n "$VERSION" ]] || die "usage: ./scripts/release.sh X.Y.Z"
|
||||||
|
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "version must be X.Y.Z (no 'v' prefix): got '$VERSION'"
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
NOTES="docs/release-notes/${VERSION}.md"
|
||||||
|
|
||||||
|
# ── Preconditions ────────────────────────────────────────────────────────────
|
||||||
|
step "Preconditions"
|
||||||
|
command -v gh >/dev/null || die "gh CLI not found"
|
||||||
|
gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
|
||||||
|
[[ "$(git branch --show-current)" == "master" ]] || die "must be on master"
|
||||||
|
[[ -z "$(git status --porcelain)" ]] || die "working tree not clean — commit or stash first"
|
||||||
|
git fetch -q origin master
|
||||||
|
[[ "$(git rev-parse HEAD)" == "$(git rev-parse origin/master)" ]] || die "local master differs from origin/master — push/pull first"
|
||||||
|
git rev-parse "$VERSION" >/dev/null 2>&1 && die "tag $VERSION already exists locally"
|
||||||
|
[[ -f "$NOTES" ]] || die "release notes not found: $NOTES (write them first — they become the GitHub release body)"
|
||||||
|
ok "on master, clean, synced; notes present; tag is free"
|
||||||
|
|
||||||
|
# ── Quality gate ─────────────────────────────────────────────────────────────
|
||||||
|
step "Tests + build"
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
ok "tests + build pass"
|
||||||
|
|
||||||
|
# ── Bump all three version files in lockstep ─────────────────────────────────
|
||||||
|
step "Bump manifest.json / package.json / versions.json → $VERSION"
|
||||||
|
node - "$VERSION" <<'NODE'
|
||||||
|
const fs = require('fs');
|
||||||
|
const v = process.argv[2];
|
||||||
|
const write = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, '\t') + '\n');
|
||||||
|
|
||||||
|
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
|
||||||
|
manifest.version = v;
|
||||||
|
write('manifest.json', manifest);
|
||||||
|
|
||||||
|
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||||
|
pkg.version = v;
|
||||||
|
write('package.json', pkg);
|
||||||
|
|
||||||
|
const versions = JSON.parse(fs.readFileSync('versions.json', 'utf8'));
|
||||||
|
versions[v] = manifest.minAppVersion; // version -> min Obsidian version
|
||||||
|
write('versions.json', versions);
|
||||||
|
|
||||||
|
console.log(` manifest ${manifest.version} · package ${pkg.version} · versions[${v}]=${versions[v]}`);
|
||||||
|
NODE
|
||||||
|
# The guard test now proves the three files agree before we tag anything.
|
||||||
|
npx vitest run tests/version-consistency.test.ts
|
||||||
|
ok "version files consistent"
|
||||||
|
|
||||||
|
# ── Commit + push + tag ──────────────────────────────────────────────────────
|
||||||
|
step "Commit, push, tag"
|
||||||
|
git add manifest.json package.json versions.json "$NOTES"
|
||||||
|
git commit -q -m "Release $VERSION"
|
||||||
|
git push -q origin master
|
||||||
|
git tag -a "$VERSION" -m "$VERSION" # no 'v' prefix — Obsidian requires tag == manifest version
|
||||||
|
git push -q origin "$VERSION"
|
||||||
|
ok "pushed master + tag $VERSION (CI release workflow triggered)"
|
||||||
|
|
||||||
|
# ── Wait for CI to build the draft release ───────────────────────────────────
|
||||||
|
step "Waiting for release workflow"
|
||||||
|
sleep 5
|
||||||
|
RUN_ID="$(gh run list --workflow=release.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
|
||||||
|
gh run watch "$RUN_ID" --exit-status >/dev/null || die "release workflow failed — see: gh run view $RUN_ID"
|
||||||
|
ok "workflow succeeded (draft release created)"
|
||||||
|
|
||||||
|
# ── Publish immediately (close the inconsistency window) ─────────────────────
|
||||||
|
step "Publishing release $VERSION"
|
||||||
|
gh release edit "$VERSION" --draft=false --latest --notes-file "$NOTES" >/dev/null
|
||||||
|
ok "release published"
|
||||||
|
|
||||||
|
# ── Verify remote consistency ────────────────────────────────────────────────
|
||||||
|
step "Verifying"
|
||||||
|
RAW="https://raw.githubusercontent.com/${REPO}"
|
||||||
|
DL="https://github.com/${REPO}/releases/download/${VERSION}"
|
||||||
|
root_v="$(curl -sfL "$RAW/master/manifest.json" | node -e 'process.stdin.on("data",d=>console.log(JSON.parse(d).version))')"
|
||||||
|
rel_v="$(curl -sfL "$DL/manifest.json" | node -e 'process.stdin.on("data",d=>console.log(JSON.parse(d).version))')"
|
||||||
|
[[ "$root_v" == "$VERSION" ]] || die "root manifest on master is '$root_v', expected '$VERSION'"
|
||||||
|
[[ "$rel_v" == "$VERSION" ]] || die "released manifest asset is '$rel_v', expected '$VERSION'"
|
||||||
|
is_draft="$(gh release view "$VERSION" --json isDraft --jq .isDraft)"
|
||||||
|
[[ "$is_draft" == "false" ]] || die "release is still a draft"
|
||||||
|
ok "root manifest = release asset = tag = $VERSION; release is published"
|
||||||
|
|
||||||
|
# ── Store-listing health (informational) ─────────────────────────────────────
|
||||||
|
step "Community-store listing check"
|
||||||
|
if curl -sfL "https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json" | grep -qi '"openaugi"'; then
|
||||||
|
ok "plugin is currently listed in the Obsidian community store"
|
||||||
|
else
|
||||||
|
printf '\033[33m! Not in the community store list. Users update via BRAT or manual install.\n'
|
||||||
|
printf ' To relist, see docs/PUBLISHING.md → "If the plugin gets removed from the store".\033[0m\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n\033[32m✓ Released %s\033[0m → https://github.com/%s/releases/tag/%s\n' "$VERSION" "$REPO" "$VERSION"
|
||||||
172
src/main.ts
172
src/main.ts
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -334,6 +494,13 @@ export default class OpenAugiPlugin extends Plugin {
|
||||||
savedData.recentActivityDefaults
|
savedData.recentActivityDefaults
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (savedData?.taskDispatch) {
|
||||||
|
this.settings.taskDispatch = Object.assign(
|
||||||
|
{},
|
||||||
|
DEFAULT_SETTINGS.taskDispatch,
|
||||||
|
savedData.taskDispatch
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
|
|
@ -668,4 +835,5 @@ export default class OpenAugiPlugin extends Plugin {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
}
|
||||||
|
|
@ -225,48 +225,46 @@ ${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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const dvApi = dataviewPlugin.api;
|
const dvApi = dataviewPlugin.api;
|
||||||
const files: TFile[] = [];
|
const files: TFile[] = [];
|
||||||
|
|
||||||
try {
|
|
||||||
// Use dataview API to execute query
|
|
||||||
const queryResult = await dvApi.queryMarkdown(query, sourcePath);
|
|
||||||
|
|
||||||
if (queryResult.successful) {
|
try {
|
||||||
// Process based on query type
|
// Use the structured query API (dvApi.query) which returns Link objects
|
||||||
if (typeof queryResult.value === "object" && queryResult.value !== null && queryResult.value.type === "list") {
|
// with clean file paths. The older queryMarkdown approach returned rendered
|
||||||
// Handle LIST query result as object
|
// markdown where TABLE queries escape pipes as \| inside [[path\|alias]],
|
||||||
|
// breaking path resolution.
|
||||||
|
const queryResult = await dvApi.query(query, sourcePath);
|
||||||
|
|
||||||
|
if (queryResult.successful && queryResult.value) {
|
||||||
|
if (queryResult.value.type === "list") {
|
||||||
for (const item of queryResult.value.values) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
406
src/services/task-dispatch-service.ts
Normal file
406
src/services/task-dispatch-service.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
import { App, TFile, Notice, FileSystemAdapter } from 'obsidian';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { OpenAugiSettings } from '../types/settings';
|
||||||
|
import { DistillService } from './distill-service';
|
||||||
|
import { AgentConfig, RepoPath, TaskSession } from '../types/task-dispatch';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/** Common locations where Homebrew installs tmux. */
|
||||||
|
const TMUX_SEARCH_PATHS = [
|
||||||
|
'/opt/homebrew/bin/tmux', // Apple Silicon Homebrew
|
||||||
|
'/usr/local/bin/tmux', // Intel Homebrew
|
||||||
|
'/usr/bin/tmux', // system install
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to locate the tmux binary on disk.
|
||||||
|
* Returns the absolute path if found, or null.
|
||||||
|
*/
|
||||||
|
export async function detectTmuxPath(): Promise<string | null> {
|
||||||
|
for (const p of TMUX_SEARCH_PATHS) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(p, fs.constants.X_OK);
|
||||||
|
return p;
|
||||||
|
} catch { /* not here */ }
|
||||||
|
}
|
||||||
|
// Fallback: try `which` with an augmented PATH
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('which tmux', {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH ?? '/usr/bin:/bin'}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const found = stdout.trim();
|
||||||
|
if (found) return found;
|
||||||
|
} catch { /* not found */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a working_dir value against the configured repo paths.
|
||||||
|
* Case-insensitive match. Returns the absolute path if found, or null.
|
||||||
|
*/
|
||||||
|
export function resolveRepoPath(name: string, repoPaths: RepoPath[]): string | null {
|
||||||
|
if (!repoPaths || repoPaths.length === 0) return null;
|
||||||
|
const match = repoPaths.find(rp => rp.name.toLowerCase() === name.toLowerCase());
|
||||||
|
return match?.path ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaskDispatchService {
|
||||||
|
private app: App;
|
||||||
|
private settings: OpenAugiSettings;
|
||||||
|
private distillService: DistillService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
settings: OpenAugiSettings,
|
||||||
|
distillService: DistillService
|
||||||
|
) {
|
||||||
|
this.app = app;
|
||||||
|
this.settings = settings;
|
||||||
|
this.distillService = distillService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the tmux binary path from settings or auto-detect. */
|
||||||
|
private async getTmux(): Promise<string> {
|
||||||
|
const configured = this.settings.taskDispatch.tmuxPath;
|
||||||
|
if (configured) return configured;
|
||||||
|
|
||||||
|
const detected = await detectTmuxPath();
|
||||||
|
if (detected) return detected;
|
||||||
|
throw new Error('tmux not found. Set the path in Settings → Task Dispatch, or install with: brew install tmux');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a new session or attach to an existing one for the given task note.
|
||||||
|
* The plugin only reads task_id from frontmatter — all other task state
|
||||||
|
* is managed by the MCP server / agent inside the session.
|
||||||
|
*/
|
||||||
|
async launchOrAttach(file: TFile): Promise<void> {
|
||||||
|
const taskId = this.getTaskId(file);
|
||||||
|
if (!taskId) {
|
||||||
|
new Notice('This note is not a task note. Add task_id to frontmatter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionName = `task-${taskId}`;
|
||||||
|
const agentConfig = this.getAgentConfig();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
const exists = await this.tmuxSessionExists(tmux, sessionName);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
new Notice(`Attaching to session: ${taskId}`);
|
||||||
|
await this.openTerminal(sessionName);
|
||||||
|
} else {
|
||||||
|
new Notice(`Launching session: ${taskId}`);
|
||||||
|
|
||||||
|
const contextContent = await this.assembleContext(file, taskId);
|
||||||
|
const contextFilePath = await this.writeContextFile(taskId, contextContent);
|
||||||
|
|
||||||
|
const workingDir = this.getWorkingDir(file);
|
||||||
|
await this.createTmuxSession(tmux, sessionName, agentConfig, contextFilePath, workingDir);
|
||||||
|
await this.openTerminal(sessionName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Task dispatch error:', error);
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
new Notice(`Task dispatch failed: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill the tmux session for the given task note.
|
||||||
|
*/
|
||||||
|
async killSession(file: TFile): Promise<void> {
|
||||||
|
const taskId = this.getTaskId(file);
|
||||||
|
if (!taskId) {
|
||||||
|
new Notice('This note is not a task note. Add task_id to frontmatter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionName = `task-${taskId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
const exists = await this.tmuxSessionExists(tmux, sessionName);
|
||||||
|
if (!exists) {
|
||||||
|
new Notice(`No active session for: ${taskId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(`${tmux} kill-session -t ${this.shellEscape(sessionName)}`);
|
||||||
|
this.cleanupContextFile(taskId);
|
||||||
|
|
||||||
|
new Notice(`Killed session: ${taskId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to kill session:', error);
|
||||||
|
new Notice(`Failed to kill session: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a session by task ID (used from the session list modal).
|
||||||
|
*/
|
||||||
|
async killSessionById(taskId: string): Promise<void> {
|
||||||
|
const sessionName = `task-${taskId}`;
|
||||||
|
try {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
await execAsync(`${tmux} kill-session -t ${this.shellEscape(sessionName)}`);
|
||||||
|
this.cleanupContextFile(taskId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to kill session:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all active task tmux sessions.
|
||||||
|
*/
|
||||||
|
async listActiveSessions(): Promise<TaskSession[]> {
|
||||||
|
try {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
const { stdout } = await execAsync(
|
||||||
|
`${tmux} list-sessions -F "#{session_name} #{session_created}" 2>/dev/null`
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessions: TaskSession[] = [];
|
||||||
|
|
||||||
|
for (const line of stdout.trim().split('\n')) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
const parts = line.trim().split(' ');
|
||||||
|
const sessionName = parts[0];
|
||||||
|
const createdTimestamp = parts[1];
|
||||||
|
|
||||||
|
if (!sessionName.startsWith('task-')) continue;
|
||||||
|
|
||||||
|
const taskId = sessionName.replace('task-', '');
|
||||||
|
const noteFile = this.findTaskNote(taskId);
|
||||||
|
|
||||||
|
const startedAt = createdTimestamp
|
||||||
|
? new Date(parseInt(createdTimestamp) * 1000).toISOString()
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
taskId,
|
||||||
|
tmuxSessionName: sessionName,
|
||||||
|
startedAt,
|
||||||
|
noteFile
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions;
|
||||||
|
} catch {
|
||||||
|
// tmux not running or no sessions — return empty
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open terminal attached to a tmux session (public for use from modal).
|
||||||
|
*/
|
||||||
|
async openTerminal(sessionName: string): Promise<void> {
|
||||||
|
const tmux = await this.getTmux();
|
||||||
|
const terminalApp = this.settings.taskDispatch.terminalApp;
|
||||||
|
const attachCmd = `${tmux} attach -t ${this.shellEscape(sessionName)}`;
|
||||||
|
|
||||||
|
let osascript: string;
|
||||||
|
if (terminalApp === 'iterm2') {
|
||||||
|
osascript = `tell application "iTerm2" to create window with default profile command "${attachCmd}"`;
|
||||||
|
} else {
|
||||||
|
osascript = `tell app "Terminal" to do script "${attachCmd}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execAsync(`osascript -e '${osascript}'`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open terminal:', error);
|
||||||
|
new Notice(`Could not open ${terminalApp === 'iterm2' ? 'iTerm2' : 'Terminal.app'}. Check your terminal settings.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read task_id from frontmatter. This is the only field the plugin cares about.
|
||||||
|
*/
|
||||||
|
private getTaskId(file: TFile): string | null {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const fm = cache?.frontmatter;
|
||||||
|
const taskId = fm?.['task_id'] || fm?.['task-id'];
|
||||||
|
return taskId ? String(taskId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the working directory for a task session.
|
||||||
|
* Priority: `working_dir` frontmatter → defaultWorkingDir setting → home dir.
|
||||||
|
*/
|
||||||
|
private getWorkingDir(file: TFile): string {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const fm = cache?.frontmatter;
|
||||||
|
const workingDir = fm?.['working_dir'] || fm?.['working-dir'];
|
||||||
|
|
||||||
|
if (workingDir && typeof workingDir === 'string') {
|
||||||
|
// 1. Check if it matches a named repo path
|
||||||
|
const repoMatch = resolveRepoPath(workingDir, this.settings.taskDispatch.repoPaths);
|
||||||
|
if (repoMatch) return repoMatch;
|
||||||
|
|
||||||
|
// 2. Absolute path — use as-is
|
||||||
|
if (path.isAbsolute(workingDir)) return workingDir;
|
||||||
|
|
||||||
|
// 3. Relative path — resolve against vault root
|
||||||
|
return this.resolveVaultPath(workingDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDir = this.settings.taskDispatch.defaultWorkingDir;
|
||||||
|
if (defaultDir) {
|
||||||
|
return path.isAbsolute(defaultDir) ? defaultDir : this.resolveVaultPath(defaultDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.HOME ?? '/tmp';
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveVaultPath(relative: string): string {
|
||||||
|
const adapter = this.app.vault.adapter as FileSystemAdapter;
|
||||||
|
return path.join(adapter.getBasePath(), relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assembleContext(file: TFile, taskId: string): Promise<string> {
|
||||||
|
// Read note body and strip frontmatter
|
||||||
|
const rawContent = await this.app.vault.read(file);
|
||||||
|
const noteBody = rawContent.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
|
||||||
|
|
||||||
|
// Get linked notes and aggregate their content
|
||||||
|
const linkedFiles = await this.distillService.getLinkedNotes(file);
|
||||||
|
let linkedContent = '';
|
||||||
|
|
||||||
|
if (linkedFiles.length > 0) {
|
||||||
|
const { content } = await this.distillService.aggregateContent(linkedFiles);
|
||||||
|
linkedContent = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
let context = `# Task: ${taskId}\n\n---\n\n## Task Note\n\n${noteBody}`;
|
||||||
|
|
||||||
|
if (linkedContent) {
|
||||||
|
context += `\n\n---\n\n## Linked Context\n${linkedContent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
context += `\n\n---\n\n## Instructions\n\nTask file: ${file.path}\nTask ID: ${taskId}\n\nWork with the context above first. Only search the vault via MCP if needed.\n\nWhen you have results, use the MCP append_results tool to write them back to the task note.\nThe ## Results section of the task note is our shared communication channel.\nLink any files you create as [[wikilinks]] in your results.\n\nYou have MCP tools available for searching the user's Obsidian vault (semantic search, tag search, hub discovery, etc.) and for writing results back. Use them to find related notes, look up referenced concepts, or gather additional context when the information above is insufficient.`;
|
||||||
|
|
||||||
|
// Cap at max characters
|
||||||
|
const maxChars = this.settings.taskDispatch.maxContextChars;
|
||||||
|
if (context.length > maxChars) {
|
||||||
|
context = context.substring(0, maxChars) + '\n\n...(context truncated at character limit)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeContextFile(taskId: string, content: string): Promise<string> {
|
||||||
|
const dir = this.settings.taskDispatch.contextTempDir;
|
||||||
|
const filePath = path.join(dir, `task-${taskId}-context.md`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, content, 'utf-8');
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupContextFile(taskId: string): void {
|
||||||
|
const filePath = path.join(
|
||||||
|
this.settings.taskDispatch.contextTempDir,
|
||||||
|
`task-${taskId}-context.md`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical, ignore cleanup failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tmuxSessionExists(tmux: string, sessionName: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execAsync(`${tmux} has-session -t ${this.shellEscape(sessionName)} 2>/dev/null`);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createTmuxSession(
|
||||||
|
tmux: string,
|
||||||
|
sessionName: string,
|
||||||
|
agentConfig: AgentConfig,
|
||||||
|
contextFilePath: string,
|
||||||
|
workingDir: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Ensure the working directory exists before launching the session.
|
||||||
|
await fs.promises.mkdir(workingDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create session with a normal login shell so the user's PATH is available.
|
||||||
|
await execAsync(`${tmux} new-session -d -s ${this.shellEscape(sessionName)} -c ${this.shellEscape(workingDir)}`);
|
||||||
|
|
||||||
|
// Wait for the shell prompt to appear before sending keys.
|
||||||
|
// Without this, send-keys can fire before the shell is ready and characters get lost.
|
||||||
|
await this.waitForShellReady(tmux, sessionName);
|
||||||
|
|
||||||
|
// Pass the context file contents as the initial user prompt via $(cat ...).
|
||||||
|
const agentCmd = `cd ${this.shellEscape(workingDir)} && ${agentConfig.command} "$(cat ${this.shellEscape(contextFilePath)})"`;
|
||||||
|
await execAsync(
|
||||||
|
`${tmux} send-keys -t ${this.shellEscape(sessionName)} ${this.shellEscape(agentCmd)} Enter`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAgentConfig(): AgentConfig {
|
||||||
|
const agents = this.settings.taskDispatch.agents;
|
||||||
|
const id = this.settings.taskDispatch.defaultAgent;
|
||||||
|
return agents.find(a => a.id === id) || agents[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTaskNote(taskId: string): TFile | undefined {
|
||||||
|
const files = this.app.vault.getMarkdownFiles();
|
||||||
|
for (const file of files) {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const fm = cache?.frontmatter;
|
||||||
|
if (fm?.['task_id'] === taskId || fm?.['task-id'] === taskId) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll the tmux pane until the shell has printed something (i.e. the prompt),
|
||||||
|
* indicating it's ready to receive input.
|
||||||
|
*/
|
||||||
|
private async waitForShellReady(tmux: string, sessionName: string, maxAttempts = 10): Promise<void> {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(
|
||||||
|
`${tmux} capture-pane -t ${this.shellEscape(sessionName)} -p`
|
||||||
|
);
|
||||||
|
// Once the pane has any non-empty content, the shell prompt is up.
|
||||||
|
if (stdout.trim().length > 0) return;
|
||||||
|
} catch {
|
||||||
|
// Session not ready yet, keep waiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we exhausted attempts, proceed anyway — better than hanging forever.
|
||||||
|
}
|
||||||
|
|
||||||
|
private shellEscape(str: string): string {
|
||||||
|
return `'${str.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/services/task-file-service.ts
Normal file
160
src/services/task-file-service.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { App } from 'obsidian';
|
||||||
|
import { createFileWithCollisionHandling } from '../utils/filename-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TaskFileService — writes `status: pending` task files to OpenAugi/Tasks/
|
||||||
|
* via the Obsidian vault API. No shell-out, no HTTP, mobile-safe.
|
||||||
|
*
|
||||||
|
* The task file IS the trigger contract: the OpenAugi task watcher
|
||||||
|
* (`openaugi up` in the parent repo) polls OpenAugi/Tasks/ for pending
|
||||||
|
* files and launches an agent session for each. This service, the
|
||||||
|
* `openaugi review` CLI, and the zzz capture grammar all converge on the
|
||||||
|
* same file format, defined authoritatively in the parent repo at
|
||||||
|
* `src/openaugi/templates/task-template.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Where the task watcher looks for pending task files (vault-relative). */
|
||||||
|
export const TASKS_FOLDER = 'OpenAugi/Tasks';
|
||||||
|
|
||||||
|
/** Frontmatter marker identifying tasks written by this plugin. */
|
||||||
|
export const SOURCE_BLOCK_ID = 'obsidian-plugin';
|
||||||
|
|
||||||
|
export interface TaskFileOptions {
|
||||||
|
/** Human-readable task title — becomes the `# heading` and filename slug. */
|
||||||
|
title: string;
|
||||||
|
/** Verbatim content for the `## Context` section. */
|
||||||
|
context: string;
|
||||||
|
/** The literal user directive, block-quoted under `## User instruction`. */
|
||||||
|
instruction: string;
|
||||||
|
/** Self-contained description for the `## Task` section (the agent's real prompt). */
|
||||||
|
task: string;
|
||||||
|
/** Wikilink target for `source_note` frontmatter (note title, no brackets). */
|
||||||
|
sourceNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip YAML frontmatter from note content, if present. */
|
||||||
|
export function stripFrontmatter(content: string): string {
|
||||||
|
return content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase, filename-safe slug for the task filename. */
|
||||||
|
export function taskFileSlug(title: string, maxLen = 50): string {
|
||||||
|
const slug = title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return slug.slice(0, maxLen).replace(/-+$/, '') || 'task';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYYMMDD-HHMMSS local timestamp, matching the `openaugi review` CLI. */
|
||||||
|
export function taskFileTimestamp(now: Date = new Date()): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
||||||
|
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full task-file markdown per the task-template contract.
|
||||||
|
* Section order matters: the watcher hydrates around `## Results` and the
|
||||||
|
* agent treats `## Task` as its prompt.
|
||||||
|
*/
|
||||||
|
export function buildTaskFileContent(opts: TaskFileOptions): string {
|
||||||
|
return `---
|
||||||
|
status: pending
|
||||||
|
source_block_id: ${SOURCE_BLOCK_ID}
|
||||||
|
source_note: "[[${opts.sourceNote}]]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${opts.title}
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
${opts.context.trim()}
|
||||||
|
|
||||||
|
## User instruction
|
||||||
|
|
||||||
|
> ${opts.instruction}
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
${opts.task.trim()}
|
||||||
|
|
||||||
|
## Human Todo
|
||||||
|
|
||||||
|
## Results
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaskFileService {
|
||||||
|
constructor(private app: App) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a full review pass. The watcher-launched agent reads
|
||||||
|
* OpenAugi/AGENT/review-pass.md and runs the whole loop.
|
||||||
|
*/
|
||||||
|
async createReviewPassTask(now: Date = new Date()): Promise<string> {
|
||||||
|
const instruction = 'run the review pass';
|
||||||
|
return this.writeTask({
|
||||||
|
title: instruction,
|
||||||
|
context: `Triggered via the OpenAugi plugin command "Augi: Run review pass" at ${taskFileTimestamp(now)}.`,
|
||||||
|
instruction,
|
||||||
|
task: `Read OpenAugi/AGENT/review-pass.md and execute: ${instruction}.`,
|
||||||
|
sourceNote: 'OpenAugi plugin',
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue dashboard processing only — execute nomination answers,
|
||||||
|
* no new-block routing.
|
||||||
|
*/
|
||||||
|
async createProcessDashboardTask(now: Date = new Date()): Promise<string> {
|
||||||
|
const instruction = 'process the dashboard';
|
||||||
|
return this.writeTask({
|
||||||
|
title: instruction,
|
||||||
|
context: `Triggered via the OpenAugi plugin command "Augi: Process dashboard" at ${taskFileTimestamp(now)}.`,
|
||||||
|
instruction,
|
||||||
|
task: `Read OpenAugi/AGENT/review-pass.md and execute: ${instruction}.`,
|
||||||
|
sourceNote: 'OpenAugi plugin',
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a distill task for the given content (current selection or
|
||||||
|
* active note body). The content goes into `## Context` verbatim; the
|
||||||
|
* agent applies the distill lens to exactly that scope.
|
||||||
|
*/
|
||||||
|
async createDistillTask(context: string, sourceNote: string, now: Date = new Date()): Promise<string> {
|
||||||
|
return this.writeTask({
|
||||||
|
title: `distill ${sourceNote}`,
|
||||||
|
context,
|
||||||
|
instruction: 'distill this per OpenAugi/AGENT/distill-lens.md',
|
||||||
|
task:
|
||||||
|
'Read OpenAugi/AGENT/distill-lens.md and apply the distill lens to the ' +
|
||||||
|
'content in the ## Context section above. That content is the entire ' +
|
||||||
|
'scope — do not pull in additional notes.',
|
||||||
|
sourceNote,
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure OpenAugi/Tasks/ exists and write the task file into it. */
|
||||||
|
private async writeTask(opts: TaskFileOptions, now: Date): Promise<string> {
|
||||||
|
// Create ancestors one level at a time — createFolder is not
|
||||||
|
// guaranteed to be recursive on all platforms.
|
||||||
|
let folder = '';
|
||||||
|
for (const segment of TASKS_FOLDER.split('/')) {
|
||||||
|
folder = folder ? `${folder}/${segment}` : segment;
|
||||||
|
if (!(await this.app.vault.adapter.exists(folder))) {
|
||||||
|
await this.app.vault.createFolder(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const filename = `${taskFileSlug(opts.title)}-${taskFileTimestamp(now)}.md`;
|
||||||
|
const content = buildTaskFileContent(opts);
|
||||||
|
return createFileWithCollisionHandling(
|
||||||
|
this.app.vault,
|
||||||
|
`${TASKS_FOLDER}/${filename}`,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,15 @@ import { Plugin } from 'obsidian';
|
||||||
import { OpenAugiSettings } from './settings';
|
import { OpenAugiSettings } from './settings';
|
||||||
import { OpenAIService } from '../services/openai-service';
|
import { OpenAIService } from '../services/openai-service';
|
||||||
import { FileService } from '../services/file-service';
|
import { FileService } from '../services/file-service';
|
||||||
|
import type { TaskDispatchService } from '../services/task-dispatch-service';
|
||||||
|
import { TaskFileService } from '../services/task-file-service';
|
||||||
|
|
||||||
export default interface OpenAugiPlugin extends Plugin {
|
export default interface OpenAugiPlugin extends Plugin {
|
||||||
settings: OpenAugiSettings;
|
settings: OpenAugiSettings;
|
||||||
openAIService: OpenAIService;
|
openAIService: OpenAIService;
|
||||||
fileService: FileService;
|
fileService: FileService;
|
||||||
|
taskFileService: TaskFileService;
|
||||||
|
/** @deprecated Task Dispatch bypasses the task watcher — use TaskFileService. */
|
||||||
|
taskDispatchService: TaskDispatchService | null;
|
||||||
saveSettings(): Promise<void>;
|
saveSettings(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -1,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;
|
||||||
|
|
@ -28,6 +29,7 @@ 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 = {
|
||||||
|
|
@ -53,5 +55,22 @@ export const DEFAULT_SETTINGS: OpenAugiSettings = {
|
||||||
filterRecentSectionsOnly: true,
|
filterRecentSectionsOnly: true,
|
||||||
includeBacklinks: true,
|
includeBacklinks: true,
|
||||||
backlinkContextLines: 0
|
backlinkContextLines: 0
|
||||||
|
},
|
||||||
|
taskDispatch: {
|
||||||
|
terminalApp: 'iterm2',
|
||||||
|
tmuxPath: '',
|
||||||
|
defaultWorkingDir: 'OpenAugi/Tasks',
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
id: 'claude-code',
|
||||||
|
name: 'Claude Code',
|
||||||
|
command: 'claude',
|
||||||
|
contextFlag: '--append-system-prompt'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaultAgent: 'claude-code',
|
||||||
|
contextTempDir: '/tmp/openaugi',
|
||||||
|
maxContextChars: 200000,
|
||||||
|
repoPaths: []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
33
src/types/task-dispatch.ts
Normal file
33
src/types/task-dispatch.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { TFile } from 'obsidian';
|
||||||
|
|
||||||
|
export interface AgentConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
contextFlag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoPath {
|
||||||
|
name: string; // short name used in frontmatter, e.g. "my-repo"
|
||||||
|
path: string; // absolute filesystem path, e.g. "/Users/chris/repos/my-repo"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TerminalApp = 'iterm2' | 'terminal-app';
|
||||||
|
|
||||||
|
export interface TaskDispatchSettings {
|
||||||
|
terminalApp: TerminalApp;
|
||||||
|
tmuxPath: string; // absolute path to tmux binary; empty = auto-detect
|
||||||
|
defaultWorkingDir: string; // directory Claude launches in; overridden by `working_dir` frontmatter
|
||||||
|
agents: AgentConfig[];
|
||||||
|
defaultAgent: string;
|
||||||
|
contextTempDir: string;
|
||||||
|
maxContextChars: number;
|
||||||
|
repoPaths: RepoPath[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskSession {
|
||||||
|
taskId: string;
|
||||||
|
tmuxSessionName: string;
|
||||||
|
startedAt: string;
|
||||||
|
noteFile?: TFile;
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
82
src/ui/session-list-modal.ts
Normal file
82
src/ui/session-list-modal.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { App, Modal, Setting } from 'obsidian';
|
||||||
|
import { TaskSession } from '../types/task-dispatch';
|
||||||
|
|
||||||
|
export class SessionListModal extends Modal {
|
||||||
|
private sessions: TaskSession[];
|
||||||
|
private onAttach: (session: TaskSession) => void;
|
||||||
|
private onKill: (session: TaskSession) => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
sessions: TaskSession[],
|
||||||
|
onAttach: (session: TaskSession) => void,
|
||||||
|
onKill: (session: TaskSession) => void
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.sessions = sessions;
|
||||||
|
this.onAttach = onAttach;
|
||||||
|
this.onKill = onKill;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.createEl('h2', { text: 'Active Task Sessions' });
|
||||||
|
|
||||||
|
if (this.sessions.length === 0) {
|
||||||
|
contentEl.createEl('p', { text: 'No active task sessions found.' });
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Close')
|
||||||
|
.onClick(() => this.close())
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of this.sessions) {
|
||||||
|
const elapsed = this.formatElapsed(session.startedAt);
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName(session.taskId)
|
||||||
|
.setDesc(`Started ${elapsed}`)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Attach')
|
||||||
|
.setCta()
|
||||||
|
.onClick(() => {
|
||||||
|
this.onAttach(session);
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Kill')
|
||||||
|
.setWarning()
|
||||||
|
.onClick(() => {
|
||||||
|
this.onKill(session);
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(button => button
|
||||||
|
.setButtonText('Close')
|
||||||
|
.onClick(() => this.close())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatElapsed(isoTimestamp: string): string {
|
||||||
|
if (isoTimestamp === 'unknown') return 'unknown';
|
||||||
|
const ms = Date.now() - new Date(isoTimestamp).getTime();
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { App, PluginSettingTab, Setting, Notice, DropdownComponent } 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 { 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;
|
||||||
|
|
@ -350,6 +353,134 @@ export class OpenAugiSettingTab extends PluginSettingTab {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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' });
|
||||||
|
|
||||||
|
|
@ -364,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
264
tests/context-gathering-service.test.ts
Normal file
264
tests/context-gathering-service.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { ContextGatheringService } from '../src/services/context-gathering-service';
|
||||||
|
import { DistillService } from '../src/services/distill-service';
|
||||||
|
import { OpenAIService } from '../src/services/openai-service';
|
||||||
|
import { DEFAULT_SETTINGS } from '../src/types/settings';
|
||||||
|
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const VAULT_DIR = path.resolve(__dirname, 'vault');
|
||||||
|
|
||||||
|
describe('ContextGatheringService', () => {
|
||||||
|
let app: ReturnType<typeof createMockApp>;
|
||||||
|
let distillService: DistillService;
|
||||||
|
let service: ContextGatheringService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createMockApp(VAULT_DIR);
|
||||||
|
const openAIService = new OpenAIService('test-key', 'gpt-5');
|
||||||
|
distillService = new DistillService(app as any, openAIService, DEFAULT_SETTINGS);
|
||||||
|
service = new ContextGatheringService(app as any, distillService, DEFAULT_SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gatherContext - linked-notes mode', () => {
|
||||||
|
it('discovers root note and its forward links at depth 1', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = result.notes.map(n => n.file.basename);
|
||||||
|
expect(names).toContain('Root Note');
|
||||||
|
expect(names).toContain('Linked Note A');
|
||||||
|
expect(names).toContain('Linked Note B');
|
||||||
|
expect(result.totalNotes).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects depth limit', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// At depth 1, we should NOT see "Deep Note" (which is depth 2: Root → A → Deep)
|
||||||
|
const names = result.notes.map(n => n.file.basename);
|
||||||
|
expect(names).not.toContain('Deep Note');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers deeper links at depth 2', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 2,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// At depth 2, "Deep Note" should be discovered (Root → Linked Note A → Deep Note)
|
||||||
|
const names = result.notes.map(n => n.file.basename);
|
||||||
|
expect(names).toContain('Deep Note');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes backlinks when enabled', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: true,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = result.notes.map(n => n.file.basename);
|
||||||
|
// "Backlink Source" links TO Root Note, so should be discovered
|
||||||
|
expect(names).toContain('Backlink Source');
|
||||||
|
|
||||||
|
// Verify the backlink note is marked as such
|
||||||
|
const backlinkNote = result.notes.find(n => n.file.basename === 'Backlink Source');
|
||||||
|
expect(backlinkNote?.isBacklink).toBe(true);
|
||||||
|
expect(backlinkNote?.discoveredVia).toContain('backlink');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects character limit', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
// Use a very small character limit
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 2,
|
||||||
|
maxCharacters: 200, // Very small — root note alone should exceed this
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Some notes should be excluded due to size limit
|
||||||
|
const excludedNotes = result.notes.filter(n => !n.included);
|
||||||
|
expect(excludedNotes.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when root note is missing in linked-notes mode', async () => {
|
||||||
|
await expect(
|
||||||
|
service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Root note required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('folder exclusion', () => {
|
||||||
|
it('excludes notes from specified folders', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 3,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: ['Excluded Folder'],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: true,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const includedNames = result.notes
|
||||||
|
.filter(n => n.included)
|
||||||
|
.map(n => n.file.basename);
|
||||||
|
expect(includedNames).not.toContain('Should Skip');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aggregateContent', () => {
|
||||||
|
it('aggregates forward links and backlinks separately', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const gathered = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: true,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gathered.aggregatedContent).toContain('# Note:');
|
||||||
|
expect(gathered.totalCharacters).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes backlink snippets in aggregated content', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const gathered = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: true,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backlink content should be in the aggregated output
|
||||||
|
if (gathered.notes.some(n => n.isBacklink)) {
|
||||||
|
expect(gathered.aggregatedContent).toContain('# Backlink:');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gatherContext - recent-activity mode', () => {
|
||||||
|
it('throws when time window is missing', async () => {
|
||||||
|
await expect(
|
||||||
|
service.gatherContext({
|
||||||
|
sourceMode: 'recent-activity',
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Time window required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('note metadata', () => {
|
||||||
|
it('assigns correct depth to discovered notes', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 2,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootEntry = result.notes.find(n => n.file.basename === 'Root Note');
|
||||||
|
expect(rootEntry?.depth).toBe(0);
|
||||||
|
|
||||||
|
const linkedA = result.notes.find(n => n.file.basename === 'Linked Note A');
|
||||||
|
expect(linkedA?.depth).toBe(1);
|
||||||
|
|
||||||
|
const deepNote = result.notes.find(n => n.file.basename === 'Deep Note');
|
||||||
|
if (deepNote) {
|
||||||
|
expect(deepNote.depth).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes discoveredVia metadata', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
|
||||||
|
const result = await service.gatherContext({
|
||||||
|
sourceMode: 'linked-notes',
|
||||||
|
rootNote: rootFile,
|
||||||
|
linkDepth: 1,
|
||||||
|
maxCharacters: 100000,
|
||||||
|
excludeFolders: [],
|
||||||
|
filterRecentSectionsOnly: false,
|
||||||
|
includeBacklinks: false,
|
||||||
|
backlinkContextLines: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootEntry = result.notes.find(n => n.file.basename === 'Root Note');
|
||||||
|
expect(rootEntry?.discoveredVia).toBe('root');
|
||||||
|
|
||||||
|
const linkedEntry = result.notes.find(n => n.file.basename === 'Linked Note A');
|
||||||
|
expect(linkedEntry?.discoveredVia).toContain('linked from');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
248
tests/distill-service.test.ts
Normal file
248
tests/distill-service.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { DistillService } from '../src/services/distill-service';
|
||||||
|
import { OpenAIService } from '../src/services/openai-service';
|
||||||
|
import { DEFAULT_SETTINGS } from '../src/types/settings';
|
||||||
|
import { createMockApp, createTestTFile } from './mocks/obsidian-mock';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const VAULT_DIR = path.resolve(__dirname, 'vault');
|
||||||
|
|
||||||
|
describe('DistillService', () => {
|
||||||
|
let app: ReturnType<typeof createMockApp>;
|
||||||
|
let openAIService: OpenAIService;
|
||||||
|
let service: DistillService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createMockApp(VAULT_DIR);
|
||||||
|
openAIService = new OpenAIService('test-key', 'gpt-5');
|
||||||
|
service = new DistillService(app as any, openAIService, DEFAULT_SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLinkedNotes', () => {
|
||||||
|
it('extracts forward links from a note', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(rootFile);
|
||||||
|
|
||||||
|
const basenames = linked.map(f => f.basename);
|
||||||
|
expect(basenames).toContain('Linked Note A');
|
||||||
|
expect(basenames).toContain('Linked Note B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include the root file itself', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(rootFile);
|
||||||
|
|
||||||
|
const paths = linked.map(f => f.path);
|
||||||
|
expect(paths).not.toContain('Root Note.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates linked files', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(rootFile);
|
||||||
|
|
||||||
|
const paths = linked.map(f => f.path);
|
||||||
|
const uniquePaths = [...new Set(paths)];
|
||||||
|
expect(paths.length).toBe(uniquePaths.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts only checked items from collection notes', async () => {
|
||||||
|
const collectionFile = createTestTFile(VAULT_DIR, 'Collection Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(collectionFile);
|
||||||
|
|
||||||
|
const basenames = linked.map(f => f.basename);
|
||||||
|
// Only [x] items should be included
|
||||||
|
expect(basenames).toContain('Linked Note A');
|
||||||
|
expect(basenames).toContain('Journal Note');
|
||||||
|
// Unchecked items should NOT be included
|
||||||
|
expect(basenames).not.toContain('Linked Note B');
|
||||||
|
expect(basenames).not.toContain('Backlink Source');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles notes with no links', async () => {
|
||||||
|
const deepNote = createTestTFile(VAULT_DIR, 'Deeply Linked/Deep Note.md');
|
||||||
|
const linked = await service.getLinkedNotes(deepNote);
|
||||||
|
expect(linked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBacklinksForFile', () => {
|
||||||
|
it('finds notes that link TO the target file', () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const backlinks = service.getBacklinksForFile(rootFile);
|
||||||
|
|
||||||
|
const basenames = backlinks.map(f => f.basename);
|
||||||
|
expect(basenames).toContain('Backlink Source');
|
||||||
|
expect(basenames).toContain('Special Characters!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for notes with no backlinks', () => {
|
||||||
|
const excludedFile = createTestTFile(VAULT_DIR, 'Excluded Folder/Should Skip.md');
|
||||||
|
const backlinks = service.getBacklinksForFile(excludedFile);
|
||||||
|
expect(backlinks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBacklinkSnippets', () => {
|
||||||
|
it('extracts context around backlink references', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const backlinkSource = createTestTFile(VAULT_DIR, 'Backlink Source.md');
|
||||||
|
|
||||||
|
const snippets = await service.getBacklinkSnippets(rootFile, backlinkSource, 0);
|
||||||
|
|
||||||
|
expect(snippets.length).toBeGreaterThan(0);
|
||||||
|
// Header section mode (0) should include the section header
|
||||||
|
expect(snippets[0].snippet).toContain('Section About Root');
|
||||||
|
expect(snippets[0].snippet).toContain('Root Note');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for files with no links to target', async () => {
|
||||||
|
const rootFile = createTestTFile(VAULT_DIR, 'Root Note.md');
|
||||||
|
const deepNote = createTestTFile(VAULT_DIR, 'Deeply Linked/Deep Note.md');
|
||||||
|
|
||||||
|
const snippets = await service.getBacklinkSnippets(rootFile, deepNote, 0);
|
||||||
|
expect(snippets).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aggregateContent', () => {
|
||||||
|
it('combines content from multiple files', async () => {
|
||||||
|
const files = [
|
||||||
|
createTestTFile(VAULT_DIR, 'Linked Note A.md'),
|
||||||
|
createTestTFile(VAULT_DIR, 'Linked Note B.md'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await service.aggregateContent(files);
|
||||||
|
|
||||||
|
expect(result.content).toContain('# Note: Linked Note A');
|
||||||
|
expect(result.content).toContain('# Note: Linked Note B');
|
||||||
|
expect(result.content).toContain('atomic note-taking');
|
||||||
|
expect(result.content).toContain('Zettelkasten');
|
||||||
|
expect(result.sourceNotes).toContain('Linked Note A');
|
||||||
|
expect(result.sourceNotes).toContain('Linked Note B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates files by path', async () => {
|
||||||
|
const fileA = createTestTFile(VAULT_DIR, 'Linked Note A.md');
|
||||||
|
const fileDuplicate = createTestTFile(VAULT_DIR, 'Linked Note A.md');
|
||||||
|
|
||||||
|
const result = await service.aggregateContent([fileA, fileDuplicate]);
|
||||||
|
|
||||||
|
// Should only appear once
|
||||||
|
const occurrences = (result.content.match(/# Note: Linked Note A/g) || []).length;
|
||||||
|
expect(occurrences).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips dataview queries from output', async () => {
|
||||||
|
const files = [createTestTFile(VAULT_DIR, 'Dataview Note.md')];
|
||||||
|
const result = await service.aggregateContent(files);
|
||||||
|
|
||||||
|
expect(result.content).not.toContain('```dataview');
|
||||||
|
expect(result.content).not.toContain('TABLE file.mtime');
|
||||||
|
expect(result.content).toContain('Regular content after the dataview block');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('journal date filtering', () => {
|
||||||
|
it('identifies journal-style notes with date headers', () => {
|
||||||
|
// Access private method via any cast
|
||||||
|
const content = '# Daily\n\n### 2026-02-25\nToday entry\n\n### 2026-02-20\nOlder entry';
|
||||||
|
const result = (service as any).isJournalStyleNote(content);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-journal notes', () => {
|
||||||
|
const content = '# Regular Note\n\nJust some content without date headers.';
|
||||||
|
const result = (service as any).isJournalStyleNote(content);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts date sections correctly', () => {
|
||||||
|
const content = '# Header\n\n### 2026-02-25\nRecent\n\n### 2026-01-15\nOlder';
|
||||||
|
const sections = (service as any).getDateSections(content);
|
||||||
|
|
||||||
|
expect(sections.length).toBe(3); // header + 2 dated sections
|
||||||
|
expect(sections[0].date).toBeNull(); // undated header
|
||||||
|
expect(sections[1].date).toBeInstanceOf(Date);
|
||||||
|
expect(sections[2].date).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters content by date range', () => {
|
||||||
|
// Pin "now" so the fixed fixture dates are deterministic regardless of the
|
||||||
|
// real current date. extractContentByDateRange uses new Date() as "now".
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-02-28T12:00:00Z'));
|
||||||
|
try {
|
||||||
|
const content = '# Daily Journal\n\n### 2026-02-25\nRecent entry\n\n### 2025-01-01\nVery old entry';
|
||||||
|
const filtered = (service as any).extractContentByDateRange(content, 30);
|
||||||
|
|
||||||
|
expect(filtered).toContain('Recent entry');
|
||||||
|
expect(filtered).not.toContain('Very old entry');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractDateFromFilename', () => {
|
||||||
|
it('extracts date from YYYY-MM-DD prefix', () => {
|
||||||
|
const date = (service as any).extractDateFromFilename('2026-02-25 My Note');
|
||||||
|
expect(date).toBeInstanceOf(Date);
|
||||||
|
expect(date.getFullYear()).toBe(2026);
|
||||||
|
expect(date.getMonth()).toBe(1); // 0-indexed
|
||||||
|
expect(date.getDate()).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-date filenames', () => {
|
||||||
|
const date = (service as any).extractDateFromFilename('My Regular Note');
|
||||||
|
expect(date).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dataview query handling', () => {
|
||||||
|
it('detects dataview queries in content', () => {
|
||||||
|
const content = 'Some text\n\n```dataview\nLIST FROM #tag\n```\n\nMore text';
|
||||||
|
expect((service as any).containsDataviewQuery(content)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for content without dataview', () => {
|
||||||
|
const content = 'Regular markdown content\n\n```javascript\nconsole.log("hi")\n```';
|
||||||
|
expect((service as any).containsDataviewQuery(content)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts dataview query strings', () => {
|
||||||
|
const content = 'Text\n\n```dataview\nLIST FROM #tag\n```\n\n```dataview\nTABLE file.name\n```';
|
||||||
|
const queries = (service as any).extractDataviewQueries(content);
|
||||||
|
expect(queries).toHaveLength(2);
|
||||||
|
expect(queries[0]).toContain('LIST FROM #tag');
|
||||||
|
expect(queries[1]).toContain('TABLE file.name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips dataview queries from content', () => {
|
||||||
|
const content = 'Before\n\n```dataview\nLIST\n```\nAfter';
|
||||||
|
const stripped = (service as any).stripDataviewQueries(content);
|
||||||
|
expect(stripped).not.toContain('```dataview');
|
||||||
|
expect(stripped).toContain('Before');
|
||||||
|
expect(stripped).toContain('After');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tag stripping', () => {
|
||||||
|
it('strips simple tags', () => {
|
||||||
|
const result = (service as any).stripTags('Some text #tag more text');
|
||||||
|
expect(result).not.toContain('#tag');
|
||||||
|
expect(result).toContain('Some text');
|
||||||
|
expect(result).toContain('more text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips nested tags', () => {
|
||||||
|
const result = (service as any).stripTags('Content #nested/tag here');
|
||||||
|
expect(result).not.toContain('#nested/tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves markdown headers', () => {
|
||||||
|
const result = (service as any).stripTags('## Header\n\nContent #tag');
|
||||||
|
expect(result).toContain('## Header');
|
||||||
|
expect(result).not.toContain('#tag');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
217
tests/file-service.test.ts
Normal file
217
tests/file-service.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { FileService } from '../src/services/file-service';
|
||||||
|
import { MockApp } from './mocks/obsidian-mock';
|
||||||
|
import { TranscriptResponse, DistillResponse } from '../src/types/transcript';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
|
||||||
|
let testCounter = 0;
|
||||||
|
|
||||||
|
describe('FileService', () => {
|
||||||
|
let app: MockApp;
|
||||||
|
let service: FileService;
|
||||||
|
let outputDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Use unique subdirectory per test to avoid parallel cleanup races
|
||||||
|
testCounter++;
|
||||||
|
outputDir = path.join(BASE_OUTPUT_DIR, `run-${testCounter}-${Date.now()}`);
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
app = new MockApp(outputDir);
|
||||||
|
service = new FileService(
|
||||||
|
app as any,
|
||||||
|
'OpenAugi/Summaries',
|
||||||
|
'OpenAugi/Notes',
|
||||||
|
'OpenAugi/Published'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ensureDirectoriesExist', () => {
|
||||||
|
it('creates all output directories', async () => {
|
||||||
|
await service.ensureDirectoriesExist();
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Summaries'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Notes'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Published'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent (safe to call multiple times)', async () => {
|
||||||
|
await service.ensureDirectoriesExist();
|
||||||
|
await service.ensureDirectoriesExist();
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(outputDir, 'OpenAugi/Summaries'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeTranscriptFiles', () => {
|
||||||
|
const mockTranscriptData: TranscriptResponse = {
|
||||||
|
summary: 'This transcript discusses [[Atomic Notes]] and [[Knowledge Management]].',
|
||||||
|
notes: [
|
||||||
|
{ title: 'Atomic Notes', content: 'Each note should contain one idea. See [[Knowledge Management]].' },
|
||||||
|
{ title: 'Knowledge Management', content: 'Systems for organizing knowledge. Related to [[Atomic Notes]].' },
|
||||||
|
],
|
||||||
|
tasks: [
|
||||||
|
'- [ ] Review [[Atomic Notes]] methodology',
|
||||||
|
'- [ ] Set up Zettelkasten system',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates summary file with backlinks', async () => {
|
||||||
|
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||||
|
|
||||||
|
const summaryPath = path.join(outputDir, 'OpenAugi/Summaries/My Transcript - summary.md');
|
||||||
|
expect(fs.existsSync(summaryPath)).toBe(true);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(summaryPath, 'utf-8');
|
||||||
|
expect(content).toContain('Atomic Notes');
|
||||||
|
expect(content).toContain('Knowledge Management');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates atomic note files in session folder', async () => {
|
||||||
|
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||||
|
|
||||||
|
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||||
|
const sessionFolders = fs.readdirSync(notesDir);
|
||||||
|
expect(sessionFolders.length).toBe(1);
|
||||||
|
expect(sessionFolders[0]).toMatch(/^Transcript \d{4}-\d{2}-\d{2}/);
|
||||||
|
|
||||||
|
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||||
|
const noteFiles = fs.readdirSync(sessionPath);
|
||||||
|
expect(noteFiles).toContain('Atomic Notes.md');
|
||||||
|
expect(noteFiles).toContain('Knowledge Management.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes tasks section in summary', async () => {
|
||||||
|
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||||
|
|
||||||
|
const summaryPath = path.join(outputDir, 'OpenAugi/Summaries/My Transcript - summary.md');
|
||||||
|
const content = fs.readFileSync(summaryPath, 'utf-8');
|
||||||
|
expect(content).toContain('## Tasks');
|
||||||
|
expect(content).toContain('Review');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes backlinks in note content', async () => {
|
||||||
|
await service.writeTranscriptFiles('My Transcript', mockTranscriptData);
|
||||||
|
|
||||||
|
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||||
|
const sessionFolders = fs.readdirSync(notesDir);
|
||||||
|
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||||
|
|
||||||
|
const atomicContent = fs.readFileSync(path.join(sessionPath, 'Atomic Notes.md'), 'utf-8');
|
||||||
|
// Backlinks should reference sanitized filenames
|
||||||
|
expect(atomicContent).toContain('[[Knowledge Management]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes filenames with special characters', async () => {
|
||||||
|
const dataWithSpecialChars: TranscriptResponse = {
|
||||||
|
summary: 'Summary of [[Note: Special]]',
|
||||||
|
notes: [
|
||||||
|
{ title: 'Note: Special', content: 'Content with special title' },
|
||||||
|
],
|
||||||
|
tasks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.writeTranscriptFiles('Test', dataWithSpecialChars);
|
||||||
|
|
||||||
|
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||||
|
const sessionFolders = fs.readdirSync(notesDir);
|
||||||
|
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||||
|
const noteFiles = fs.readdirSync(sessionPath);
|
||||||
|
|
||||||
|
// Colon should be sanitized
|
||||||
|
expect(noteFiles.some(f => f.includes(':'))).toBe(false);
|
||||||
|
expect(noteFiles.some(f => f.includes('Note'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeDistilledFiles', () => {
|
||||||
|
const mockDistillData: DistillResponse = {
|
||||||
|
summary: 'Distilled insights about [[Topic A]] and [[Topic B]].',
|
||||||
|
notes: [
|
||||||
|
{ title: 'Topic A', content: 'Deep analysis of A' },
|
||||||
|
{ title: 'Topic B', content: 'Overview of B, see also [[Topic A]]' },
|
||||||
|
],
|
||||||
|
tasks: ['- [ ] Follow up on Topic A'],
|
||||||
|
sourceNotes: ['Root Note', 'Linked Note A'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates distilled summary file', async () => {
|
||||||
|
// Create a mock TFile for the root
|
||||||
|
const { createTestTFile } = await import('./mocks/obsidian-mock');
|
||||||
|
// We need a file that exists in the output dir, so create it first
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, 'test/My Root.md'), 'root content');
|
||||||
|
const rootFile = createTestTFile(outputDir, 'test/My Root.md');
|
||||||
|
|
||||||
|
const summaryPath = await service.writeDistilledFiles(rootFile, mockDistillData);
|
||||||
|
|
||||||
|
expect(summaryPath).toContain('My Root - distilled');
|
||||||
|
expect(fs.existsSync(path.join(outputDir, summaryPath))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates atomic notes in session folder', async () => {
|
||||||
|
const { createTestTFile } = await import('./mocks/obsidian-mock');
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, 'test/Root.md'), 'root');
|
||||||
|
const rootFile = createTestTFile(outputDir, 'test/Root.md');
|
||||||
|
|
||||||
|
await service.writeDistilledFiles(rootFile, mockDistillData);
|
||||||
|
|
||||||
|
const notesDir = path.join(outputDir, 'OpenAugi/Notes');
|
||||||
|
const sessionFolders = fs.readdirSync(notesDir);
|
||||||
|
expect(sessionFolders.length).toBe(1);
|
||||||
|
expect(sessionFolders[0]).toMatch(/^Distill Root/);
|
||||||
|
|
||||||
|
const sessionPath = path.join(notesDir, sessionFolders[0]);
|
||||||
|
const noteFiles = fs.readdirSync(sessionPath);
|
||||||
|
expect(noteFiles).toContain('Topic A.md');
|
||||||
|
expect(noteFiles).toContain('Topic B.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes source notes in summary', async () => {
|
||||||
|
const { createTestTFile } = await import('./mocks/obsidian-mock');
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, 'test/Root.md'), 'root');
|
||||||
|
const rootFile = createTestTFile(outputDir, 'test/Root.md');
|
||||||
|
|
||||||
|
const summaryPath = await service.writeDistilledFiles(rootFile, mockDistillData);
|
||||||
|
const content = fs.readFileSync(path.join(outputDir, summaryPath), 'utf-8');
|
||||||
|
|
||||||
|
expect(content).toContain('## Source Notes');
|
||||||
|
expect(content).toContain('[[Root Note]]');
|
||||||
|
expect(content).toContain('[[Linked Note A]]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writePublishedPost', () => {
|
||||||
|
it('creates published file with frontmatter', async () => {
|
||||||
|
const content = '# My Great Post\n\nThis is the blog post content.';
|
||||||
|
const filePath = await service.writePublishedPost(content, ['Source A', 'Source B'], 'default');
|
||||||
|
|
||||||
|
expect(filePath).toContain('Published');
|
||||||
|
const fullContent = fs.readFileSync(path.join(outputDir, filePath), 'utf-8');
|
||||||
|
|
||||||
|
expect(fullContent).toContain('---');
|
||||||
|
expect(fullContent).toContain('type: published-post');
|
||||||
|
expect(fullContent).toContain('status: draft');
|
||||||
|
expect(fullContent).toContain('[[Source A]]');
|
||||||
|
expect(fullContent).toContain('# My Great Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts title from first heading', async () => {
|
||||||
|
const content = '# Test Title\n\nContent here.';
|
||||||
|
const filePath = await service.writePublishedPost(content, ['Source'], 'default');
|
||||||
|
|
||||||
|
expect(filePath).toContain('Test Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to source note name when no heading', async () => {
|
||||||
|
const content = 'No heading, just content.';
|
||||||
|
const filePath = await service.writePublishedPost(content, ['My Source Note'], 'default');
|
||||||
|
|
||||||
|
expect(filePath).toContain('My Source Note');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
125
tests/filename-utils.test.ts
Normal file
125
tests/filename-utils.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { sanitizeFilename, BacklinkMapper, createFileWithCollisionHandling } from '../src/utils/filename-utils';
|
||||||
|
import { MockVault } from './mocks/obsidian-mock';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const BASE_OUTPUT_DIR = path.resolve(__dirname, 'vault-output');
|
||||||
|
let collisionTestDir: string;
|
||||||
|
let collisionTestCounter = 0;
|
||||||
|
|
||||||
|
describe('sanitizeFilename', () => {
|
||||||
|
it('passes through clean filenames unchanged', () => {
|
||||||
|
expect(sanitizeFilename('My Note')).toBe('My Note');
|
||||||
|
expect(sanitizeFilename('simple-name')).toBe('simple-name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces backslash', () => {
|
||||||
|
expect(sanitizeFilename('path\\to\\file')).toBe('path - to - file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces forward slash', () => {
|
||||||
|
expect(sanitizeFilename('path/to/file')).toBe('path - to - file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces colon', () => {
|
||||||
|
expect(sanitizeFilename('Note: Important')).toBe('Note - Important');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces asterisk', () => {
|
||||||
|
expect(sanitizeFilename('Note*star')).toBe('Note - star');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces question mark', () => {
|
||||||
|
expect(sanitizeFilename('Is this a note?')).toBe('Is this a note - ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces double quotes', () => {
|
||||||
|
expect(sanitizeFilename('He said "hello"')).toBe('He said - hello - ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces angle brackets', () => {
|
||||||
|
expect(sanitizeFilename('<tag>')).toBe(' - tag - ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces pipe', () => {
|
||||||
|
expect(sanitizeFilename('A | B')).toBe('A - B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple special characters', () => {
|
||||||
|
const result = sanitizeFilename('Note: "Important" <test> | value');
|
||||||
|
expect(result).not.toMatch(/[\\/:*?"<>|]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BacklinkMapper', () => {
|
||||||
|
let mapper: BacklinkMapper;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mapper = new BacklinkMapper();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps registered titles to sanitized filenames in backlinks', () => {
|
||||||
|
mapper.registerTitle('My Important Note', 'My Important Note');
|
||||||
|
mapper.registerTitle('Note: Special', 'Note - Special');
|
||||||
|
|
||||||
|
const result = mapper.processBacklinks('See [[Note: Special]] and [[My Important Note]]');
|
||||||
|
expect(result).toBe('See [[Note - Special]] and [[My Important Note]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to sanitizeFilename for unregistered titles', () => {
|
||||||
|
const result = mapper.processBacklinks('See [[Unregistered: Note]]');
|
||||||
|
expect(result).toBe('See [[Unregistered - Note]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles content with no backlinks', () => {
|
||||||
|
const result = mapper.processBacklinks('Plain text with no links');
|
||||||
|
expect(result).toBe('Plain text with no links');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple backlinks in same line', () => {
|
||||||
|
mapper.registerTitle('A', 'a-sanitized');
|
||||||
|
mapper.registerTitle('B', 'b-sanitized');
|
||||||
|
|
||||||
|
const result = mapper.processBacklinks('Links: [[A]] and [[B]]');
|
||||||
|
expect(result).toBe('Links: [[a-sanitized]] and [[b-sanitized]]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createFileWithCollisionHandling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
collisionTestCounter++;
|
||||||
|
collisionTestDir = path.join(BASE_OUTPUT_DIR, `collision-${collisionTestCounter}-${Date.now()}`);
|
||||||
|
fs.mkdirSync(collisionTestDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates file at original path when no collision', async () => {
|
||||||
|
const vault = new MockVault(collisionTestDir);
|
||||||
|
const result = await createFileWithCollisionHandling(vault as any, 'test-note.md', 'Hello');
|
||||||
|
expect(result).toBe('test-note.md');
|
||||||
|
|
||||||
|
const content = fs.readFileSync(path.join(collisionTestDir, 'test-note.md'), 'utf-8');
|
||||||
|
expect(content).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends -1 when file already exists', async () => {
|
||||||
|
const vault = new MockVault(collisionTestDir);
|
||||||
|
fs.writeFileSync(path.join(collisionTestDir, 'note.md'), 'existing');
|
||||||
|
|
||||||
|
const result = await createFileWithCollisionHandling(vault as any, 'note.md', 'New content');
|
||||||
|
expect(result).toBe('note-1.md');
|
||||||
|
|
||||||
|
const content = fs.readFileSync(path.join(collisionTestDir, 'note-1.md'), 'utf-8');
|
||||||
|
expect(content).toBe('New content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments counter for multiple collisions', async () => {
|
||||||
|
const vault = new MockVault(collisionTestDir);
|
||||||
|
fs.writeFileSync(path.join(collisionTestDir, 'note.md'), 'v1');
|
||||||
|
fs.writeFileSync(path.join(collisionTestDir, 'note-1.md'), 'v2');
|
||||||
|
fs.writeFileSync(path.join(collisionTestDir, 'note-2.md'), 'v3');
|
||||||
|
|
||||||
|
const result = await createFileWithCollisionHandling(vault as any, 'note.md', 'v4');
|
||||||
|
expect(result).toBe('note-3.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
287
tests/mocks/obsidian-mock.ts
Normal file
287
tests/mocks/obsidian-mock.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
/**
|
||||||
|
* Filesystem-backed mock of the Obsidian API.
|
||||||
|
* Reads/writes real markdown files from a test vault directory so we can
|
||||||
|
* test DistillService, FileService, ContextGatheringService, etc. without
|
||||||
|
* running Obsidian.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { TFile } from './obsidian-module';
|
||||||
|
|
||||||
|
// ─── Link Parsing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ParsedLink {
|
||||||
|
link: string; // The raw link text (e.g., "Note A" or "Note A|alias")
|
||||||
|
original: string; // The full match including brackets
|
||||||
|
position: { start: { line: number; col: number }; end: { line: number; col: number } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse [[wikilinks]] from markdown content */
|
||||||
|
function parseWikilinks(content: string): ParsedLink[] {
|
||||||
|
const links: ParsedLink[] = [];
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
||||||
|
const line = lines[lineNum];
|
||||||
|
const regex = /\[\[(.*?)\]\]/g;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
|
const rawLink = match[1];
|
||||||
|
// Strip alias: [[path|alias]] → path
|
||||||
|
const linkPath = rawLink.includes('|') ? rawLink.split('|')[0] : rawLink;
|
||||||
|
links.push({
|
||||||
|
link: linkPath,
|
||||||
|
original: match[0],
|
||||||
|
position: {
|
||||||
|
start: { line: lineNum, col: match.index },
|
||||||
|
end: { line: lineNum, col: match.index + match[0].length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockTFile helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Create a TFile from a real filesystem path relative to vault root */
|
||||||
|
function createMockTFile(vaultRoot: string, relativePath: string): TFile {
|
||||||
|
const absPath = path.join(vaultRoot, relativePath);
|
||||||
|
let mtime = Date.now();
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(absPath);
|
||||||
|
mtime = stat.mtimeMs;
|
||||||
|
} catch { /* file may not exist yet for output tests */ }
|
||||||
|
const file = new TFile(relativePath, mtime);
|
||||||
|
file.stat.size = (() => {
|
||||||
|
try { return fs.statSync(absPath).size; } catch { return 0; }
|
||||||
|
})();
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockVaultAdapter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MockVaultAdapter {
|
||||||
|
constructor(private vaultRoot: string) {}
|
||||||
|
|
||||||
|
async exists(filePath: string): Promise<boolean> {
|
||||||
|
return fs.existsSync(path.join(this.vaultRoot, filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(filePath: string): Promise<{ mtime: number; ctime: number; size: number } | null> {
|
||||||
|
const absPath = path.join(this.vaultRoot, filePath);
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(absPath);
|
||||||
|
return { mtime: stat.mtimeMs, ctime: stat.ctimeMs, size: stat.size };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockVault ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class MockVault {
|
||||||
|
adapter: MockVaultAdapter;
|
||||||
|
private vaultRoot: string;
|
||||||
|
|
||||||
|
constructor(vaultRoot: string) {
|
||||||
|
this.vaultRoot = vaultRoot;
|
||||||
|
this.adapter = new MockVaultAdapter(vaultRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a file's content */
|
||||||
|
async read(file: TFile): Promise<string> {
|
||||||
|
const absPath = path.join(this.vaultRoot, file.path);
|
||||||
|
return fs.readFileSync(absPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a file */
|
||||||
|
async create(filePath: string, content: string): Promise<TFile> {
|
||||||
|
const absPath = path.join(this.vaultRoot, filePath);
|
||||||
|
const dir = path.dirname(absPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(absPath, content, 'utf-8');
|
||||||
|
return createMockTFile(this.vaultRoot, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a folder */
|
||||||
|
async createFolder(folderPath: string): Promise<void> {
|
||||||
|
const absPath = path.join(this.vaultRoot, folderPath);
|
||||||
|
if (!fs.existsSync(absPath)) {
|
||||||
|
fs.mkdirSync(absPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all markdown files in the vault */
|
||||||
|
getMarkdownFiles(): TFile[] {
|
||||||
|
const files: TFile[] = [];
|
||||||
|
const walk = (dir: string, relative: string) => {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const relPath = relative ? `${relative}/${entry.name}` : entry.name;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(path.join(dir, entry.name), relPath);
|
||||||
|
} else if (entry.name.endsWith('.md')) {
|
||||||
|
files.push(createMockTFile(this.vaultRoot, relPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(this.vaultRoot, '');
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a file by its exact path */
|
||||||
|
getAbstractFileByPath(filePath: string): TFile | null {
|
||||||
|
const absPath = path.join(this.vaultRoot, filePath);
|
||||||
|
if (fs.existsSync(absPath)) {
|
||||||
|
return createMockTFile(this.vaultRoot, filePath);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockMetadataCache ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class MockMetadataCache {
|
||||||
|
/** resolvedLinks[sourcePath][targetPath] = linkCount */
|
||||||
|
resolvedLinks: Record<string, Record<string, number>> = {};
|
||||||
|
private vaultRoot: string;
|
||||||
|
private vault: MockVault;
|
||||||
|
|
||||||
|
constructor(vaultRoot: string, vault: MockVault) {
|
||||||
|
this.vaultRoot = vaultRoot;
|
||||||
|
this.vault = vault;
|
||||||
|
this.buildLinkGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the resolved links graph by scanning all markdown files */
|
||||||
|
private buildLinkGraph(): void {
|
||||||
|
const files = this.vault.getMarkdownFiles();
|
||||||
|
for (const file of files) {
|
||||||
|
const absPath = path.join(this.vaultRoot, file.path);
|
||||||
|
const content = fs.readFileSync(absPath, 'utf-8');
|
||||||
|
const links = parseWikilinks(content);
|
||||||
|
|
||||||
|
if (!this.resolvedLinks[file.path]) {
|
||||||
|
this.resolvedLinks[file.path] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
const resolved = this.resolveLink(link.link, file.path);
|
||||||
|
if (resolved) {
|
||||||
|
this.resolvedLinks[file.path][resolved.path] =
|
||||||
|
(this.resolvedLinks[file.path][resolved.path] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a link path to a TFile (mimics Obsidian's shortest-path resolution) */
|
||||||
|
private resolveLink(linkPath: string, sourcePath: string): TFile | null {
|
||||||
|
// Add .md extension if not present
|
||||||
|
let searchPath = linkPath;
|
||||||
|
if (!searchPath.endsWith('.md')) {
|
||||||
|
searchPath += '.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact path first
|
||||||
|
const exactFile = this.vault.getAbstractFileByPath(searchPath);
|
||||||
|
if (exactFile) return exactFile;
|
||||||
|
|
||||||
|
// Try relative to source file's directory
|
||||||
|
const sourceDir = path.dirname(sourcePath);
|
||||||
|
const relativePath = sourceDir === '.' ? searchPath : `${sourceDir}/${searchPath}`;
|
||||||
|
const relativeFile = this.vault.getAbstractFileByPath(relativePath);
|
||||||
|
if (relativeFile) return relativeFile;
|
||||||
|
|
||||||
|
// Obsidian-style: search all files for basename match
|
||||||
|
const baseName = searchPath.split('/').pop();
|
||||||
|
if (baseName) {
|
||||||
|
const allFiles = this.vault.getMarkdownFiles();
|
||||||
|
const match = allFiles.find(f => f.path.endsWith(baseName) || f.path.endsWith(`/${baseName}`));
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get cached metadata for a file (links extracted from content) */
|
||||||
|
getFileCache(file: TFile): { links?: ParsedLink[]; embeds?: ParsedLink[] } | null {
|
||||||
|
const absPath = path.join(this.vaultRoot, file.path);
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(absPath, 'utf-8');
|
||||||
|
const links = parseWikilinks(content);
|
||||||
|
// For simplicity, embeds use the same format as links (Obsidian uses ![[embed]])
|
||||||
|
const embedRegex = /!\[\[(.*?)\]\]/g;
|
||||||
|
const embeds: ParsedLink[] = [];
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
||||||
|
let match;
|
||||||
|
while ((match = embedRegex.exec(lines[lineNum])) !== null) {
|
||||||
|
const rawLink = match[1];
|
||||||
|
const linkPath = rawLink.includes('|') ? rawLink.split('|')[0] : rawLink;
|
||||||
|
embeds.push({
|
||||||
|
link: linkPath,
|
||||||
|
original: match[0],
|
||||||
|
position: {
|
||||||
|
start: { line: lineNum, col: match.index },
|
||||||
|
end: { line: lineNum, col: match.index + match[0].length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { links, embeds: embeds.length > 0 ? embeds : undefined };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a link path to a TFile (public API matching Obsidian's) */
|
||||||
|
getFirstLinkpathDest(linkPath: string, sourcePath: string): TFile | null {
|
||||||
|
return this.resolveLink(linkPath, sourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockApp ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class MockApp {
|
||||||
|
vault: MockVault;
|
||||||
|
metadataCache: MockMetadataCache;
|
||||||
|
workspace: { onLayoutReady: (cb: () => void) => void; getActiveFile: () => null; getLeaf: () => any };
|
||||||
|
plugins: { plugins: Record<string, any> };
|
||||||
|
fileManager: { processFrontMatter: () => Promise<void> };
|
||||||
|
|
||||||
|
constructor(vaultRoot: string) {
|
||||||
|
this.vault = new MockVault(vaultRoot);
|
||||||
|
this.metadataCache = new MockMetadataCache(vaultRoot, this.vault);
|
||||||
|
this.workspace = {
|
||||||
|
onLayoutReady: (cb) => cb(),
|
||||||
|
getActiveFile: () => null,
|
||||||
|
getLeaf: () => ({ openFile: async () => {} }),
|
||||||
|
};
|
||||||
|
this.plugins = { plugins: {} };
|
||||||
|
this.fileManager = { processFrontMatter: async () => {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper to create a fresh test vault ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a MockApp pointed at a vault directory.
|
||||||
|
* Use for tests that need the full Obsidian API mock.
|
||||||
|
*/
|
||||||
|
export function createMockApp(vaultRoot: string): MockApp {
|
||||||
|
return new MockApp(vaultRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a TFile from a vault-relative path (for use in test assertions).
|
||||||
|
*/
|
||||||
|
export function createTestTFile(vaultRoot: string, relativePath: string): TFile {
|
||||||
|
return createMockTFile(vaultRoot, relativePath);
|
||||||
|
}
|
||||||
82
tests/mocks/obsidian-module.ts
Normal file
82
tests/mocks/obsidian-module.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
/**
|
||||||
|
* Stub module that replaces `import { ... } from 'obsidian'` in tests.
|
||||||
|
* Provides minimal class/type stubs so service code can import without error.
|
||||||
|
* The real mock implementations (MockVault, MockApp, etc.) are in obsidian-mock.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class TFile {
|
||||||
|
path: string;
|
||||||
|
basename: string;
|
||||||
|
extension: string;
|
||||||
|
stat: { mtime: number; ctime: number; size: number };
|
||||||
|
|
||||||
|
constructor(path: string, mtime?: number) {
|
||||||
|
this.path = path;
|
||||||
|
this.extension = path.split('.').pop() || '';
|
||||||
|
this.basename = path.split('/').pop()?.replace(`.${this.extension}`, '') || '';
|
||||||
|
this.stat = { mtime: mtime || Date.now(), ctime: mtime || Date.now(), size: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TAbstractFile {
|
||||||
|
path: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Vault {}
|
||||||
|
|
||||||
|
export class MetadataCache {}
|
||||||
|
|
||||||
|
export class App {}
|
||||||
|
|
||||||
|
export class Plugin {}
|
||||||
|
|
||||||
|
export class Component {}
|
||||||
|
|
||||||
|
export class Modal {
|
||||||
|
app: any;
|
||||||
|
constructor(app: any) { this.app = app; }
|
||||||
|
open() {}
|
||||||
|
close() {}
|
||||||
|
onOpen() {}
|
||||||
|
onClose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginSettingTab {
|
||||||
|
app: any;
|
||||||
|
plugin: any;
|
||||||
|
constructor(app: any, plugin: any) { this.app = app; this.plugin = plugin; }
|
||||||
|
display() {}
|
||||||
|
hide() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Setting {
|
||||||
|
constructor(_el: any) {}
|
||||||
|
setName(_n: string) { return this; }
|
||||||
|
setDesc(_d: string) { return this; }
|
||||||
|
addText(_cb: any) { return this; }
|
||||||
|
addToggle(_cb: any) { return this; }
|
||||||
|
addDropdown(_cb: any) { return this; }
|
||||||
|
addButton(_cb: any) { return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Notice {
|
||||||
|
constructor(message: string, _timeout?: number) {
|
||||||
|
// Silent in tests — uncomment for debugging:
|
||||||
|
// console.log('[Notice]', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileSystemAdapter {
|
||||||
|
private basePath: string;
|
||||||
|
constructor(basePath?: string) { this.basePath = basePath || ''; }
|
||||||
|
getBasePath(): string { return this.basePath; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Platform = {
|
||||||
|
isMobile: false,
|
||||||
|
isDesktop: true,
|
||||||
|
isDesktopApp: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MarkdownView {}
|
||||||
|
export class WorkspaceLeaf {}
|
||||||
235
tests/openai-service.test.ts
Normal file
235
tests/openai-service.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { OpenAIService } from '../src/services/openai-service';
|
||||||
|
|
||||||
|
describe('OpenAIService', () => {
|
||||||
|
let service: OpenAIService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new OpenAIService('test-api-key', 'gpt-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('stores API key and model', () => {
|
||||||
|
// Access private fields via any cast (testing internals)
|
||||||
|
expect((service as any).apiKey).toBe('test-api-key');
|
||||||
|
expect((service as any).model).toBe('gpt-5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCustomContext (via prompt building)', () => {
|
||||||
|
// We test this through the prompt generation methods since extractCustomContext is private
|
||||||
|
|
||||||
|
it('includes context: section in transcript prompt', () => {
|
||||||
|
const content = 'Some transcript\n\ncontext:\nFocus on tech topics\n\nMore content';
|
||||||
|
const prompt = (service as any).getPrompt(content);
|
||||||
|
expect(prompt).toContain('USER CONTEXT');
|
||||||
|
expect(prompt).toContain('Focus on tech topics');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes fenced context: section in transcript prompt', () => {
|
||||||
|
const content = 'Some content\n\n```context:\nOnly extract action items\n```\n\nMore stuff';
|
||||||
|
const prompt = (service as any).getPrompt(content);
|
||||||
|
expect(prompt).toContain('USER CONTEXT');
|
||||||
|
expect(prompt).toContain('Only extract action items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles content without context section', () => {
|
||||||
|
const content = 'Just a regular transcript with no special context';
|
||||||
|
const prompt = (service as any).getPrompt(content);
|
||||||
|
expect(prompt).not.toContain('USER CONTEXT');
|
||||||
|
expect(prompt).toContain('Just a regular transcript');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPrompt', () => {
|
||||||
|
it('includes the transcript content', () => {
|
||||||
|
const prompt = (service as any).getPrompt('My voice note about productivity');
|
||||||
|
expect(prompt).toContain('Transcript:\nMy voice note about productivity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes atomic notes instructions', () => {
|
||||||
|
const prompt = (service as any).getPrompt('test');
|
||||||
|
expect(prompt).toContain('Atomic Notes');
|
||||||
|
expect(prompt).toContain('self-contained');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes task extraction instructions', () => {
|
||||||
|
const prompt = (service as any).getPrompt('test');
|
||||||
|
expect(prompt).toContain('Tasks');
|
||||||
|
expect(prompt).toContain('actionable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes AUGI command handling', () => {
|
||||||
|
const prompt = (service as any).getPrompt('test');
|
||||||
|
expect(prompt).toContain('AUGI');
|
||||||
|
expect(prompt).toContain('auggie');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDistillPrompt', () => {
|
||||||
|
it('includes content to distill', () => {
|
||||||
|
const prompt = (service as any).getDistillPrompt('Note content here');
|
||||||
|
expect(prompt).toContain('Content to Distill:\nNote content here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom prompt when provided', () => {
|
||||||
|
const custom = 'You are a custom processor. Do something special.';
|
||||||
|
const prompt = (service as any).getDistillPrompt('content', custom);
|
||||||
|
expect(prompt).toContain(custom);
|
||||||
|
// Should NOT contain the default distill instructions
|
||||||
|
expect(prompt).not.toContain('expert knowledge curator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default prompt when no custom prompt', () => {
|
||||||
|
const prompt = (service as any).getDistillPrompt('content');
|
||||||
|
expect(prompt).toContain('expert knowledge curator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends custom context from content', () => {
|
||||||
|
const content = 'Notes here\n\ncontext:\nFocus on architecture\n\nMore notes';
|
||||||
|
const prompt = (service as any).getDistillPrompt(content);
|
||||||
|
expect(prompt).toContain('USER CONTEXT');
|
||||||
|
expect(prompt).toContain('Focus on architecture');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPublishPrompt', () => {
|
||||||
|
it('includes content to transform', () => {
|
||||||
|
const prompt = (service as any).getPublishPrompt('Blog content');
|
||||||
|
expect(prompt).toContain('Content to Transform:\nBlog content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default publish prompt', () => {
|
||||||
|
const prompt = (service as any).getPublishPrompt('content');
|
||||||
|
expect(prompt).toContain('publishable blog post');
|
||||||
|
expect(prompt).toContain('PRESERVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom prompt when provided', () => {
|
||||||
|
const custom = 'Write a newsletter instead.';
|
||||||
|
const prompt = (service as any).getPublishPrompt('content', custom);
|
||||||
|
expect(prompt).toContain('Write a newsletter instead.');
|
||||||
|
expect(prompt).not.toContain('publishable blog post');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseTranscript', () => {
|
||||||
|
it('throws when API key is not set', async () => {
|
||||||
|
const noKeyService = new OpenAIService('', 'gpt-5');
|
||||||
|
await expect(noKeyService.parseTranscript('content')).rejects.toThrow('OpenAI API key not set');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls OpenAI API with correct parameters', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
summary: 'Test summary',
|
||||||
|
notes: [{ title: 'Note 1', content: 'Content 1' }],
|
||||||
|
tasks: ['- [ ] Task 1']
|
||||||
|
}),
|
||||||
|
refusal: null
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const result = await service.parseTranscript('Test transcript');
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||||
|
const [url, options] = fetchSpy.mock.calls[0];
|
||||||
|
expect(url).toBe('https://api.openai.com/v1/chat/completions');
|
||||||
|
expect((options as any).method).toBe('POST');
|
||||||
|
expect(JSON.parse((options as any).body).model).toBe('gpt-5');
|
||||||
|
|
||||||
|
expect(result.summary).toBe('Test summary');
|
||||||
|
expect(result.notes).toHaveLength(1);
|
||||||
|
expect(result.tasks).toHaveLength(1);
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on API error response', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'Unauthorized',
|
||||||
|
json: async () => ({ error: { message: 'Invalid API key' } })
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
await expect(service.parseTranscript('content')).rejects.toThrow('OpenAI API error');
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('distillContent', () => {
|
||||||
|
it('throws when API key is not set', async () => {
|
||||||
|
const noKeyService = new OpenAIService('', 'gpt-5');
|
||||||
|
await expect(noKeyService.distillContent('content')).rejects.toThrow('OpenAI API key not set');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses structured response correctly', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
summary: 'Distilled summary about [[Topic A]]',
|
||||||
|
notes: [
|
||||||
|
{ title: 'Topic A', content: 'Deep dive into A' },
|
||||||
|
{ title: 'Topic B', content: 'Overview of B' },
|
||||||
|
],
|
||||||
|
tasks: []
|
||||||
|
}),
|
||||||
|
refusal: null
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const result = await service.distillContent('aggregated content');
|
||||||
|
|
||||||
|
expect(result.summary).toContain('Topic A');
|
||||||
|
expect(result.notes).toHaveLength(2);
|
||||||
|
expect(result.sourceNotes).toEqual([]); // Initialized as empty
|
||||||
|
expect(result.tasks).toEqual([]);
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('publishContent', () => {
|
||||||
|
it('returns plain text content', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: '# My Blog Post\n\nThis is the published content.',
|
||||||
|
refusal: null
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
|
||||||
|
|
||||||
|
const result = await service.publishContent('raw notes');
|
||||||
|
|
||||||
|
expect(result).toContain('# My Blog Post');
|
||||||
|
expect(result).toContain('published content');
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
634
tests/task-dispatch-service.test.ts
Normal file
634
tests/task-dispatch-service.test.ts
Normal file
|
|
@ -0,0 +1,634 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { resolveRepoPath, TaskDispatchService } from '../src/services/task-dispatch-service';
|
||||||
|
import { DEFAULT_SETTINGS, OpenAugiSettings } from '../src/types/settings';
|
||||||
|
import { RepoPath } from '../src/types/task-dispatch';
|
||||||
|
import { TFile } from './mocks/obsidian-module';
|
||||||
|
|
||||||
|
// ─── Mock child_process.exec to avoid real tmux/osascript calls ──────────────
|
||||||
|
|
||||||
|
const mockExecAsync = vi.fn();
|
||||||
|
vi.mock('child_process', () => ({
|
||||||
|
exec: (...args: any[]) => {
|
||||||
|
// promisify(exec) wraps exec into a function that returns a promise.
|
||||||
|
// We intercept the callback-style call and delegate to mockExecAsync.
|
||||||
|
const cb = args[args.length - 1];
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
mockExecAsync(args[0], args[1])
|
||||||
|
.then((result: any) => cb(null, result))
|
||||||
|
.catch((err: any) => cb(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createMockApp(vaultBasePath: string) {
|
||||||
|
return {
|
||||||
|
vault: {
|
||||||
|
adapter: { getBasePath: () => vaultBasePath },
|
||||||
|
read: vi.fn(),
|
||||||
|
getMarkdownFiles: vi.fn().mockReturnValue([]),
|
||||||
|
},
|
||||||
|
metadataCache: {
|
||||||
|
getFileCache: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockDistillService() {
|
||||||
|
return {
|
||||||
|
getLinkedNotes: vi.fn().mockResolvedValue([]),
|
||||||
|
aggregateContent: vi.fn().mockResolvedValue({ content: '', sourceNotes: [] }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSettings(overrides: Partial<OpenAugiSettings['taskDispatch']> = {}): OpenAugiSettings {
|
||||||
|
return {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
taskDispatch: {
|
||||||
|
...DEFAULT_SETTINGS.taskDispatch,
|
||||||
|
tmuxPath: '/usr/bin/tmux',
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTFile(relativePath: string, frontmatter?: Record<string, any>): { file: TFile; frontmatter: Record<string, any> | undefined } {
|
||||||
|
const file = new TFile(relativePath);
|
||||||
|
return { file, frontmatter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveRepoPath (pure function)', () => {
|
||||||
|
const repoPaths: RepoPath[] = [
|
||||||
|
{ name: 'my-repo', path: '/Users/chris/repos/my-repo' },
|
||||||
|
{ name: 'Other Project', path: '/opt/projects/other' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('matches by name (case-insensitive)', () => {
|
||||||
|
expect(resolveRepoPath('my-repo', repoPaths)).toBe('/Users/chris/repos/my-repo');
|
||||||
|
expect(resolveRepoPath('MY-REPO', repoPaths)).toBe('/Users/chris/repos/my-repo');
|
||||||
|
expect(resolveRepoPath('My-Repo', repoPaths)).toBe('/Users/chris/repos/my-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown names', () => {
|
||||||
|
expect(resolveRepoPath('nonexistent', repoPaths)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty repo paths', () => {
|
||||||
|
expect(resolveRepoPath('anything', [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for null/undefined repo paths', () => {
|
||||||
|
expect(resolveRepoPath('anything', null as any)).toBeNull();
|
||||||
|
expect(resolveRepoPath('anything', undefined as any)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TaskDispatchService', () => {
|
||||||
|
let app: ReturnType<typeof createMockApp>;
|
||||||
|
let distillService: ReturnType<typeof createMockDistillService>;
|
||||||
|
let settings: OpenAugiSettings;
|
||||||
|
let service: TaskDispatchService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = createMockApp('/Users/chris/vault');
|
||||||
|
distillService = createMockDistillService();
|
||||||
|
settings = makeSettings();
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── shellEscape ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('shellEscape', () => {
|
||||||
|
const escape = (str: string) => (service as any).shellEscape(str);
|
||||||
|
|
||||||
|
it('wraps in single quotes', () => {
|
||||||
|
expect(escape('hello')).toBe("'hello'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes single quotes in the string', () => {
|
||||||
|
expect(escape("it's")).toBe("'it'\\''s'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
expect(escape('')).toBe("''");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles strings with spaces and special chars', () => {
|
||||||
|
expect(escape('hello world $HOME')).toBe("'hello world $HOME'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple single quotes', () => {
|
||||||
|
expect(escape("a'b'c")).toBe("'a'\\''b'\\''c'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getTaskId ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getTaskId', () => {
|
||||||
|
const getTaskId = (file: TFile) => (service as any).getTaskId(file);
|
||||||
|
|
||||||
|
it('reads task_id from frontmatter', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'abc-123' },
|
||||||
|
});
|
||||||
|
expect(getTaskId(file)).toBe('abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads task-id (hyphenated) from frontmatter', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { 'task-id': 'def-456' },
|
||||||
|
});
|
||||||
|
expect(getTaskId(file)).toBe('def-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no frontmatter', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue(null);
|
||||||
|
expect(getTaskId(file)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when frontmatter has no task_id', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { title: 'Hello' },
|
||||||
|
});
|
||||||
|
expect(getTaskId(file)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts numeric task_id to string', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 42 },
|
||||||
|
});
|
||||||
|
expect(getTaskId(file)).toBe('42');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getWorkingDir ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getWorkingDir', () => {
|
||||||
|
const getWorkingDir = (file: TFile) => (service as any).getWorkingDir(file);
|
||||||
|
|
||||||
|
it('uses working_dir frontmatter as absolute path', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { working_dir: '/absolute/path' },
|
||||||
|
});
|
||||||
|
expect(getWorkingDir(file)).toBe('/absolute/path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses working-dir (hyphenated) frontmatter', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { 'working-dir': '/other/path' },
|
||||||
|
});
|
||||||
|
expect(getWorkingDir(file)).toBe('/other/path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves named repo path from frontmatter', () => {
|
||||||
|
settings.taskDispatch.repoPaths = [
|
||||||
|
{ name: 'my-repo', path: '/Users/chris/repos/my-repo' },
|
||||||
|
];
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { working_dir: 'my-repo' },
|
||||||
|
});
|
||||||
|
expect(getWorkingDir(file)).toBe('/Users/chris/repos/my-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves relative path against vault root', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { working_dir: 'projects/foo' },
|
||||||
|
});
|
||||||
|
expect(getWorkingDir(file)).toBe('/Users/chris/vault/projects/foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to defaultWorkingDir setting', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
settings.taskDispatch.defaultWorkingDir = '/default/dir';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
expect(getWorkingDir(file)).toBe('/default/dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves relative defaultWorkingDir against vault root', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
settings.taskDispatch.defaultWorkingDir = 'OpenAugi/Tasks';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
expect(getWorkingDir(file)).toBe('/Users/chris/vault/OpenAugi/Tasks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to HOME when no working dir configured', () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
settings.taskDispatch.defaultWorkingDir = '';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
expect(getWorkingDir(file)).toBe(process.env.HOME);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getAgentConfig ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getAgentConfig', () => {
|
||||||
|
const getAgentConfig = () => (service as any).getAgentConfig();
|
||||||
|
|
||||||
|
it('returns the configured default agent', () => {
|
||||||
|
const config = getAgentConfig();
|
||||||
|
expect(config.id).toBe('claude-code');
|
||||||
|
expect(config.command).toBe('claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first agent if default not found', () => {
|
||||||
|
settings.taskDispatch.defaultAgent = 'nonexistent';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
const config = getAgentConfig();
|
||||||
|
expect(config.id).toBe('claude-code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── assembleContext ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('assembleContext', () => {
|
||||||
|
it('includes task ID, note body, and instructions', async () => {
|
||||||
|
const file = new TFile('Notes/Task 1.md');
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: test-1\n---\n\nDo the thing.\n\n## Details\nMore info.');
|
||||||
|
distillService.getLinkedNotes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 'test-1');
|
||||||
|
|
||||||
|
expect(context).toContain('# Task: test-1');
|
||||||
|
expect(context).toContain('Do the thing.');
|
||||||
|
expect(context).toContain('## Details');
|
||||||
|
expect(context).toContain('More info.');
|
||||||
|
// Instructions section
|
||||||
|
expect(context).toContain('## Instructions');
|
||||||
|
expect(context).toContain('Task file: Notes/Task 1.md');
|
||||||
|
expect(context).toContain('Task ID: test-1');
|
||||||
|
expect(context).toContain('MCP append_results tool');
|
||||||
|
expect(context).toContain('## Results section');
|
||||||
|
expect(context).toContain('[[wikilinks]]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips frontmatter from note body', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: t1\nworking_dir: /foo\n---\n\nBody text.');
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 't1');
|
||||||
|
|
||||||
|
expect(context).not.toContain('task_id: t1');
|
||||||
|
expect(context).not.toContain('working_dir: /foo');
|
||||||
|
expect(context).toContain('Body text.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes linked context when present', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: t1\n---\n\nMain body.');
|
||||||
|
|
||||||
|
const linkedFile = new TFile('Notes/Reference.md');
|
||||||
|
distillService.getLinkedNotes.mockResolvedValue([linkedFile]);
|
||||||
|
distillService.aggregateContent.mockResolvedValue({
|
||||||
|
content: '# Note: Reference\n\nLinked content here.',
|
||||||
|
sourceNotes: ['Reference'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 't1');
|
||||||
|
|
||||||
|
expect(context).toContain('## Linked Context');
|
||||||
|
expect(context).toContain('Linked content here.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits linked context section when no linked notes', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: t1\n---\n\nBody.');
|
||||||
|
distillService.getLinkedNotes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 't1');
|
||||||
|
|
||||||
|
expect(context).not.toContain('## Linked Context');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates context at maxContextChars', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
const longBody = 'A'.repeat(500);
|
||||||
|
app.vault.read.mockResolvedValue(`---\ntask_id: t1\n---\n\n${longBody}`);
|
||||||
|
distillService.getLinkedNotes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
settings.taskDispatch.maxContextChars = 100;
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
const context = await (service as any).assembleContext(file, 't1');
|
||||||
|
|
||||||
|
expect(context.length).toBeLessThanOrEqual(100 + 50); // 100 + truncation message
|
||||||
|
expect(context).toContain('...(context truncated at character limit)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── writeContextFile / cleanupContextFile ───────────────────────────────
|
||||||
|
|
||||||
|
describe('writeContextFile and cleanupContextFile', () => {
|
||||||
|
const tmpDir = path.join('/tmp', `openaugi-test-${process.pid}`);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
settings.taskDispatch.contextTempDir = tmpDir;
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* ok */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes context to file and returns path', async () => {
|
||||||
|
const filePath = await (service as any).writeContextFile('abc', 'hello world');
|
||||||
|
|
||||||
|
expect(filePath).toBe(path.join(tmpDir, 'task-abc-context.md'));
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true);
|
||||||
|
expect(fs.readFileSync(filePath, 'utf-8')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates directory if it does not exist', async () => {
|
||||||
|
const deepDir = path.join(tmpDir, 'nested', 'dir');
|
||||||
|
settings.taskDispatch.contextTempDir = deepDir;
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
const filePath = await (service as any).writeContextFile('xyz', 'content');
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
fs.rmSync(deepDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up context file', async () => {
|
||||||
|
await (service as any).writeContextFile('cleanup-test', 'data');
|
||||||
|
const filePath = path.join(tmpDir, 'task-cleanup-test-context.md');
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true);
|
||||||
|
|
||||||
|
(service as any).cleanupContextFile('cleanup-test');
|
||||||
|
expect(fs.existsSync(filePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanupContextFile does not throw for missing file', () => {
|
||||||
|
expect(() => (service as any).cleanupContextFile('nonexistent')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── tmuxSessionExists ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('tmuxSessionExists', () => {
|
||||||
|
it('returns true when tmux has-session succeeds', async () => {
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
const result = await (service as any).tmuxSessionExists('/usr/bin/tmux', 'task-abc');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when tmux has-session fails', async () => {
|
||||||
|
mockExecAsync.mockRejectedValueOnce(new Error('no session'));
|
||||||
|
const result = await (service as any).tmuxSessionExists('/usr/bin/tmux', 'task-abc');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── createTmuxSession ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('createTmuxSession', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock fs.promises.mkdir
|
||||||
|
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates tmux session then sends agent command with context as user prompt', async () => {
|
||||||
|
// new-session
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// capture-pane for waitForShellReady
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '$ ', stderr: '' });
|
||||||
|
// send-keys
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
const agentConfig = {
|
||||||
|
id: 'claude-code',
|
||||||
|
name: 'Claude Code',
|
||||||
|
command: 'claude',
|
||||||
|
contextFlag: '--append-system-prompt',
|
||||||
|
};
|
||||||
|
|
||||||
|
await (service as any).createTmuxSession(
|
||||||
|
'/usr/bin/tmux', 'task-test', agentConfig, '/tmp/ctx.md', '/home/user/project'
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0] as string);
|
||||||
|
|
||||||
|
// Verify new-session was called
|
||||||
|
expect(calls.some(c => c.includes('new-session -d -s'))).toBe(true);
|
||||||
|
|
||||||
|
// Verify send-keys passes context file via $(cat ...) as user prompt
|
||||||
|
const sendKeysCmd = calls.find(c => c.includes('send-keys'));
|
||||||
|
expect(sendKeysCmd).toBeTruthy();
|
||||||
|
expect(sendKeysCmd).toContain('$(cat');
|
||||||
|
expect(sendKeysCmd).toContain('/tmp/ctx.md');
|
||||||
|
expect(sendKeysCmd).toContain('Enter');
|
||||||
|
// Should NOT contain the contextFlag — context is the user prompt now
|
||||||
|
expect(sendKeysCmd).not.toContain('--append-system-prompt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── listActiveSessions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('listActiveSessions', () => {
|
||||||
|
it('parses tmux list-sessions output', async () => {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
mockExecAsync.mockResolvedValueOnce({
|
||||||
|
stdout: `task-abc ${timestamp}\ntask-def ${timestamp}\nnon-task-session ${timestamp}\n`,
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = await service.listActiveSessions();
|
||||||
|
|
||||||
|
expect(sessions).toHaveLength(2);
|
||||||
|
expect(sessions[0].taskId).toBe('abc');
|
||||||
|
expect(sessions[0].tmuxSessionName).toBe('task-abc');
|
||||||
|
expect(sessions[1].taskId).toBe('def');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out non-task sessions', async () => {
|
||||||
|
mockExecAsync.mockResolvedValueOnce({
|
||||||
|
stdout: 'my-other-session 1234567890\n',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = await service.listActiveSessions();
|
||||||
|
expect(sessions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when tmux is not running', async () => {
|
||||||
|
mockExecAsync.mockRejectedValueOnce(new Error('no server running'));
|
||||||
|
|
||||||
|
const sessions = await service.listActiveSessions();
|
||||||
|
expect(sessions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── launchOrAttach ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('launchOrAttach', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for notes without task_id', async () => {
|
||||||
|
const file = new TFile('Notes/Regular.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
|
||||||
|
await service.launchOrAttach(file as any);
|
||||||
|
|
||||||
|
expect(mockExecAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches to existing session', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'existing' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// has-session succeeds (session exists)
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// osascript for openTerminal
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
await service.launchOrAttach(file as any);
|
||||||
|
|
||||||
|
// Should NOT call new-session
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]);
|
||||||
|
expect(calls.some((c: string) => c.includes('new-session'))).toBe(false);
|
||||||
|
// Should call osascript to attach
|
||||||
|
expect(calls.some((c: string) => c.includes('osascript'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new session for fresh task', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'fresh' },
|
||||||
|
});
|
||||||
|
app.vault.read.mockResolvedValue('---\ntask_id: fresh\n---\n\nDo stuff.');
|
||||||
|
|
||||||
|
// Ensure contextTempDir exists for writeContextFile
|
||||||
|
const tmpDir = path.join('/tmp', `openaugi-launch-test-${process.pid}`);
|
||||||
|
settings.taskDispatch.contextTempDir = tmpDir;
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
// has-session fails (no existing session)
|
||||||
|
mockExecAsync.mockRejectedValueOnce(new Error('no session'));
|
||||||
|
// new-session
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// capture-pane (waitForShellReady)
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '$ ', stderr: '' });
|
||||||
|
// send-keys
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// osascript (openTerminal)
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
await service.launchOrAttach(file as any);
|
||||||
|
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]);
|
||||||
|
expect(calls.some((c: string) => c.includes('new-session'))).toBe(true);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* ok */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── killSession ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('killSession', () => {
|
||||||
|
it('does nothing for notes without task_id', async () => {
|
||||||
|
const file = new TFile('Notes/Regular.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||||
|
|
||||||
|
await service.killSession(file as any);
|
||||||
|
expect(mockExecAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills existing session and cleans up context file', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'kill-me' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// has-session succeeds
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
// kill-session
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
await service.killSession(file as any);
|
||||||
|
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]);
|
||||||
|
expect(calls.some((c: string) => c.includes('kill-session'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies when no active session found', async () => {
|
||||||
|
const file = new TFile('Notes/Task.md');
|
||||||
|
app.metadataCache.getFileCache.mockReturnValue({
|
||||||
|
frontmatter: { task_id: 'no-session' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// has-session fails
|
||||||
|
mockExecAsync.mockRejectedValueOnce(new Error('no session'));
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await service.killSession(file as any);
|
||||||
|
|
||||||
|
// Should NOT call kill-session
|
||||||
|
const calls = mockExecAsync.mock.calls.map((c: any[]) => c[0]);
|
||||||
|
expect(calls.some((c: string) => c.includes('kill-session'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── openTerminal ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('openTerminal', () => {
|
||||||
|
it('opens iTerm2 when configured', async () => {
|
||||||
|
settings.taskDispatch.terminalApp = 'iterm2';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
await service.openTerminal('task-test');
|
||||||
|
|
||||||
|
const cmd = mockExecAsync.mock.calls[0][0] as string;
|
||||||
|
expect(cmd).toContain('iTerm2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens Terminal.app when configured', async () => {
|
||||||
|
settings.taskDispatch.terminalApp = 'terminal-app';
|
||||||
|
service = new TaskDispatchService(app as any, settings, distillService as any);
|
||||||
|
|
||||||
|
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||||
|
await service.openTerminal('task-test');
|
||||||
|
|
||||||
|
const cmd = mockExecAsync.mock.calls[0][0] as string;
|
||||||
|
expect(cmd).toContain('Terminal');
|
||||||
|
expect(cmd).not.toContain('iTerm2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
189
tests/task-file-service.test.ts
Normal file
189
tests/task-file-service.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
TaskFileService,
|
||||||
|
TASKS_FOLDER,
|
||||||
|
buildTaskFileContent,
|
||||||
|
stripFrontmatter,
|
||||||
|
taskFileSlug,
|
||||||
|
taskFileTimestamp,
|
||||||
|
} from '../src/services/task-file-service';
|
||||||
|
import { createMockApp, MockApp } from './mocks/obsidian-mock';
|
||||||
|
|
||||||
|
// Mirrors FRONTMATTER_RE in the parent repo's task_watcher.py — the reader
|
||||||
|
// side of the task-file contract. If a file doesn't match this, the watcher
|
||||||
|
// never sees its frontmatter.
|
||||||
|
const WATCHER_FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n/;
|
||||||
|
|
||||||
|
// ─── Pure helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('stripFrontmatter', () => {
|
||||||
|
it('removes a leading frontmatter block', () => {
|
||||||
|
expect(stripFrontmatter('---\nfoo: bar\n---\nBody text.')).toBe('Body text.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves content without frontmatter untouched', () => {
|
||||||
|
expect(stripFrontmatter('Just a note.')).toBe('Just a note.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strip --- separators mid-document', () => {
|
||||||
|
const content = 'Intro.\n\n---\n\nMore.';
|
||||||
|
expect(stripFrontmatter(content)).toBe(content);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('taskFileSlug', () => {
|
||||||
|
it('lowercases and hyphenates', () => {
|
||||||
|
expect(taskFileSlug('run the review pass')).toBe('run-the-review-pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips special characters', () => {
|
||||||
|
expect(taskFileSlug('distill Journal 2026-07-07!')).toBe('distill-journal-2026-07-07');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long titles without a trailing hyphen', () => {
|
||||||
|
const slug = taskFileSlug('a'.repeat(45) + ' bcdef ghijk');
|
||||||
|
expect(slug.length).toBeLessThanOrEqual(50);
|
||||||
|
expect(slug.endsWith('-')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "task" for empty input', () => {
|
||||||
|
expect(taskFileSlug('!!!')).toBe('task');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('taskFileTimestamp', () => {
|
||||||
|
it('formats as YYYYMMDD-HHMMSS', () => {
|
||||||
|
const ts = taskFileTimestamp(new Date(2026, 6, 7, 9, 5, 3));
|
||||||
|
expect(ts).toBe('20260707-090503');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── buildTaskFileContent ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('buildTaskFileContent', () => {
|
||||||
|
const content = buildTaskFileContent({
|
||||||
|
title: 'run the review pass',
|
||||||
|
context: 'Triggered via the plugin.',
|
||||||
|
instruction: 'run the review pass',
|
||||||
|
task: 'Read OpenAugi/AGENT/review-pass.md and execute: run the review pass.',
|
||||||
|
sourceNote: 'OpenAugi plugin',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces frontmatter the watcher can parse, with status pending', () => {
|
||||||
|
const match = content.match(WATCHER_FRONTMATTER_RE);
|
||||||
|
expect(match).toBeTruthy();
|
||||||
|
expect(match![1]).toContain('status: pending');
|
||||||
|
expect(match![1]).toContain('source_block_id: obsidian-plugin');
|
||||||
|
expect(match![1]).toContain('source_note: "[[OpenAugi plugin]]"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains all template sections in order', () => {
|
||||||
|
const sections = [
|
||||||
|
'# run the review pass',
|
||||||
|
'## Context',
|
||||||
|
'## User instruction',
|
||||||
|
'## Task',
|
||||||
|
'## Human Todo',
|
||||||
|
'## Results',
|
||||||
|
];
|
||||||
|
let lastIndex = -1;
|
||||||
|
for (const section of sections) {
|
||||||
|
const idx = content.indexOf(section);
|
||||||
|
expect(idx, `missing section: ${section}`).toBeGreaterThan(lastIndex);
|
||||||
|
lastIndex = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('block-quotes the user instruction', () => {
|
||||||
|
expect(content).toContain('> run the review pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the context verbatim under ## Context', () => {
|
||||||
|
const multiline = buildTaskFileContent({
|
||||||
|
title: 't',
|
||||||
|
context: 'Line one.\n\n- bullet\n- another',
|
||||||
|
instruction: 'i',
|
||||||
|
task: 'do it',
|
||||||
|
sourceNote: 'Note',
|
||||||
|
});
|
||||||
|
expect(multiline).toContain('## Context\n\nLine one.\n\n- bullet\n- another\n\n## User instruction');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── TaskFileService (fs-backed vault) ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TaskFileService', () => {
|
||||||
|
let vaultRoot: string;
|
||||||
|
let app: MockApp;
|
||||||
|
let service: TaskFileService;
|
||||||
|
const now = new Date(2026, 6, 7, 14, 30, 0);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vaultRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openaugi-taskfile-'));
|
||||||
|
app = createMockApp(vaultRoot);
|
||||||
|
service = new TaskFileService(app as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(vaultRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function readTask(relPath: string): string {
|
||||||
|
return fs.readFileSync(path.join(vaultRoot, relPath), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates the OpenAugi/Tasks folder if missing', async () => {
|
||||||
|
expect(fs.existsSync(path.join(vaultRoot, TASKS_FOLDER))).toBe(false);
|
||||||
|
await service.createReviewPassTask(now);
|
||||||
|
expect(fs.existsSync(path.join(vaultRoot, TASKS_FOLDER))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a pending review-pass task with the expected filename', async () => {
|
||||||
|
const taskPath = await service.createReviewPassTask(now);
|
||||||
|
|
||||||
|
expect(taskPath).toBe(`${TASKS_FOLDER}/run-the-review-pass-20260707-143000.md`);
|
||||||
|
const content = readTask(taskPath);
|
||||||
|
expect(content.match(WATCHER_FRONTMATTER_RE)![1]).toContain('status: pending');
|
||||||
|
expect(content).toContain('> run the review pass');
|
||||||
|
expect(content).toContain('Read OpenAugi/AGENT/review-pass.md and execute: run the review pass.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a process-dashboard task with its own instruction', async () => {
|
||||||
|
const taskPath = await service.createProcessDashboardTask(now);
|
||||||
|
|
||||||
|
expect(taskPath).toBe(`${TASKS_FOLDER}/process-the-dashboard-20260707-143000.md`);
|
||||||
|
const content = readTask(taskPath);
|
||||||
|
expect(content).toContain('> process the dashboard');
|
||||||
|
expect(content).toContain('Read OpenAugi/AGENT/review-pass.md and execute: process the dashboard.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a distill task with the given content as ## Context', async () => {
|
||||||
|
const selection = 'Idea one about capture.\n\nIdea two about routing.';
|
||||||
|
const taskPath = await service.createDistillTask(selection, 'Journal 2026-07-07', now);
|
||||||
|
|
||||||
|
expect(taskPath).toBe(`${TASKS_FOLDER}/distill-journal-2026-07-07-20260707-143000.md`);
|
||||||
|
const content = readTask(taskPath);
|
||||||
|
expect(content).toContain('source_note: "[[Journal 2026-07-07]]"');
|
||||||
|
expect(content).toContain(`## Context\n\n${selection}\n\n## User instruction`);
|
||||||
|
expect(content).toContain('> distill this per OpenAugi/AGENT/distill-lens.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite when two tasks land in the same second', async () => {
|
||||||
|
const first = await service.createReviewPassTask(now);
|
||||||
|
const second = await service.createReviewPassTask(now);
|
||||||
|
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(fs.existsSync(path.join(vaultRoot, first))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(vaultRoot, second))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never sets a repo/working_dir key — the watcher defaults to the vault', async () => {
|
||||||
|
const taskPath = await service.createReviewPassTask(now);
|
||||||
|
const frontmatter = readTask(taskPath).match(WATCHER_FRONTMATTER_RE)![1];
|
||||||
|
expect(frontmatter).not.toContain('repo:');
|
||||||
|
expect(frontmatter).not.toContain('working_dir');
|
||||||
|
});
|
||||||
|
});
|
||||||
10
tests/vault/Backlink Source.md
Normal file
10
tests/vault/Backlink Source.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Backlink Source
|
||||||
|
|
||||||
|
## Section About Root
|
||||||
|
|
||||||
|
This section references the [[Root Note]] because it is important for testing backlink discovery.
|
||||||
|
|
||||||
|
## Unrelated Section
|
||||||
|
|
||||||
|
This section has nothing to do with the root note.
|
||||||
|
It discusses other topics entirely.
|
||||||
8
tests/vault/Collection Note.md
Normal file
8
tests/vault/Collection Note.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Collection of Notes
|
||||||
|
|
||||||
|
This is a collection note with checkboxes.
|
||||||
|
|
||||||
|
- [x] [[Linked Note A]]
|
||||||
|
- [ ] [[Linked Note B]]
|
||||||
|
- [x] [[Journal Note]]
|
||||||
|
- [ ] [[Backlink Source]]
|
||||||
11
tests/vault/Context Note.md
Normal file
11
tests/vault/Context Note.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Note with Custom Context
|
||||||
|
|
||||||
|
This note has custom context instructions for AI processing.
|
||||||
|
|
||||||
|
Some content about software architecture patterns.
|
||||||
|
|
||||||
|
```context:
|
||||||
|
Only extract notes about design patterns. Ignore implementation details.
|
||||||
|
```
|
||||||
|
|
||||||
|
More content about specific patterns like Observer, Strategy, and Factory.
|
||||||
14
tests/vault/Dataview Note.md
Normal file
14
tests/vault/Dataview Note.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Notes with Dataview
|
||||||
|
|
||||||
|
This note contains a dataview query that should be stripped from output.
|
||||||
|
|
||||||
|
```dataview
|
||||||
|
TABLE file.mtime as "Modified"
|
||||||
|
FROM "/"
|
||||||
|
SORT file.mtime DESC
|
||||||
|
LIMIT 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Regular content after the dataview block.
|
||||||
|
|
||||||
|
Also links to [[Linked Note A]].
|
||||||
8
tests/vault/Deeply Linked/Deep Note.md
Normal file
8
tests/vault/Deeply Linked/Deep Note.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Deep Note
|
||||||
|
|
||||||
|
This note is at depth 2 from the root:
|
||||||
|
Root Note → Linked Note A → Deep Note
|
||||||
|
|
||||||
|
It contains insights about deep work and focused concentration.
|
||||||
|
|
||||||
|
No further links from here (leaf node).
|
||||||
4
tests/vault/Excluded Folder/Should Skip.md
Normal file
4
tests/vault/Excluded Folder/Should Skip.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Should Be Excluded
|
||||||
|
|
||||||
|
This note lives in a folder that should be excluded from discovery.
|
||||||
|
It should never appear in gathered context.
|
||||||
17
tests/vault/Journal Note.md
Normal file
17
tests/vault/Journal Note.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Daily Journal
|
||||||
|
|
||||||
|
### 2026-02-25
|
||||||
|
Worked on setting up automated tests for the Obsidian plugin.
|
||||||
|
Made good progress on the mock layer.
|
||||||
|
|
||||||
|
### 2026-02-20
|
||||||
|
Started thinking about test architecture.
|
||||||
|
Reviewed several approaches for testing Electron apps.
|
||||||
|
|
||||||
|
### 2026-01-15
|
||||||
|
This is an old entry that should be filtered out by time window.
|
||||||
|
Did some unrelated work on another project.
|
||||||
|
|
||||||
|
### 2025-12-01
|
||||||
|
Very old entry. Should definitely be filtered out.
|
||||||
|
Working on something completely different.
|
||||||
7
tests/vault/Linked Note A.md
Normal file
7
tests/vault/Linked Note A.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Linked Note A
|
||||||
|
|
||||||
|
This note contains ideas about atomic note-taking.
|
||||||
|
|
||||||
|
Key insight: each note should contain exactly one idea.
|
||||||
|
|
||||||
|
See also [[Deeply Linked/Deep Note]] for more depth.
|
||||||
10
tests/vault/Linked Note B.md
Normal file
10
tests/vault/Linked Note B.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Linked Note B
|
||||||
|
|
||||||
|
This note discusses knowledge management systems.
|
||||||
|
|
||||||
|
The Zettelkasten method emphasizes:
|
||||||
|
- Atomic notes
|
||||||
|
- Linking between ideas
|
||||||
|
- Building a network of knowledge
|
||||||
|
|
||||||
|
Related: [[Linked Note A]]
|
||||||
8
tests/vault/Root Note.md
Normal file
8
tests/vault/Root Note.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
This is the root note for testing linked note discovery.
|
||||||
|
|
||||||
|
It links to [[Linked Note A]] and [[Linked Note B]].
|
||||||
|
|
||||||
|
It also has an embed: ![[Linked Note A]]
|
||||||
|
|
||||||
|
context:
|
||||||
|
Focus on extracting key concepts about testing
|
||||||
6
tests/vault/Special Characters!.md
Normal file
6
tests/vault/Special Characters!.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Note with Special: Characters* in "Title"
|
||||||
|
|
||||||
|
This note tests filename sanitization.
|
||||||
|
It contains characters that are invalid in filenames: \ / : * ? " < > |
|
||||||
|
|
||||||
|
Links to [[Root Note]].
|
||||||
45
tests/version-consistency.test.ts
Normal file
45
tests/version-consistency.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Guard test for the release contract. Obsidian resolves the "latest version"
|
||||||
|
// from manifest.json at the repo root, then downloads the GitHub release whose
|
||||||
|
// tag equals that version. If manifest.json / package.json / versions.json ever
|
||||||
|
// disagree, releases go out inconsistent — which is how the 0.6.0 release shipped
|
||||||
|
// with a stale package.json and a MISSING versions.json, and (plausibly) how the
|
||||||
|
// plugin got auto-removed from the community store. This test makes that drift a
|
||||||
|
// red build instead of a production incident. See docs/PUBLISHING.md.
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const readJson = (rel: string) =>
|
||||||
|
JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
|
||||||
|
const SEMVER = /^\d+\.\d+\.\d+$/; // x.y.z only — no "v" prefix, no pre-release suffix
|
||||||
|
|
||||||
|
describe('version consistency (release contract)', () => {
|
||||||
|
const manifest = readJson('manifest.json');
|
||||||
|
const pkg = readJson('package.json');
|
||||||
|
const versions = readJson('versions.json');
|
||||||
|
|
||||||
|
it('manifest.json version is valid semver (x.y.z, no v prefix)', () => {
|
||||||
|
expect(manifest.version).toMatch(SEMVER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('package.json version matches manifest.json', () => {
|
||||||
|
expect(pkg.version).toBe(manifest.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('versions.json contains the current manifest version', () => {
|
||||||
|
expect(Object.keys(versions)).toContain(manifest.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('versions.json maps the current version to manifest.minAppVersion', () => {
|
||||||
|
expect(versions[manifest.version]).toBe(manifest.minAppVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every versions.json key is valid semver', () => {
|
||||||
|
for (const v of Object.keys(versions)) {
|
||||||
|
expect(v, `versions.json key "${v}"`).toMatch(SEMVER);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,5 +21,9 @@
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"tests/**/*.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"0.6.0": "1.8.9"
|
||||||
|
}
|
||||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// Mock the obsidian module globally so imports don't fail
|
||||||
|
'obsidian': path.resolve(__dirname, 'tests/mocks/obsidian-module.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue