mirror of
https://github.com/bitsofchris/openaugi-obsidian-plugin.git
synced 2026-07-22 12:40:27 +00:00
task dispatch
This commit is contained in:
parent
f0a8816653
commit
7660c798b5
12 changed files with 713 additions and 8 deletions
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -49,17 +49,23 @@ Read the docs/CODEBASE_MAP.md to understand the project at a high level. Be sure
|
|||
## Development Guidelines
|
||||
|
||||
### Build Commands
|
||||
|
||||
**Important:** `npm` is not on the default PATH in this environment. Source nvm first:
|
||||
```bash
|
||||
export PATH="$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ | head -1)/bin:$PATH"
|
||||
```
|
||||
|
||||
Then run commands as normal:
|
||||
```bash
|
||||
# Development build with hot reload
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
# Production build (includes typecheck)
|
||||
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
|
||||
- TypeScript with strict mode enabled
|
||||
- ESLint configuration for code quality
|
||||
|
|
|
|||
119
PLAN.md
Normal file
119
PLAN.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Plan: Automated Testing for OpenAugi Plugin
|
||||
|
||||
## Problem
|
||||
|
||||
Currently all testing is manual: open Obsidian, create notes, run commands, inspect output. This means:
|
||||
- Claude Code can't verify changes without you manually testing
|
||||
- No regression safety net
|
||||
- Slow iteration cycle
|
||||
|
||||
## Analysis: Obsidian API Coupling
|
||||
|
||||
After reviewing every service, here's the coupling breakdown:
|
||||
|
||||
| Layer | Services | Obsidian APIs Used |
|
||||
|-------|----------|--------------------|
|
||||
| **Pure logic** (no Obsidian) | `OpenAIService`, `sanitizeFilename`, `BacklinkMapper`, `estimateTokens` | None - just `fetch` and string ops |
|
||||
| **Light coupling** | `FileService` | `Vault.adapter.exists`, `Vault.createFolder`, `Vault.create` |
|
||||
| **Heavy coupling** | `DistillService`, `ContextGatheringService` | `MetadataCache`, `vault.read`, `vault.getMarkdownFiles`, link resolution, Dataview API |
|
||||
|
||||
## Strategy: Mock Obsidian, Test Against Real Files
|
||||
|
||||
We create a **mock Obsidian API** backed by Node.js `fs`, pointed at a **test vault** with fixture markdown files. This lets us test ~80% of logic without Obsidian running.
|
||||
|
||||
No E2E/Electron testing for now — that's complex, fragile, and low-value compared to service-level testing.
|
||||
|
||||
## What We'll Build
|
||||
|
||||
### 1. Add Vitest (test framework)
|
||||
|
||||
- Install `vitest` as dev dependency
|
||||
- Add `npm test` and `npm run test:watch` scripts
|
||||
- Configure to handle TypeScript, mock the `obsidian` module globally
|
||||
|
||||
### 2. Create mock Obsidian API (`tests/mocks/obsidian-mock.ts`)
|
||||
|
||||
A filesystem-backed mock that simulates:
|
||||
- **`TFile`** — wraps real files with `path`, `basename`, `extension`, `stat`
|
||||
- **`Vault`** — `read()`, `create()`, `getMarkdownFiles()`, `adapter.exists()`, `createFolder()`, `getAbstractFileByPath()` — all backed by Node.js `fs`
|
||||
- **`MetadataCache`** — parses `[[wikilinks]]` from markdown files to build `resolvedLinks`, `getFileCache()`, and `getFirstLinkpathDest()`
|
||||
- **`App`** — ties vault + metadataCache together
|
||||
- **`Notice`** — no-op (just logs)
|
||||
|
||||
This is ~150 lines of code and gives us the ability to test DistillService, ContextGatheringService, and FileService against real markdown files.
|
||||
|
||||
### 3. Create test vault with fixtures (`tests/vault/`)
|
||||
|
||||
A small set of markdown files committed to the repo that cover key scenarios:
|
||||
|
||||
```
|
||||
tests/vault/
|
||||
├── Root Note.md # Has [[links]] to other notes
|
||||
├── Linked Note A.md # Forward-linked from root
|
||||
├── Linked Note B.md # Forward-linked from root
|
||||
├── Backlink Source.md # Links back to Root Note
|
||||
├── Journal Note.md # Has ### 2026-02-20 date headers
|
||||
├── Dataview Note.md # Has ```dataview blocks
|
||||
├── Collection Note.md # Has - [x] [[checked]] checkboxes
|
||||
├── Context Note.md # Has context: section
|
||||
├── Special Characters!.md # Tests filename sanitization
|
||||
├── Deeply Linked/
|
||||
│ └── Deep Note.md # Tests depth traversal
|
||||
└── Excluded Folder/
|
||||
└── Should Skip.md # Tests folder exclusion
|
||||
```
|
||||
|
||||
### 4. Write tests
|
||||
|
||||
**Pure unit tests** (no mocks needed):
|
||||
- `filename-utils.test.ts` — `sanitizeFilename`, `BacklinkMapper.processBacklinks`
|
||||
- `openai-service.test.ts` — prompt construction, `extractCustomContext`, response parsing (mock `fetch`)
|
||||
|
||||
**Integration tests** (with mock Obsidian):
|
||||
- `distill-service.test.ts` — link extraction, backlink discovery, content aggregation, journal filtering, date parsing
|
||||
- `context-gathering-service.test.ts` — BFS traversal, character limits, folder exclusion, backlink snippets
|
||||
- `file-service.test.ts` — file creation, collision handling, session folders, backlink mapping
|
||||
|
||||
### 5. npm scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
}
|
||||
```
|
||||
|
||||
## What This Enables
|
||||
|
||||
After setup, Claude Code can:
|
||||
1. Make a code change
|
||||
2. Run `npm test`
|
||||
3. See if anything broke
|
||||
4. Add new test cases for edge cases discovered during development
|
||||
|
||||
You can also add test notes to `tests/vault/` to capture edge cases you find during manual testing — they become permanent regression tests.
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| Install | `vitest` dev dependency |
|
||||
| Create | `vitest.config.ts` — test config |
|
||||
| Create | `tests/mocks/obsidian-mock.ts` — mock Obsidian API |
|
||||
| Create | `tests/vault/*.md` — ~10 fixture notes |
|
||||
| Create | `tests/filename-utils.test.ts` |
|
||||
| Create | `tests/openai-service.test.ts` |
|
||||
| Create | `tests/distill-service.test.ts` |
|
||||
| Create | `tests/file-service.test.ts` |
|
||||
| Create | `tests/context-gathering-service.test.ts` |
|
||||
| Update | `package.json` — add test scripts + vitest dep |
|
||||
| Update | `tsconfig.json` — exclude tests from build |
|
||||
| Update | `.gitignore` — add `tests/vault-output/` for test artifacts |
|
||||
|
||||
## Not in Scope (for now)
|
||||
|
||||
- **E2E testing with real Obsidian** — requires Playwright + Electron orchestration, fragile
|
||||
- **OpenAI API integration tests** — would cost real API credits; we mock `fetch` instead
|
||||
- **UI modal testing** — Obsidian modals are tightly coupled to the DOM
|
||||
- **Task dispatch testing** — requires tmux, hard to automate in CI
|
||||
|
|
@ -13,7 +13,10 @@ A comprehensive reference for navigating and extending this Obsidian plugin.
|
|||
| Link traversal & aggregation | [services/distill-service.ts](../src/services/distill-service.ts) |
|
||||
| Unified context discovery | [services/context-gathering-service.ts](../src/services/context-gathering-service.ts) |
|
||||
| Settings UI | [ui/settings-tab.ts](../src/ui/settings-tab.ts) |
|
||||
| Task dispatch service | [services/task-dispatch-service.ts](../src/services/task-dispatch-service.ts) |
|
||||
| Task dispatch types | [types/task-dispatch.ts](../src/types/task-dispatch.ts) |
|
||||
| Context modals | [ui/context-gathering-modal.ts](../src/ui/context-gathering-modal.ts), [ui/context-selection-modal.ts](../src/ui/context-selection-modal.ts), [ui/context-preview-modal.ts](../src/ui/context-preview-modal.ts) |
|
||||
| Session list modal | [ui/session-list-modal.ts](../src/ui/session-list-modal.ts) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -31,7 +34,14 @@ src/
|
|||
│ ├── openai-service.ts # OpenAI API calls
|
||||
│ ├── file-service.ts # File creation & output
|
||||
│ ├── distill-service.ts # Content aggregation, link traversal
|
||||
│ └── context-gathering-service.ts # Unified discovery orchestration
|
||||
│ ├── context-gathering-service.ts # Unified discovery orchestration
|
||||
│ └── task-dispatch-service.ts # tmux session & agent dispatch
|
||||
├── types/
|
||||
│ ├── plugin.ts # Plugin interface
|
||||
│ ├── settings.ts # Settings interfaces & defaults
|
||||
│ ├── context.ts # Context gathering types
|
||||
│ ├── transcript.ts # API response types
|
||||
│ └── task-dispatch.ts # Task dispatch types (agents, sessions, frontmatter)
|
||||
├── ui/
|
||||
│ ├── settings-tab.ts # Settings panel
|
||||
│ ├── loading-indicator.ts # Status bar spinner
|
||||
|
|
@ -39,6 +49,7 @@ src/
|
|||
│ ├── context-selection-modal.ts # Stage 2: Checkbox selection
|
||||
│ ├── context-preview-modal.ts # Stage 3: Preview & action
|
||||
│ ├── prompt-selection-modal.ts # Custom prompt picker
|
||||
│ ├── session-list-modal.ts # Task dispatch session list
|
||||
│ └── recent-activity-modal.ts # (Legacy)
|
||||
└── utils/
|
||||
└── filename-utils.ts # Sanitization, backlink mapping
|
||||
|
|
@ -148,6 +159,35 @@ gatherContext(config: ContextGatheringConfig): Promise<GatheredContext>
|
|||
|
||||
---
|
||||
|
||||
### TaskDispatchService (`task-dispatch-service.ts`)
|
||||
|
||||
Manages tmux-based agent sessions dispatched from task notes.
|
||||
|
||||
**Key Methods:**
|
||||
- `launchOrAttach(file)` - If tmux session exists, attach; otherwise assemble context, create session, start agent
|
||||
- `killSession(file)` - Kill tmux session for the given task note, update frontmatter
|
||||
- `listActiveSessions()` - List all `task-*` tmux sessions
|
||||
- `killSessionById(taskId)` - Kill session by ID (from session list modal)
|
||||
- `openTerminal(sessionName)` - Open terminal app attached to tmux session
|
||||
|
||||
**Context Assembly:**
|
||||
- Reads task note body (strips frontmatter)
|
||||
- Gets linked notes via `DistillService.getLinkedNotes()`
|
||||
- Aggregates via `DistillService.aggregateContent()`
|
||||
- Writes to temp file at `/tmp/openaugi/task-{id}-context.md`
|
||||
- Context injected into agent via `--append-system-prompt-file` (Claude Code)
|
||||
|
||||
**Frontmatter:**
|
||||
- Reads `task_id`, `agent`, `status` via `metadataCache.getFileCache()`
|
||||
- Updates `session_active`, `last_session` via `fileManager.processFrontMatter()`
|
||||
|
||||
**Shell Execution:**
|
||||
- Uses Node.js `child_process.exec` (available via Electron)
|
||||
- tmux commands: `has-session`, `new-session`, `send-keys`, `kill-session`, `list-sessions`
|
||||
- Terminal opening: `osascript` for iTerm2 or Terminal.app
|
||||
|
||||
---
|
||||
|
||||
## Types Reference
|
||||
|
||||
### Settings (`types/settings.ts`)
|
||||
|
|
@ -248,6 +288,9 @@ Commands are registered in `main.ts`:
|
|||
| `openaugi-process-notes` | Process notes | Unified flow: linked notes |
|
||||
| `openaugi-process-recent` | Process recent activity | Unified flow: recent activity |
|
||||
| `openaugi-save-context` | Save context | Save raw aggregated content |
|
||||
| `task-dispatch-launch` | Task dispatch: Launch or attach | Launch or attach to agent tmux session |
|
||||
| `task-dispatch-kill` | Task dispatch: Kill session | Kill active tmux session for task note |
|
||||
| `task-dispatch-list` | Task dispatch: List active sessions | Show modal of all active task sessions |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"id": "openaugi",
|
||||
"name": "OpenAugi",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"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.",
|
||||
"author": "Chris Lettieri",
|
||||
"authorUrl": "https://bitsofchris.com",
|
||||
"isDesktopOnly": false
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "open-augi",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
63
src/main.ts
63
src/main.ts
|
|
@ -4,14 +4,17 @@ import { OpenAIService } from './services/openai-service';
|
|||
import { FileService } from './services/file-service';
|
||||
import { DistillService } from './services/distill-service';
|
||||
import { ContextGatheringService } from './services/context-gathering-service';
|
||||
import { TaskDispatchService } from './services/task-dispatch-service';
|
||||
import { OpenAugiSettingTab } from './ui/settings-tab';
|
||||
import { LoadingIndicator } from './ui/loading-indicator';
|
||||
import { SessionListModal } from './ui/session-list-modal';
|
||||
import { sanitizeFilename, createFileWithCollisionHandling } from './utils/filename-utils';
|
||||
import { PromptSelectionModal, PromptSelectionConfig } from './ui/prompt-selection-modal';
|
||||
import { ContextGatheringModal } from './ui/context-gathering-modal';
|
||||
import { ContextSelectionModal } from './ui/context-selection-modal';
|
||||
import { ContextPreviewModal } from './ui/context-preview-modal';
|
||||
import { CommandOptions, ContextGatheringConfig, GatheredContext, DiscoveredNote } from './types/context';
|
||||
import { TaskSession } from './types/task-dispatch';
|
||||
|
||||
/**
|
||||
* A simple tokeinzer to estimate the number of tokens
|
||||
|
|
@ -29,6 +32,7 @@ export default class OpenAugiPlugin extends Plugin {
|
|||
fileService: FileService;
|
||||
distillService: DistillService;
|
||||
contextGatheringService: ContextGatheringService;
|
||||
taskDispatchService: TaskDispatchService;
|
||||
loadingIndicator: LoadingIndicator;
|
||||
|
||||
async onload() {
|
||||
|
|
@ -112,6 +116,53 @@ export default class OpenAugiPlugin extends Plugin {
|
|||
}
|
||||
});
|
||||
|
||||
// Task dispatch commands
|
||||
this.addCommand({
|
||||
id: 'task-dispatch-launch',
|
||||
name: 'Task dispatch: Launch or attach',
|
||||
callback: async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
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
|
||||
this.addSettingTab(new OpenAugiSettingTab(this.app, this));
|
||||
}
|
||||
|
|
@ -144,6 +195,11 @@ export default class OpenAugiPlugin extends Plugin {
|
|||
this.distillService,
|
||||
this.settings
|
||||
);
|
||||
this.taskDispatchService = new TaskDispatchService(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.distillService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -334,6 +390,13 @@ export default class OpenAugiPlugin extends Plugin {
|
|||
savedData.recentActivityDefaults
|
||||
);
|
||||
}
|
||||
if (savedData?.taskDispatch) {
|
||||
this.settings.taskDispatch = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS.taskDispatch,
|
||||
savedData.taskDispatch
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
|
|||
286
src/services/task-dispatch-service.ts
Normal file
286
src/services/task-dispatch-service.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { App, TFile, Notice } 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, TaskSession } from '../types/task-dispatch';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 exists = await this.tmuxSessionExists(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);
|
||||
|
||||
await this.createTmuxSession(sessionName, agentConfig, contextFilePath);
|
||||
await this.openTerminal(sessionName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Task dispatch error:', error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (msg.includes('tmux') && msg.includes('not found')) {
|
||||
new Notice('tmux is not installed. Install it with: brew install tmux');
|
||||
} else {
|
||||
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 exists = await this.tmuxSessionExists(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 {
|
||||
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 { 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 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 taskId = cache?.frontmatter?.['task_id'];
|
||||
return taskId ? String(taskId) : null;
|
||||
}
|
||||
|
||||
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## Additional Context\n\nThe OpenAugi MCP server is available if you need to query for more context beyond what\'s provided here.';
|
||||
|
||||
// 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(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(
|
||||
sessionName: string,
|
||||
agentConfig: AgentConfig,
|
||||
contextFilePath: string
|
||||
): Promise<void> {
|
||||
await execAsync(`tmux new-session -d -s ${this.shellEscape(sessionName)}`);
|
||||
|
||||
const agentCommand = `${agentConfig.command} ${agentConfig.contextFlag} ${this.shellEscape(contextFilePath)}`;
|
||||
await execAsync(
|
||||
`tmux send-keys -t ${this.shellEscape(sessionName)} ${this.shellEscape(agentCommand)} 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);
|
||||
if (cache?.frontmatter?.['task_id'] === taskId) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private shellEscape(str: string): string {
|
||||
return `'${str.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@ import { Plugin } from 'obsidian';
|
|||
import { OpenAugiSettings } from './settings';
|
||||
import { OpenAIService } from '../services/openai-service';
|
||||
import { FileService } from '../services/file-service';
|
||||
import { TaskDispatchService } from '../services/task-dispatch-service';
|
||||
|
||||
export default interface OpenAugiPlugin extends Plugin {
|
||||
settings: OpenAugiSettings;
|
||||
openAIService: OpenAIService;
|
||||
fileService: FileService;
|
||||
taskDispatchService: TaskDispatchService;
|
||||
saveSettings(): Promise<void>;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { App } from 'obsidian';
|
||||
import { TaskDispatchSettings } from './task-dispatch';
|
||||
|
||||
export interface RecentActivitySettings {
|
||||
daysBack: number;
|
||||
|
|
@ -28,6 +29,7 @@ export interface OpenAugiSettings {
|
|||
enableDistillLogging: boolean;
|
||||
recentActivityDefaults: RecentActivitySettings;
|
||||
contextGatheringDefaults: ContextGatheringDefaults;
|
||||
taskDispatch: TaskDispatchSettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: OpenAugiSettings = {
|
||||
|
|
@ -53,5 +55,19 @@ export const DEFAULT_SETTINGS: OpenAugiSettings = {
|
|||
filterRecentSectionsOnly: true,
|
||||
includeBacklinks: true,
|
||||
backlinkContextLines: 0
|
||||
},
|
||||
taskDispatch: {
|
||||
terminalApp: 'iterm2',
|
||||
agents: [
|
||||
{
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code',
|
||||
command: 'claude',
|
||||
contextFlag: '--append-system-prompt-file'
|
||||
}
|
||||
],
|
||||
defaultAgent: 'claude-code',
|
||||
contextTempDir: '/tmp/openaugi',
|
||||
maxContextChars: 200000
|
||||
}
|
||||
};
|
||||
25
src/types/task-dispatch.ts
Normal file
25
src/types/task-dispatch.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { TFile } from 'obsidian';
|
||||
|
||||
export interface AgentConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
contextFlag: string;
|
||||
}
|
||||
|
||||
export type TerminalApp = 'iterm2' | 'terminal-app';
|
||||
|
||||
export interface TaskDispatchSettings {
|
||||
terminalApp: TerminalApp;
|
||||
agents: AgentConfig[];
|
||||
defaultAgent: string;
|
||||
contextTempDir: string;
|
||||
maxContextChars: number;
|
||||
}
|
||||
|
||||
export interface TaskSession {
|
||||
taskId: string;
|
||||
tmuxSessionName: string;
|
||||
startedAt: string;
|
||||
noteFile?: TFile;
|
||||
}
|
||||
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,7 @@
|
|||
import { App, PluginSettingTab, Setting, Notice, DropdownComponent } from 'obsidian';
|
||||
import type OpenAugiPlugin from '../types/plugin';
|
||||
import { OpenAIService } from '../services/openai-service';
|
||||
import { TerminalApp } from '../types/task-dispatch';
|
||||
|
||||
export class OpenAugiSettingTab extends PluginSettingTab {
|
||||
plugin: OpenAugiPlugin;
|
||||
|
|
@ -350,6 +351,68 @@ export class OpenAugiSettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
// Task Dispatch Settings Header
|
||||
containerEl.createEl('h3', { text: 'Task Dispatch' });
|
||||
|
||||
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('Default agent')
|
||||
.setDesc('Agent to use when task note does not specify one')
|
||||
.addDropdown(dropdown => {
|
||||
for (const agent of this.plugin.settings.taskDispatch.agents) {
|
||||
dropdown.addOption(agent.id, agent.name);
|
||||
}
|
||||
dropdown.setValue(this.plugin.settings.taskDispatch.defaultAgent);
|
||||
dropdown.onChange(async (value) => {
|
||||
this.plugin.settings.taskDispatch.defaultAgent = value;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Max context characters')
|
||||
.setDesc('Maximum characters to include in the context bundle sent to the agent')
|
||||
.addText(text => text
|
||||
.setPlaceholder('200000')
|
||||
.setValue(String(this.plugin.settings.taskDispatch.maxContextChars))
|
||||
.onChange(async (value) => {
|
||||
const num = parseInt(value);
|
||||
if (!isNaN(num) && num > 0) {
|
||||
this.plugin.settings.taskDispatch.maxContextChars = num;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Context temp directory')
|
||||
.setDesc('Directory for temporary context files passed to agents')
|
||||
.addText(text => {
|
||||
text
|
||||
.setPlaceholder('/tmp/openaugi')
|
||||
.setValue(this.plugin.settings.taskDispatch.contextTempDir);
|
||||
text.inputEl.addEventListener('blur', async () => {
|
||||
const value = text.getValue();
|
||||
if (value !== this.plugin.settings.taskDispatch.contextTempDir) {
|
||||
this.plugin.settings.taskDispatch.contextTempDir = value;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
return text;
|
||||
});
|
||||
|
||||
// Advanced Settings Header
|
||||
containerEl.createEl('h3', { text: 'Advanced Settings' });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue