mirror of
https://github.com/denberek/obsidian-agent-fleet.git
synced 2026-07-22 07:47:06 +00:00
release: 0.16.0 — Windows stdin fix, robustness hardening, UX polish, internal restructuring
See CHANGELOG.md for details. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0ede55ffb6
commit
0b5ea13bd0
72 changed files with 11634 additions and 7564 deletions
41
CHANGELOG.md
41
CHANGELOG.md
|
|
@ -1,5 +1,46 @@
|
|||
# Changelog
|
||||
|
||||
## 0.16.0 — 2026-07-01
|
||||
|
||||
A hardening and quality release: a Windows fix, ~20 robustness fixes across process lifecycle and persistence, dashboard performance work, a batch of UX improvements, and a large internal restructuring — with 79 new tests (331 total).
|
||||
|
||||
**Windows: ENAMETOOLONG fixed** (thanks @zhaoming-mike!)
|
||||
- The Claude adapter now passes the prompt via stdin instead of argv, so one-shot runs (heartbeats, scheduled tasks, Wiki Keeper) no longer fail on Windows' 32,767-character command-line limit. Also makes long prompts more robust on macOS/Linux.
|
||||
|
||||
**Robustness**
|
||||
- Codex follow-up messages queued during a turn are no longer silently lost if the next turn fails to start.
|
||||
- Stdout buffers are capped (10MB) in both chat and one-shot runs — a runaway process can no longer exhaust memory.
|
||||
- The chat "working" spinner can no longer get stuck when a CLI process dies between turns.
|
||||
- Heartbeat overlap guard now holds until the run actually finishes (it previously released on enqueue, so overlapping heartbeat runs were possible).
|
||||
- One hung vault write can no longer wedge all subsequent memory captures (10s per-capture timeout).
|
||||
- Corrupted `permissions.json` or agent sidecar files now log an error and flag a validation issue in the dashboard instead of silently running the agent with empty permissions.
|
||||
- CLI output that fails to parse is now logged with context instead of showing "(no output)" with no clue — makes CLI version drift debuggable.
|
||||
- Reference validation re-runs after create/update/delete, so deleting a skill immediately flags agents that still reference it.
|
||||
- Working-memory files with a newer schema version are left untouched instead of being clobbered.
|
||||
- Legacy memory migration and Slack's send queue no longer race under concurrency; channel shutdown drains in-flight turns.
|
||||
- MCP OAuth callback server reliably frees its port (previously a lingering keep-alive connection could cause "address already in use" on the next login).
|
||||
- Stale MCP temp files and Codex overlays are swept on plugin load; temp names are now collision-proof UUIDs.
|
||||
- Reflection run failures now show a Notice (all other run types already did).
|
||||
|
||||
**UX**
|
||||
- Deleting a task, skill, or channel now asks for confirmation (parity with agent deletion).
|
||||
- Search: shows "No results" instead of nothing, a "Showing 10 of N" footer when truncated, and is debounced.
|
||||
- All create/edit forms disable their submit button while saving (no more accidental double-submits).
|
||||
- Empty states have Create buttons; a dismissible welcome card appears on a pristine fleet.
|
||||
- Agent cards show heartbeat state, schedule, next run time, and a quick pause/resume toggle.
|
||||
- Task scheduling is a single Immediate / Recurring / One-time selector (saved file format unchanged).
|
||||
- Chat error bubbles include recovery hints for common failures (context overflow, rate limit, auth, timeout, missing CLI).
|
||||
- Switching an agent between Claude and Codex explains how permission modes map.
|
||||
- Run success Notices report how many memory facts were captured.
|
||||
|
||||
**Performance**
|
||||
- Agents page no longer refetches runs per card (was O(n²) with fleet size).
|
||||
- Live run output batches dashboard updates (~100ms) instead of re-rendering per stdout chunk.
|
||||
- Run logs are parse-cached by mtime; fleet status is cached; name lookups are indexed; running-card timers share one ticker.
|
||||
|
||||
**Internal**
|
||||
- `FleetRepository` decomposed into focused store modules (`src/repository/`); the dashboard view split into form and page modules (`src/views/forms/`, `src/views/pages/`). Shared prompt assembly guarantees chat and one-shot runs build identical context. Channel adapters share text-splitting and backoff helpers. 221 lines of dead CSS removed.
|
||||
|
||||
## 0.15.0 — 2026-06-21
|
||||
|
||||
Accurate cost tracking, live follow-ups in channels, and Discord docs.
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ Discord uses the Gateway (outbound WebSocket) + REST. Features: `@agent-name` ro
|
|||
- Agents with `approval_required` set cannot be bound to a channel
|
||||
- Multi-agent routing: type `@agent-name: message` to switch agents, or use `/agents` for interactive picker
|
||||
- Obsidian must be running for channels to work — when closed, bots go offline
|
||||
- Live follow-ups (since 0.15.0): a message sent while the agent is still working is folded into the running turn (Claude: live stdin; Codex: queued follow-up turn) instead of waiting for a fresh turn — matching the in-app chat. Each injected follow-up gets its own reply; `[REMEMBER]` blocks are stripped from channel replies.
|
||||
|
||||
## Creating a Task
|
||||
|
||||
|
|
|
|||
319
main.js
319
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "agent-fleet",
|
||||
"name": "Agent Fleet",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"minAppVersion": "1.11.4",
|
||||
"description": "File-backed AI agents with task scheduling, channels, memory, and MCP — running on Claude Code or OpenAI Codex, all as plain markdown.",
|
||||
"description": "File-backed AI agents with task scheduling, channels, memory, and MCP \u2014 running on Claude Code or OpenAI Codex, all as plain markdown.",
|
||||
"author": "Denis Berekchiyan",
|
||||
"authorUrl": "https://github.com/denberek",
|
||||
"isDesktopOnly": true
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-agent-fleet",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-agent-fleet",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-agent-fleet",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"description": "Obsidian plugin for file-backed AI agents, task scheduling, channels (Slack, Telegram, Discord), heartbeat, and interactive chat.",
|
||||
"license": "MIT",
|
||||
"main": "plugin/main.js",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfig, FleetSettings } from "../types";
|
||||
import type { ExecBuildOptions } from "./types";
|
||||
import { claudeCodeAdapter, isCodexShapedModel } from "./claudeCodeAdapter";
|
||||
|
|
@ -151,6 +151,49 @@ describe("claudeCodeAdapter.parseExecOutput", () => {
|
|||
const parsed = claudeCodeAdapter.parseExecOutput("", "spawn failed", true);
|
||||
expect(parsed.outputText).toBe("spawn failed");
|
||||
});
|
||||
|
||||
describe("parse-failure logging", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not warn about non-JSON noise between valid stream events", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const stdout = [
|
||||
"Some CLI banner line",
|
||||
JSON.stringify({ type: "result", result: "Done.", usage: { input_tokens: 1, output_tokens: 1 } }),
|
||||
].join("\n");
|
||||
const parsed = claudeCodeAdapter.parseExecOutput(stdout, "", true);
|
||||
expect(parsed.outputText).toBe("Done.");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when a streaming run produced no parseable JSON event at all", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const parsed = claudeCodeAdapter.parseExecOutput("total garbage\nno json here", "", true);
|
||||
expect(parsed.outputText).toBe("(no output)");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const message = String(warn.mock.calls[0]?.[0]);
|
||||
expect(message).toContain("no parseable JSON event");
|
||||
expect(message).toContain("total garbage");
|
||||
});
|
||||
|
||||
it("warns when non-streaming whole-stdout JSON fails to parse", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const parsed = claudeCodeAdapter.parseExecOutput('{"result": "truncat', "", false);
|
||||
expect(parsed.outputText).toBe("(no output)");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const message = String(warn.mock.calls[0]?.[0]);
|
||||
expect(message).toContain("failed to parse");
|
||||
expect(message).toContain('{"result": "truncat');
|
||||
});
|
||||
|
||||
it("does not warn on empty stdout", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
claudeCodeAdapter.parseExecOutput("", "spawn failed", true);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCodexShapedModel", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { AgentConfig, ExecutionToolUse, FleetSettings } from "../types";
|
||||
import { splitLines } from "../utils/platform";
|
||||
import { restoreClaudeSettingsFile, writeClaudeSettingsFile } from "../utils/claudeSettings";
|
||||
import { parseJsonLoud, tryParseJson, warnJsonParseFailure } from "./parseHelpers";
|
||||
import type {
|
||||
CliAdapter,
|
||||
ExecBuildOptions,
|
||||
|
|
@ -263,25 +264,25 @@ export const claudeCodeAdapter: CliAdapter = {
|
|||
let rawJson: unknown;
|
||||
|
||||
if (streaming) {
|
||||
// stream-json: find the last parseable event (normally the "result" line)
|
||||
// stream-json: find the last parseable event (normally the "result"
|
||||
// line). Individual non-JSON lines are expected (banners, verbose
|
||||
// noise) — but a stream with NO parseable event at all is a real
|
||||
// failure worth surfacing.
|
||||
const lines = splitLines(trimmed);
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]?.trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
rawJson = parsed;
|
||||
break;
|
||||
}
|
||||
} catch { /* skip non-json lines */ }
|
||||
const parsed = tryParseJson(line);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
rawJson = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rawJson === undefined && trimmed) {
|
||||
warnJsonParseFailure("Claude Code stream-json output contained no parseable JSON event", trimmed);
|
||||
}
|
||||
} else if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
rawJson = JSON.parse(trimmed);
|
||||
} catch {
|
||||
rawJson = undefined;
|
||||
}
|
||||
rawJson = parseJsonLoud("Claude Code JSON output failed to parse", trimmed);
|
||||
}
|
||||
|
||||
let outputText = extractText(rawJson) ?? "";
|
||||
|
|
@ -291,17 +292,16 @@ export const claudeCodeAdapter: CliAdapter = {
|
|||
for (const line of splitLines(trimmed)) {
|
||||
const l = line.trim();
|
||||
if (!l) continue;
|
||||
try {
|
||||
const ev = JSON.parse(l) as ClaudeStreamEvent;
|
||||
// Only extract assistant text content, skip system/result/user events
|
||||
if (ev.type === "assistant" && ev.message?.content) {
|
||||
for (const block of ev.message.content) {
|
||||
if (block.type === "text" && block.text) textParts.push(block.text);
|
||||
}
|
||||
} else if (ev.type === "result" && typeof ev.result === "string") {
|
||||
textParts.push(ev.result);
|
||||
const ev = tryParseJson(l) as ClaudeStreamEvent | undefined;
|
||||
if (!ev || typeof ev !== "object") continue; // non-JSON noise is expected
|
||||
// Only extract assistant text content, skip system/result/user events
|
||||
if (ev.type === "assistant" && ev.message?.content) {
|
||||
for (const block of ev.message.content) {
|
||||
if (block.type === "text" && block.text) textParts.push(block.text);
|
||||
}
|
||||
} catch { /* not JSON, skip */ }
|
||||
} else if (ev.type === "result" && typeof ev.result === "string") {
|
||||
textParts.push(ev.result);
|
||||
}
|
||||
}
|
||||
outputText = textParts.join("\n").trim();
|
||||
}
|
||||
|
|
@ -316,18 +316,17 @@ export const claudeCodeAdapter: CliAdapter = {
|
|||
for (const line of splitLines(trimmed)) {
|
||||
const l = line.trim();
|
||||
if (!l) continue;
|
||||
try {
|
||||
const ev: unknown = JSON.parse(l);
|
||||
if (!concreteModel) {
|
||||
const m = extractConcreteModel(ev);
|
||||
if (m) concreteModel = m;
|
||||
}
|
||||
if (!finalResult) {
|
||||
const r = extractFinalResult(ev);
|
||||
if (r) finalResult = r;
|
||||
}
|
||||
if (concreteModel && finalResult) break;
|
||||
} catch { /* not JSON, skip */ }
|
||||
const ev = tryParseJson(l); // non-JSON noise is expected, skip silently
|
||||
if (ev === undefined) continue;
|
||||
if (!concreteModel) {
|
||||
const m = extractConcreteModel(ev);
|
||||
if (m) concreteModel = m;
|
||||
}
|
||||
if (!finalResult) {
|
||||
const r = extractFinalResult(ev);
|
||||
if (r) finalResult = r;
|
||||
}
|
||||
if (concreteModel && finalResult) break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -345,12 +344,10 @@ export const claudeCodeAdapter: CliAdapter = {
|
|||
extractStreamChunk(line: string): string | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
let event: Record<string, unknown>;
|
||||
try {
|
||||
event = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Live per-line parsing — non-JSON lines are expected noise, skip silently.
|
||||
const parsed = tryParseJson(trimmed);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
const event = parsed as Record<string, unknown>;
|
||||
const type = event.type as string | undefined;
|
||||
|
||||
// Assistant message: {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfig, FleetSettings } from "../types";
|
||||
import type { ExecBuildOptions } from "./types";
|
||||
import {
|
||||
|
|
@ -230,6 +230,39 @@ describe("codexAdapter.parseExecOutput", () => {
|
|||
const parsed = codexAdapter.parseExecOutput("", "codex: command failed", true);
|
||||
expect(parsed.outputText).toBe("codex: command failed");
|
||||
});
|
||||
|
||||
describe("parse-failure logging", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not warn about non-JSON noise mixed with valid JSONL events", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const stdout = [
|
||||
"codex banner text",
|
||||
JSON.stringify({ type: "item.completed", item: { id: "i1", type: "agent_message", text: "hi" } }),
|
||||
].join("\n");
|
||||
const parsed = codexAdapter.parseExecOutput(stdout, "", true);
|
||||
expect(parsed.outputText).toBe("hi");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when non-empty stdout contained no parseable JSONL event", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const parsed = codexAdapter.parseExecOutput("plain text output\nstill not json", "", true);
|
||||
expect(parsed.outputText).toBe("(no output)");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const message = String(warn.mock.calls[0]?.[0]);
|
||||
expect(message).toContain("no parseable JSONL event");
|
||||
expect(message).toContain("plain text output");
|
||||
});
|
||||
|
||||
it("does not warn on empty stdout", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
codexAdapter.parseExecOutput("", "codex: command failed", true);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("codexAdapter.extractStreamChunk", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { AgentConfig, ExecutionToolUse, FleetSettings } from "../types";
|
||||
import { splitLines } from "../utils/platform";
|
||||
import { setupCodexPermissions } from "./codexPermissions";
|
||||
import { tryParseJson, warnJsonParseFailure } from "./parseHelpers";
|
||||
import type {
|
||||
CliAdapter,
|
||||
ExecBuildOptions,
|
||||
|
|
@ -125,17 +126,16 @@ export function buildCodexExecArgs(opts: ExecBuildOptions): { args: string[]; st
|
|||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/** Parse one JSONL line into an object event, or null. Non-JSON lines are
|
||||
* expected noise (CLI banners, progress text) and are skipped silently —
|
||||
* parseExecOutput warns separately when NO line parsed at all. */
|
||||
function parseJsonLine(line: string): JsonRecord | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as JsonRecord)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parsed = tryParseJson(trimmed);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as JsonRecord)
|
||||
: null;
|
||||
}
|
||||
|
||||
function itemOf(event: JsonRecord): JsonRecord | null {
|
||||
|
|
@ -311,10 +311,12 @@ export const codexAdapter: CliAdapter = {
|
|||
let totalTokens = 0;
|
||||
let sessionId: string | undefined;
|
||||
let lastTurnCompleted: JsonRecord | undefined;
|
||||
let parsedAnyEvent = false;
|
||||
|
||||
for (const line of splitLines(stdout)) {
|
||||
const event = parseJsonLine(line);
|
||||
if (!event) continue;
|
||||
parsedAnyEvent = true;
|
||||
const type = typeof event.type === "string" ? event.type : "";
|
||||
|
||||
if (type === "thread.started" && typeof event.thread_id === "string") {
|
||||
|
|
@ -342,6 +344,13 @@ export const codexAdapter: CliAdapter = {
|
|||
}
|
||||
}
|
||||
|
||||
// Non-JSON lines between events are expected; a non-empty stdout with NO
|
||||
// parseable JSONL event at all means the CLI's output format drifted (or
|
||||
// --json was ignored) — surface that instead of a silent "(no output)".
|
||||
if (!parsedAnyEvent && stdout.trim()) {
|
||||
warnJsonParseFailure("Codex exec output contained no parseable JSONL event", stdout.trim());
|
||||
}
|
||||
|
||||
let outputText = agentMessages.join("\n\n").trim();
|
||||
if (!outputText) outputText = errors.join("\n").trim();
|
||||
if (!outputText) outputText = stderr.trim() || "(no output)";
|
||||
|
|
|
|||
|
|
@ -239,8 +239,14 @@ export function buildCodexHomeOverlay(
|
|||
if (!f.endsWith(".rules")) continue;
|
||||
try {
|
||||
copyFileSync(join(userRulesDir, f), join(rulesDir, `user-${f}`));
|
||||
} catch {
|
||||
/* skip an unreadable global rule rather than fail the run */
|
||||
} catch (err) {
|
||||
// Skip an unreadable global rule rather than fail the run — but say
|
||||
// so: the user's safety rules are being dropped for this agent's run.
|
||||
console.warn(
|
||||
`Agent Fleet: couldn't copy the Codex rules file ${join(userRulesDir, f)} into agent ` +
|
||||
`"${agent.name}"'s permission overlay (${err instanceof Error ? err.message : String(err)}); ` +
|
||||
`its rules will NOT apply to this agent's runs.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
src/adapters/parseHelpers.test.ts
Normal file
57
src/adapters/parseHelpers.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseJsonLoud, tryParseJson, warnJsonParseFailure } from "./parseHelpers";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("tryParseJson", () => {
|
||||
it("parses valid JSON of any shape", () => {
|
||||
expect(tryParseJson('{"a":1}')).toEqual({ a: 1 });
|
||||
expect(tryParseJson("[1,2]")).toEqual([1, 2]);
|
||||
expect(tryParseJson('"str"')).toBe("str");
|
||||
expect(tryParseJson("3")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns undefined for non-JSON without logging", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(tryParseJson("Loading model… please wait")).toBeUndefined();
|
||||
expect(tryParseJson("")).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJsonLoud", () => {
|
||||
it("parses valid JSON silently", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(parseJsonLoud("ctx", '{"ok":true}')).toEqual({ ok: true });
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns with context, parse error, and a text preview on failure", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(parseJsonLoud("final result JSON", "{broken")).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const message = String(warn.mock.calls[0]?.[0]);
|
||||
expect(message).toContain("final result JSON");
|
||||
expect(message).toContain("{broken");
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnJsonParseFailure", () => {
|
||||
it("truncates the preview to ~200 chars", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const long = "x".repeat(500);
|
||||
warnJsonParseFailure("ctx", long);
|
||||
const message = String(warn.mock.calls[0]?.[0]);
|
||||
expect(message).toContain("x".repeat(200));
|
||||
expect(message).not.toContain("x".repeat(201));
|
||||
expect(message).toContain("…");
|
||||
});
|
||||
|
||||
it("includes the error message when an error is given", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnJsonParseFailure("ctx", "text", new Error("Unexpected token"));
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain("Unexpected token");
|
||||
});
|
||||
});
|
||||
49
src/adapters/parseHelpers.ts
Normal file
49
src/adapters/parseHelpers.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Shared JSON-parsing helpers for the CLI adapters.
|
||||
*
|
||||
* Two distinct situations, two helpers:
|
||||
*
|
||||
* - `tryParseJson` — per-line parsing of stream-json/JSONL output, where
|
||||
* non-JSON lines are EXPECTED (CLI banners, verbose noise, progress text).
|
||||
* Fails silently by design; warning per line would spam the console.
|
||||
*
|
||||
* - `parseJsonLoud` / `warnJsonParseFailure` — for output that SHOULD be
|
||||
* valid JSON (a whole-stdout result payload, or a stream that yielded no
|
||||
* parseable event at all). Silence here hides real failures: the user sees
|
||||
* "(no output)" with no clue that the CLI's output format drifted. These
|
||||
* log a console.warn with the parse error and a preview of the offending
|
||||
* text so version drift is debuggable.
|
||||
*/
|
||||
|
||||
/** Parse a line that may or may not be JSON. Returns undefined on failure
|
||||
* WITHOUT logging — non-JSON lines are expected between stream events. */
|
||||
export function tryParseJson(text: string): unknown | undefined {
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const PREVIEW_LENGTH = 200;
|
||||
|
||||
/** Warn that output which should have contained JSON didn't parse, with a
|
||||
* ~200-char preview of the offending text and the parse error (if any). */
|
||||
export function warnJsonParseFailure(context: string, text: string, error?: unknown): void {
|
||||
const preview = text.length > PREVIEW_LENGTH ? `${text.slice(0, PREVIEW_LENGTH)}…` : text;
|
||||
const reason =
|
||||
error instanceof Error ? error.message : error !== undefined ? String(error) : "no parseable JSON found";
|
||||
console.warn(`Agent Fleet: ${context} — ${reason}. Output begins: ${preview}`);
|
||||
}
|
||||
|
||||
/** Parse JSON that is expected to be valid (e.g. a whole-stdout result
|
||||
* payload). Logs a console.warn via `warnJsonParseFailure` on failure and
|
||||
* returns undefined so callers keep their existing fallback behavior. */
|
||||
export function parseJsonLoud(context: string, text: string): unknown | undefined {
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch (err) {
|
||||
warnJsonParseFailure(context, text, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -857,6 +857,7 @@ Discord uses the Gateway (outbound WebSocket) + REST. Features: \`@agent-name\`
|
|||
- Agents with \`approval_required\` set cannot be bound to a channel
|
||||
- Multi-agent routing: type \`@agent-name: message\` to switch agents, or use \`/agents\` for interactive picker
|
||||
- Obsidian must be running for channels to work — when closed, bots go offline
|
||||
- Live follow-ups (since 0.15.0): a message sent while the agent is still working is folded into the running turn (Claude: live stdin; Codex: queued follow-up turn) instead of waiting for a fresh turn — matching the in-app chat. Each injected follow-up gets its own reply; \`[REMEMBER]\` blocks are stripped from channel replies.
|
||||
|
||||
## Creating a Task
|
||||
|
||||
|
|
|
|||
338
src/fleetRepository.test.ts
Normal file
338
src/fleetRepository.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The shared obsidian stub lacks App/FileSystemAdapter (fleetRepository
|
||||
// references FileSystemAdapter at runtime via instanceof); extend it here.
|
||||
// Its parseYaml also lacks inline-list support (`skills: [s1]`), which the
|
||||
// reference-validation tests need — bolt on a minimal `[a, b]` parser.
|
||||
vi.mock("obsidian", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const baseParseYaml = actual.parseYaml as (input: string) => Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
App: class App {},
|
||||
FileSystemAdapter: class FileSystemAdapter {},
|
||||
parseYaml: (input: string): Record<string, unknown> => {
|
||||
const out = baseParseYaml(input);
|
||||
for (const [key, value] of Object.entries(out)) {
|
||||
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
|
||||
const inner = value.slice(1, -1).trim();
|
||||
out[key] = inner
|
||||
? inner.split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
||||
: [];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import type { App } from "obsidian";
|
||||
import { TFile, TFolder } from "obsidian";
|
||||
import { DEFAULT_SETTINGS } from "./constants";
|
||||
import { FleetRepository } from "./fleetRepository";
|
||||
|
||||
/** Minimal in-memory vault: enough surface for the repository paths under test. */
|
||||
class FakeVault {
|
||||
files = new Map<string, TFile>();
|
||||
contents = new Map<string, string>();
|
||||
folders = new Map<string, TFolder>();
|
||||
cachedReadCalls = 0;
|
||||
private clock = 1;
|
||||
|
||||
addFile(path: string, content: string): TFile {
|
||||
const existing = this.files.get(path);
|
||||
if (existing) {
|
||||
this.contents.set(path, content);
|
||||
existing.stat.mtime = this.clock++;
|
||||
existing.stat.size = content.length;
|
||||
return existing;
|
||||
}
|
||||
const file = new TFile();
|
||||
file.path = path;
|
||||
const base = path.split("/").pop() ?? "";
|
||||
file.basename = base.replace(/\.[^.]+$/, "");
|
||||
file.extension = base.split(".").pop() ?? "";
|
||||
file.stat = { ctime: this.clock, mtime: this.clock++, size: content.length };
|
||||
this.files.set(path, file);
|
||||
this.contents.set(path, content);
|
||||
this.ensureFolderChain(path.slice(0, path.lastIndexOf("/")));
|
||||
return file;
|
||||
}
|
||||
|
||||
private ensureFolderChain(path: string): void {
|
||||
if (!path || this.folders.has(path)) return;
|
||||
const folder = new TFolder();
|
||||
folder.path = path;
|
||||
this.folders.set(path, folder);
|
||||
const parent = path.slice(0, path.lastIndexOf("/"));
|
||||
if (parent) this.ensureFolderChain(parent);
|
||||
}
|
||||
|
||||
getAbstractFileByPath(path: string): TFile | TFolder | null {
|
||||
const file = this.files.get(path);
|
||||
if (file) return file;
|
||||
const folder = this.folders.get(path);
|
||||
if (!folder) return null;
|
||||
const children: Array<TFile | TFolder> = [];
|
||||
for (const candidate of [...this.folders.values(), ...this.files.values()]) {
|
||||
if (!candidate.path.startsWith(`${path}/`)) continue;
|
||||
if (candidate.path.slice(path.length + 1).includes("/")) continue;
|
||||
children.push(candidate);
|
||||
}
|
||||
folder.children = children;
|
||||
return folder;
|
||||
}
|
||||
|
||||
async cachedRead(file: TFile): Promise<string> {
|
||||
this.cachedReadCalls++;
|
||||
const content = this.contents.get(file.path);
|
||||
if (content === undefined) throw new Error(`no such file: ${file.path}`);
|
||||
return content;
|
||||
}
|
||||
|
||||
async create(path: string, content: string): Promise<TFile> {
|
||||
if (this.files.has(path)) throw new Error("File already exists");
|
||||
return this.addFile(path, content);
|
||||
}
|
||||
|
||||
async createFolder(path: string): Promise<void> {
|
||||
if (this.folders.has(path)) throw new Error("Folder already exists");
|
||||
this.ensureFolderChain(path);
|
||||
}
|
||||
|
||||
async modify(file: TFile, content: string): Promise<void> {
|
||||
this.addFile(file.path, content);
|
||||
}
|
||||
|
||||
getMarkdownFiles(): TFile[] {
|
||||
return [...this.files.values()].filter((f) => f.extension === "md");
|
||||
}
|
||||
|
||||
removeTree(path: string): void {
|
||||
this.files.delete(path);
|
||||
this.contents.delete(path);
|
||||
this.folders.delete(path);
|
||||
for (const p of [...this.files.keys()]) {
|
||||
if (p.startsWith(`${path}/`)) {
|
||||
this.files.delete(p);
|
||||
this.contents.delete(p);
|
||||
}
|
||||
}
|
||||
for (const p of [...this.folders.keys()]) {
|
||||
if (p.startsWith(`${path}/`)) this.folders.delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeApp(vault: FakeVault): App {
|
||||
return {
|
||||
vault,
|
||||
fileManager: {
|
||||
trashFile: async (file: TFile | TFolder) => {
|
||||
vault.removeTree(file.path);
|
||||
},
|
||||
},
|
||||
} as unknown as App;
|
||||
}
|
||||
|
||||
describe("FleetRepository", () => {
|
||||
let vault: FakeVault;
|
||||
let repo: FleetRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vault = new FakeVault();
|
||||
repo = new FleetRepository(makeApp(vault), { ...DEFAULT_SETTINGS });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("name-keyed lookups", () => {
|
||||
it("reflects loads, removals, and reloads (index never goes stale)", async () => {
|
||||
vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\n---\n\nBody\n");
|
||||
vault.addFile("_fleet/tasks/t1.md", "---\ntask_id: t1\nagent: foo\ntype: immediate\n---\n\nDo it\n");
|
||||
vault.addFile("_fleet/skills/s1.md", "---\nname: s1\n---\n\nSkill\n");
|
||||
|
||||
await repo.loadFile("_fleet/agents/foo.md");
|
||||
await repo.loadFile("_fleet/tasks/t1.md");
|
||||
await repo.loadFile("_fleet/skills/s1.md");
|
||||
|
||||
expect(repo.getAgentByName("foo")?.filePath).toBe("_fleet/agents/foo.md");
|
||||
expect(repo.getTaskById("t1")?.filePath).toBe("_fleet/tasks/t1.md");
|
||||
expect(repo.getSkillByName("s1")?.filePath).toBe("_fleet/skills/s1.md");
|
||||
|
||||
repo.removeFile("_fleet/agents/foo.md");
|
||||
expect(repo.getAgentByName("foo")).toBeUndefined();
|
||||
|
||||
await repo.loadFile("_fleet/agents/foo.md");
|
||||
expect(repo.getAgentByName("foo")).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps the first entity when names collide (linear-scan parity)", async () => {
|
||||
vault.addFile("_fleet/agents/a1.md", "---\nname: dup\nmodel: sonnet\n---\n\nA\n");
|
||||
vault.addFile("_fleet/agents/a2.md", "---\nname: dup\nmodel: sonnet\n---\n\nB\n");
|
||||
await repo.loadFile("_fleet/agents/a1.md");
|
||||
await repo.loadFile("_fleet/agents/a2.md");
|
||||
expect(repo.getAgentByName("dup")?.filePath).toBe("_fleet/agents/a1.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("corrupt permissions.json", () => {
|
||||
it("loads the folder agent, logs an error, and records a validation issue", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vault.addFile("_fleet/agents/bot/agent.md", "---\nname: bot\n---\n\nPrompt\n");
|
||||
vault.addFile("_fleet/agents/bot/permissions.json", "{not json");
|
||||
|
||||
await repo.loadAll();
|
||||
|
||||
const agent = repo.getAgentByName("bot");
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent?.permissionRules).toEqual({ allow: [], deny: [] });
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
const issues = repo
|
||||
.getSnapshot()
|
||||
.validationIssues.filter((i) => i.path === "_fleet/agents/bot/permissions.json");
|
||||
// Exactly one, even though loadAll also reloads the folder agent via
|
||||
// loadFile(agent.md) — the stale issue is cleared before re-parsing.
|
||||
expect(issues).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateLegacyMemory", () => {
|
||||
const legacyPath = "_fleet/memory/bot.md";
|
||||
const workingPath = "_fleet/memory/bot/working.md";
|
||||
|
||||
it("lets concurrent callers await the same in-flight migration", async () => {
|
||||
vault.addFile(legacyPath, "---\nagent: bot\n---\n\n## Learned Context\n\n- fact one\n- fact two\n");
|
||||
|
||||
let secondSawWorking = false;
|
||||
const first = repo.migrateLegacyMemory("bot");
|
||||
const second = repo.migrateLegacyMemory("bot").then(() => {
|
||||
secondSawWorking = vault.getAbstractFileByPath(workingPath) !== null;
|
||||
});
|
||||
await Promise.all([first, second]);
|
||||
|
||||
// The second caller must resolve only after the migration completed.
|
||||
expect(secondSawWorking).toBe(true);
|
||||
expect(vault.getAbstractFileByPath(workingPath)).toBeInstanceOf(TFile);
|
||||
expect(vault.getAbstractFileByPath(legacyPath)).toBeNull();
|
||||
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const raw = vault.contents.get(`_fleet/memory/bot/raw/${day}.md`) ?? "";
|
||||
expect(raw.match(/\[migrated\] Imported/g)).toHaveLength(1);
|
||||
|
||||
// A later call is a no-op — no double seeding.
|
||||
await repo.migrateLegacyMemory("bot");
|
||||
const rawAfter = vault.contents.get(`_fleet/memory/bot/raw/${day}.md`) ?? "";
|
||||
expect(rawAfter).toBe(raw);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRunLog cache", () => {
|
||||
it("returns the cached parse until mtime changes", async () => {
|
||||
const file = vault.addFile(
|
||||
"_fleet/runs/2026-07-01/000000-a-t.md",
|
||||
"---\nrun_id: r1\nagent: a\ntask: t\nstatus: success\nstarted: 2026-07-01T00:00:00Z\n---\n\n## Prompt\n\nhi\n\n## Output\n\nok\n",
|
||||
);
|
||||
|
||||
const first = await repo.readRunLog(file);
|
||||
expect(first?.status).toBe("success");
|
||||
const readsAfterFirst = vault.cachedReadCalls;
|
||||
|
||||
const second = await repo.readRunLog(file);
|
||||
expect(second).toBe(first); // cached object, no re-read
|
||||
expect(vault.cachedReadCalls).toBe(readsAfterFirst);
|
||||
|
||||
// Modifying the file bumps mtime → cache invalidates.
|
||||
vault.addFile(
|
||||
file.path,
|
||||
"---\nrun_id: r1\nagent: a\ntask: t\nstatus: failure\nstarted: 2026-07-01T00:00:00Z\n---\n\n## Prompt\n\nhi\n\n## Output\n\nboom\n",
|
||||
);
|
||||
const third = await repo.readRunLog(file);
|
||||
expect(third?.status).toBe("failure");
|
||||
expect(vault.cachedReadCalls).toBe(readsAfterFirst + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reference validation re-runs after mutations", () => {
|
||||
it("flags an agent's dangling skill reference immediately after deleteSkill", async () => {
|
||||
vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\nskills: [s1]\n---\n\nBody\n");
|
||||
vault.addFile("_fleet/skills/s1.md", "---\nname: s1\n---\n\nSkill\n");
|
||||
await repo.loadAll();
|
||||
expect(repo.getSnapshot().validationIssues).toHaveLength(0);
|
||||
|
||||
await repo.deleteSkill("s1");
|
||||
|
||||
// No loadAll() in between — the issue must appear from the delete alone.
|
||||
const issues = repo.getSnapshot().validationIssues;
|
||||
expect(
|
||||
issues.some(
|
||||
(i) => i.path === "_fleet/agents/foo.md" && i.message.includes("missing skill `s1`"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(repo.getSkillByName("s1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flags a task's dangling agent reference immediately after deleteAgent", async () => {
|
||||
vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\n---\n\nBody\n");
|
||||
vault.addFile("_fleet/tasks/t1.md", "---\ntask_id: t1\nagent: foo\ntype: immediate\n---\n\nDo\n");
|
||||
await repo.loadAll();
|
||||
expect(repo.getSnapshot().validationIssues).toHaveLength(0);
|
||||
|
||||
await repo.deleteAgent("foo", false);
|
||||
|
||||
expect(
|
||||
repo.getSnapshot().validationIssues.some(
|
||||
(i) => i.path === "_fleet/tasks/t1.md" && i.message.includes("missing agent `foo`"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("clears a dangling-skill issue as soon as the skill is created", async () => {
|
||||
vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\nskills: [s1]\n---\n\nBody\n");
|
||||
await repo.loadAll();
|
||||
expect(
|
||||
repo.getSnapshot().validationIssues.some((i) => i.message.includes("missing skill `s1`")),
|
||||
).toBe(true);
|
||||
|
||||
await repo.createSkillTemplate("s1");
|
||||
|
||||
expect(
|
||||
repo.getSnapshot().validationIssues.some((i) => i.message.includes("missing skill `s1`")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("re-running validation neither duplicates reference issues nor clears load-time corrupt-file issues", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
// Load-time issue: corrupt permissions.json on a folder agent.
|
||||
vault.addFile("_fleet/agents/bot/agent.md", "---\nname: bot\n---\n\nPrompt\n");
|
||||
vault.addFile("_fleet/agents/bot/permissions.json", "{not json");
|
||||
// Reference issue: task pointing at a missing agent.
|
||||
vault.addFile("_fleet/tasks/t1.md", "---\ntask_id: t1\nagent: ghost\ntype: immediate\n---\n\nDo\n");
|
||||
await repo.loadAll();
|
||||
|
||||
const count = (issues: Array<{ path: string; message: string }>) => ({
|
||||
corrupt: issues.filter((i) => i.path === "_fleet/agents/bot/permissions.json").length,
|
||||
dangling: issues.filter((i) => i.message.includes("missing agent `ghost`")).length,
|
||||
});
|
||||
expect(count(repo.getSnapshot().validationIssues)).toEqual({ corrupt: 1, dangling: 1 });
|
||||
|
||||
// Two mutations, each re-running validation.
|
||||
await repo.updateTask("t1", { priority: "high" });
|
||||
await repo.updateHeartbeat("bot", { enabled: false });
|
||||
|
||||
expect(count(repo.getSnapshot().validationIssues)).toEqual({ corrupt: 1, dangling: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail-soft reads warn instead of staying silent", () => {
|
||||
it("readCandidates returns [] and warns on corrupt JSON", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vault.addFile("_fleet/memory/bot/candidates.json", "{oops");
|
||||
expect(await repo.readCandidates("bot")).toEqual([]);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
86
src/main.ts
86
src/main.ts
|
|
@ -1,5 +1,6 @@
|
|||
import { existsSync, readFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { readdir, rm, stat } from "fs/promises";
|
||||
import { homedir, tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
Notice,
|
||||
|
|
@ -51,6 +52,11 @@ export default class AgentFleetPlugin extends Plugin {
|
|||
channelManager!: ChannelManager;
|
||||
secretStore!: SecretStore;
|
||||
|
||||
/** Successful CLI verifications, keyed by `${label}:${cliPath}` → timestamp.
|
||||
* Skips re-spawning `--version` for the same binary within the TTL. */
|
||||
private cliVerifiedAt = new Map<string, number>();
|
||||
private static readonly CLI_VERIFY_TTL_MS = 5 * 60_000;
|
||||
|
||||
private statusBarEl?: HTMLElement;
|
||||
private subscribedViews = new Set<{ render: () => Promise<void> }>();
|
||||
private vaultChangeTimer?: number;
|
||||
|
|
@ -209,6 +215,12 @@ export default class AgentFleetPlugin extends Plugin {
|
|||
// Claude/Codex servers into the registry on first load.
|
||||
void this.importNativeMcpServers();
|
||||
|
||||
// Sweep temp files a force-quit orphaned (their normal cleanup lives in
|
||||
// finally blocks that never ran). Fire-and-forget — must not block load.
|
||||
void this.cleanupStaleTempFiles().catch((err) => {
|
||||
console.warn("Agent Fleet: stale temp-file sweep failed", err);
|
||||
});
|
||||
|
||||
// Periodically refresh expiring OAuth tokens (every 30 min) so the next run
|
||||
// projects a fresh bearer. Refreshed tokens persist to SecretStore.
|
||||
this.registerInterval(
|
||||
|
|
@ -441,6 +453,25 @@ export default class AgentFleetPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private async verifyCliBinary(cliPath: string, label: string, showNotice: boolean): Promise<boolean> {
|
||||
// Skip re-spawning `--version` if this exact binary verified successfully
|
||||
// recently — settings edits would otherwise probe on every save.
|
||||
const cacheKey = `${label}:${cliPath}`;
|
||||
const verifiedAt = this.cliVerifiedAt.get(cacheKey);
|
||||
if (verifiedAt !== undefined && Date.now() - verifiedAt < AgentFleetPlugin.CLI_VERIFY_TTL_MS) {
|
||||
if (showNotice) {
|
||||
new Notice(`${label} CLI available.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const installHint =
|
||||
label === "Claude"
|
||||
? "install with: npm install -g @anthropic-ai/claude-code"
|
||||
: "install with: npm install -g @openai/codex";
|
||||
const failureMessage =
|
||||
`${label} CLI verification failed (path: ${cliPath || "not set"}). ` +
|
||||
`Fix the ${label} CLI Path in settings, or ${installHint}`;
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
// On macOS/Linux: spawns through login shell so env vars are available.
|
||||
// On Windows: spawns directly (env is inherited from the system).
|
||||
|
|
@ -451,24 +482,71 @@ export default class AgentFleetPlugin extends Plugin {
|
|||
});
|
||||
proc.on("close", (code) => {
|
||||
const ok = code === 0;
|
||||
if (!ok) {
|
||||
if (ok) {
|
||||
this.cliVerifiedAt.set(cacheKey, Date.now());
|
||||
} else {
|
||||
console.error(`Agent Fleet: ${label} CLI verification failed`, stderr);
|
||||
}
|
||||
if (showNotice) {
|
||||
new Notice(ok ? `${label} CLI available.` : `${label} CLI verification failed — check ${label} CLI Path in settings.`);
|
||||
new Notice(ok ? `${label} CLI available.` : failureMessage, ok ? 5000 : 10000);
|
||||
}
|
||||
resolve(ok);
|
||||
});
|
||||
proc.on("error", (error) => {
|
||||
console.error(`Agent Fleet: ${label} CLI verification error`, error);
|
||||
if (showNotice) {
|
||||
new Notice(`${label} CLI verification failed — check ${label} CLI Path in settings.`);
|
||||
new Notice(failureMessage, 10000);
|
||||
}
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort removal of per-run temp files that a force-quit left behind:
|
||||
* MCP projection files under `<vaultBase>/.claude/` — `af-mcp.<token>.json`
|
||||
* and `af-mcp-<slug>.<token>.cjs` (mcpProjection.ts) plus
|
||||
* `af-remember-mcp.<token>.{json,cjs}` (rememberMcpServer.ts) — older than
|
||||
* 24h, and per-agent CODEX_HOME overlay dirs under the OS temp dir
|
||||
* (codexPermissions.ts OVERLAY_ROOT) older than 7 days. Conservative: only
|
||||
* names matching the plugin's own prefixes are touched.
|
||||
*/
|
||||
private async cleanupStaleTempFiles(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const sweep = async (dir: string, matches: (name: string) => boolean, maxAgeMs: number) => {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch {
|
||||
return; // dir missing/unreadable — nothing to sweep
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!matches(name)) continue;
|
||||
const fullPath = join(dir, name);
|
||||
try {
|
||||
const info = await stat(fullPath);
|
||||
if (now - info.mtimeMs > maxAgeMs) {
|
||||
await rm(fullPath, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// best-effort — skip entries we can't stat/remove
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const vaultBase = this.repository.getVaultBasePath();
|
||||
if (vaultBase) {
|
||||
await sweep(
|
||||
join(vaultBase, ".claude"),
|
||||
(name) => /^(af-mcp|af-remember-mcp)[.-].+\.(json|cjs)$/.test(name),
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
// Overlays are keyed by agent and rebuilt on demand, so removing old ones
|
||||
// is safe even if the agent still exists.
|
||||
await sweep(join(tmpdir(), "agent-fleet-codex"), () => true, 7 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async openPath(path: string): Promise<void> {
|
||||
const file = this.app.vault.getAbstractFileByPath(normalizePath(path));
|
||||
if (file instanceof TFile) {
|
||||
|
|
|
|||
111
src/repository/conversationStore.test.ts
Normal file
111
src/repository/conversationStore.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfig } from "../types";
|
||||
import { ConversationStore } from "./conversationStore";
|
||||
import { FakeVault, makeApp } from "./testSupport";
|
||||
|
||||
function agent(overrides: Partial<AgentConfig>): AgentConfig {
|
||||
return {
|
||||
filePath: "_fleet/agents/bot.md",
|
||||
name: "bot",
|
||||
model: "sonnet",
|
||||
adapter: "claude-code",
|
||||
permissionMode: "bypassPermissions",
|
||||
maxRetries: 1,
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
approvalRequired: [],
|
||||
memory: false,
|
||||
memoryMaxEntries: 100,
|
||||
memoryTokenBudget: 2000,
|
||||
reflection: {
|
||||
enabled: false,
|
||||
schedule: "0 3 * * *",
|
||||
recurrenceThreshold: 3,
|
||||
proposeSkills: false,
|
||||
model: undefined,
|
||||
},
|
||||
autoCompactThreshold: 85,
|
||||
tags: [],
|
||||
avatar: "",
|
||||
body: "",
|
||||
contextBody: "",
|
||||
skillsBody: "",
|
||||
env: {},
|
||||
permissionRules: { allow: [], deny: [] },
|
||||
isFolder: false,
|
||||
heartbeatEnabled: false,
|
||||
heartbeatSchedule: "",
|
||||
heartbeatBody: "",
|
||||
heartbeatNotify: true,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
...overrides,
|
||||
} as AgentConfig;
|
||||
}
|
||||
|
||||
describe("ConversationStore", () => {
|
||||
let vault: FakeVault;
|
||||
let store: ConversationStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vault = new FakeVault();
|
||||
store = new ConversationStore(
|
||||
makeApp(vault),
|
||||
(name) => `_fleet/memory/${name}.md`,
|
||||
);
|
||||
});
|
||||
|
||||
it("flat agents park conversations beside the memory dir; folder agents nest in their folder", async () => {
|
||||
await store.createConversation(agent({}), "chat-1", "First");
|
||||
expect(vault.files.has("_fleet/memory/bot-conversations/chat-1.json")).toBe(true);
|
||||
|
||||
const folderAgent = agent({ isFolder: true, filePath: "_fleet/agents/bot/agent.md" });
|
||||
await store.createConversation(folderAgent, "chat-1", "First");
|
||||
expect(vault.files.has("_fleet/agents/bot/conversations/chat-1.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("createConversation is idempotent — an existing file is left alone", async () => {
|
||||
const a = agent({});
|
||||
await store.createConversation(a, "chat-1", "Original");
|
||||
const before = vault.contents.get("_fleet/memory/bot-conversations/chat-1.json");
|
||||
await store.createConversation(a, "chat-1", "Renamed attempt");
|
||||
expect(vault.contents.get("_fleet/memory/bot-conversations/chat-1.json")).toBe(before);
|
||||
});
|
||||
|
||||
it("lists conversations sorted by lastActive desc, skipping unreadable files", async () => {
|
||||
const a = agent({});
|
||||
const dir = "_fleet/memory/bot-conversations";
|
||||
vault.addFile(`${dir}/old.json`, JSON.stringify({ name: "Old", lastActive: "2026-01-01T00:00:00Z", messages: [1] }));
|
||||
vault.addFile(`${dir}/new.json`, JSON.stringify({ name: "New", lastActive: "2026-07-01T00:00:00Z", messages: [] }));
|
||||
vault.addFile(`${dir}/broken.json`, "{not json");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const list = await store.listConversations(a);
|
||||
|
||||
expect(list.map((c) => c.id)).toEqual(["new", "old"]);
|
||||
expect(list[0]?.messageCount).toBe(0);
|
||||
expect(list[1]?.messageCount).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renameConversation rewrites the name field", async () => {
|
||||
const a = agent({});
|
||||
await store.createConversation(a, "chat-1", "Before");
|
||||
await store.renameConversation(a, "chat-1", "After");
|
||||
const state = JSON.parse(vault.contents.get("_fleet/memory/bot-conversations/chat-1.json") ?? "{}") as {
|
||||
name?: string;
|
||||
};
|
||||
expect(state.name).toBe("After");
|
||||
});
|
||||
|
||||
it("deleteConversation trashes the file and the parent folder once empty", async () => {
|
||||
const a = agent({});
|
||||
await store.createConversation(a, "chat-1", "Only");
|
||||
await store.deleteConversation(a, "chat-1");
|
||||
expect(vault.files.has("_fleet/memory/bot-conversations/chat-1.json")).toBe(false);
|
||||
expect(vault.folders.has("_fleet/memory/bot-conversations")).toBe(false);
|
||||
});
|
||||
});
|
||||
146
src/repository/conversationStore.ts
Normal file
146
src/repository/conversationStore.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { TFile, TFolder, normalizePath } from "obsidian";
|
||||
import type { App, Vault } from "obsidian";
|
||||
import { slugify } from "../utils/markdown";
|
||||
import type { AgentConfig, ConversationMeta } from "../types";
|
||||
import { ensureFolder } from "./shared";
|
||||
|
||||
/**
|
||||
* Conversation registry (in-app multi-conversation): per-conversation JSON
|
||||
* state files under the agent's `conversations/` folder. Extracted verbatim
|
||||
* from FleetRepository.
|
||||
*/
|
||||
export class ConversationStore {
|
||||
private readonly vault: Vault;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
/** Facade's legacy flat memory path — flat agents park their
|
||||
* conversations beside it (`<memory dir>/<agent>-conversations`). */
|
||||
private readonly getLegacyMemoryPath: (agentName: string) => string,
|
||||
) {
|
||||
this.vault = app.vault;
|
||||
}
|
||||
|
||||
/** Folder that holds per-conversation JSON files for an agent. Folder
|
||||
* agents nest under their own folder; flat agents share the memory dir. */
|
||||
private getConversationsDir(agent: AgentConfig): string {
|
||||
if (agent.isFolder) {
|
||||
const folderPath = agent.filePath.replace(/\/agent\.md$/, "");
|
||||
return normalizePath(`${folderPath}/conversations`);
|
||||
}
|
||||
const memoryDir = this.getLegacyMemoryPath(agent.name).replace(/\/[^/]+$/, "");
|
||||
return normalizePath(`${memoryDir}/${agent.name}-conversations`);
|
||||
}
|
||||
|
||||
/** Path to a conversation's state file. The id is slugified for safety
|
||||
* on disk; callers should treat the original id as opaque. */
|
||||
private getConversationPath(agent: AgentConfig, conversationId: string): string {
|
||||
const dir = this.getConversationsDir(agent);
|
||||
const safeId = slugify(conversationId) || "conversation";
|
||||
return normalizePath(`${dir}/${safeId}.json`);
|
||||
}
|
||||
|
||||
/** List all conversations for an agent, sorted by lastActive desc.
|
||||
* Scans the conversations/ folder; pre-feature chat.json files (if any)
|
||||
* are intentionally NOT surfaced here — see the "no migration" decision
|
||||
* in the conversation-feature design notes. */
|
||||
async listConversations(agent: AgentConfig): Promise<ConversationMeta[]> {
|
||||
const out: ConversationMeta[] = [];
|
||||
const dir = this.getConversationsDir(agent);
|
||||
const folder = this.vault.getAbstractFileByPath(dir);
|
||||
if (folder instanceof TFolder) {
|
||||
for (const child of folder.children) {
|
||||
if (!(child instanceof TFile) || child.extension !== "json") continue;
|
||||
const id = child.basename;
|
||||
const meta = await this.readConversationMeta(child, id);
|
||||
if (meta) out.push(meta);
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => b.lastActive.localeCompare(a.lastActive));
|
||||
return out;
|
||||
}
|
||||
|
||||
private async readConversationMeta(
|
||||
file: TFile,
|
||||
id: string,
|
||||
): Promise<ConversationMeta | null> {
|
||||
try {
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const state = JSON.parse(content) as {
|
||||
name?: string;
|
||||
lastActive?: string;
|
||||
messages?: unknown[];
|
||||
};
|
||||
return {
|
||||
id,
|
||||
name: state.name?.trim() || "Untitled",
|
||||
lastActive: state.lastActive ?? new Date(file.stat.mtime).toISOString(),
|
||||
messageCount: Array.isArray(state.messages) ? state.messages.length : 0,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`Agent Fleet: skipping unreadable conversation file ${file.path}`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an empty conversation file with a given id + name. Idempotent —
|
||||
* if the file already exists, leaves it alone (callers can just switch
|
||||
* into it). */
|
||||
async createConversation(agent: AgentConfig, id: string, name: string): Promise<void> {
|
||||
const path = this.getConversationPath(agent, id);
|
||||
const existing = this.vault.getAbstractFileByPath(path);
|
||||
if (existing instanceof TFile) return;
|
||||
const dir = path.replace(/\/[^/]+$/, "");
|
||||
await ensureFolder(this.vault, dir);
|
||||
const now = new Date().toISOString();
|
||||
const state = {
|
||||
sessionId: null,
|
||||
messages: [],
|
||||
lastActive: now,
|
||||
createdAt: now,
|
||||
name: name.trim(),
|
||||
};
|
||||
await this.vault.create(path, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
/** Rename a conversation by writing a new `name` field into its state file. */
|
||||
async renameConversation(agent: AgentConfig, conversationId: string, name: string): Promise<void> {
|
||||
const path = this.getConversationPath(agent, conversationId);
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (!(file instanceof TFile)) return;
|
||||
const content = await this.vault.cachedRead(file);
|
||||
let state: Record<string, unknown>;
|
||||
try {
|
||||
state = JSON.parse(content) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
console.warn(`Agent Fleet: cannot rename conversation — invalid JSON in ${path}`, err);
|
||||
return;
|
||||
}
|
||||
state.name = name.trim();
|
||||
await this.vault.modify(file, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
/** Delete a conversation: the JSON file, its threads sidecar, and the
|
||||
* parent `conversations/` folder if it just emptied out. The chat view
|
||||
* auto-creates a fresh conversation if the user deletes their last one,
|
||||
* so this never strands the panel in a no-conversations state. */
|
||||
async deleteConversation(agent: AgentConfig, conversationId: string): Promise<void> {
|
||||
const path = this.getConversationPath(agent, conversationId);
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
await this.app.fileManager.trashFile(file);
|
||||
}
|
||||
|
||||
const threadsDir = path.replace(/\.json$/, ".threads");
|
||||
const threadsFolder = this.vault.getAbstractFileByPath(threadsDir);
|
||||
if (threadsFolder instanceof TFolder) {
|
||||
await this.app.fileManager.trashFile(threadsFolder);
|
||||
}
|
||||
|
||||
const dir = this.getConversationsDir(agent);
|
||||
const folder = this.vault.getAbstractFileByPath(dir);
|
||||
if (folder instanceof TFolder && folder.children.length === 0) {
|
||||
await this.app.fileManager.trashFile(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
716
src/repository/entityMutations.ts
Normal file
716
src/repository/entityMutations.ts
Normal file
|
|
@ -0,0 +1,716 @@
|
|||
import { TFile, TFolder, normalizePath } from "obsidian";
|
||||
import type { App, Vault } from "obsidian";
|
||||
import type { FLEET_SUBFOLDERS } from "../constants";
|
||||
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
|
||||
import { collectMarkdownChildren, createFileIfMissing, ensureFolder, getAvailablePath, trashPath } from "./shared";
|
||||
import { sidecarPermissionsPath } from "./entityParsers";
|
||||
import type { EntityStore } from "./entityStore";
|
||||
import type { TaskConfig } from "../types";
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Update/create option shapes — structurally identical to the inline object
|
||||
// types the facade methods have always taken, so callers are unaffected.
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export interface CreateAgentFolderOptions {
|
||||
name: string;
|
||||
description: string;
|
||||
avatar: string;
|
||||
tags: string[];
|
||||
systemPrompt: string;
|
||||
model: string;
|
||||
adapter: string;
|
||||
cwd: string;
|
||||
timeout: number;
|
||||
permissionMode: string;
|
||||
effort?: string;
|
||||
approvalRequired: string[];
|
||||
memory: boolean;
|
||||
memoryMaxEntries: number;
|
||||
skills: string[];
|
||||
mcpServers?: string[];
|
||||
skillsBody: string;
|
||||
contextBody: string;
|
||||
enabled?: boolean;
|
||||
permissionRules?: { allow: string[]; deny: string[] };
|
||||
autoCompactThreshold?: number;
|
||||
wikiReferences?: string[];
|
||||
}
|
||||
|
||||
export interface CreateSkillFolderOptions {
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
body: string;
|
||||
toolsBody: string;
|
||||
referencesBody: string;
|
||||
examplesBody: string;
|
||||
}
|
||||
|
||||
export interface AgentUpdates {
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
tags?: string[];
|
||||
systemPrompt?: string;
|
||||
model?: string;
|
||||
adapter?: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
permissionMode?: string;
|
||||
effort?: string;
|
||||
permissionRules?: { allow: string[]; deny: string[] };
|
||||
approvalRequired?: string[];
|
||||
memory?: boolean;
|
||||
memoryTokenBudget?: number;
|
||||
reflectionEnabled?: boolean;
|
||||
reflectionSchedule?: string;
|
||||
reflectionProposeSkills?: boolean;
|
||||
skills?: string[];
|
||||
mcpServers?: string[];
|
||||
skillsBody?: string;
|
||||
contextBody?: string;
|
||||
enabled?: boolean;
|
||||
/** 0-100; percent of context window at which auto-compact fires. */
|
||||
autoCompactThreshold?: number;
|
||||
/** Names of Wiki Keeper agents this agent can read from. */
|
||||
wikiReferences?: string[];
|
||||
}
|
||||
|
||||
export interface TaskUpdates {
|
||||
agent?: string;
|
||||
type?: string;
|
||||
schedule?: string;
|
||||
runAt?: string;
|
||||
enabled?: boolean;
|
||||
priority?: string;
|
||||
catch_up?: boolean;
|
||||
effort?: string;
|
||||
model?: string;
|
||||
channel?: string;
|
||||
channelTarget?: string;
|
||||
tags?: string[];
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface SkillUpdates {
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
body?: string;
|
||||
toolsBody?: string;
|
||||
referencesBody?: string;
|
||||
examplesBody?: string;
|
||||
}
|
||||
|
||||
export interface ChannelUpdates {
|
||||
default_agent?: string;
|
||||
allowed_agents?: string[];
|
||||
enabled?: boolean;
|
||||
credential_ref?: string;
|
||||
allowed_users?: string[];
|
||||
per_user_sessions?: boolean;
|
||||
channel_context?: string;
|
||||
tags?: string[];
|
||||
body?: string;
|
||||
type?: string;
|
||||
transport?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HeartbeatUpdates {
|
||||
enabled?: boolean;
|
||||
schedule?: string;
|
||||
notify?: boolean;
|
||||
channel?: string;
|
||||
channelTarget?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity create/update/delete: persists changes to the vault files, then
|
||||
* drives the EntityStore (loadFile / clearStoredFile / validateReferences) so
|
||||
* the in-memory maps and reference issues are fresh without waiting for the
|
||||
* debounced vault-event reload. Extracted verbatim from the FleetRepository
|
||||
* facade; memory paths are injected because deleting an agent also trashes
|
||||
* its MemoryStore-owned files.
|
||||
*/
|
||||
export class EntityMutations {
|
||||
private readonly vault: Vault;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly deps: {
|
||||
store: EntityStore;
|
||||
getSubfolder: (name: (typeof FLEET_SUBFOLDERS)[number]) => string;
|
||||
getMemoryPath: (agentName: string) => string;
|
||||
getMemoryDir: (agentName: string) => string;
|
||||
},
|
||||
) {
|
||||
this.vault = app.vault;
|
||||
}
|
||||
|
||||
private get store(): EntityStore {
|
||||
return this.deps.store;
|
||||
}
|
||||
|
||||
async updateTaskRunMetadata(task: TaskConfig, updates: Partial<Pick<TaskConfig, "lastRun" | "nextRun" | "runCount">>): Promise<void> {
|
||||
const file = this.vault.getAbstractFileByPath(task.filePath);
|
||||
if (!(file instanceof TFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
const nextFrontmatter = {
|
||||
...frontmatter,
|
||||
last_run: updates.lastRun ?? task.lastRun,
|
||||
next_run: updates.nextRun ?? task.nextRun,
|
||||
run_count: updates.runCount ?? task.runCount,
|
||||
};
|
||||
|
||||
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(nextFrontmatter, body));
|
||||
await this.store.loadFile(file);
|
||||
}
|
||||
|
||||
async createAgentTemplate(name: string): Promise<TFile> {
|
||||
const path = await getAvailablePath(this.vault, this.deps.getSubfolder("agents"), slugify(name));
|
||||
const content = `---\nname: ${slugify(name)}\ndescription: \nenabled: true\nskills: []\ntags: []\n---\n\nAgent instructions go here.\n`;
|
||||
const file = await this.vault.create(path, content);
|
||||
// Register the new entity in-memory and revalidate references immediately
|
||||
// (a new agent can resolve a task's missing-agent issue) instead of waiting
|
||||
// for the debounced vault-event reload.
|
||||
await this.store.loadFile(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
async createAgentFolder(opts: CreateAgentFolderOptions): Promise<string> {
|
||||
const slug = slugify(opts.name);
|
||||
const folderPath = normalizePath(`${this.deps.getSubfolder("agents")}/${slug}`);
|
||||
await ensureFolder(this.vault, folderPath);
|
||||
|
||||
// agent.md
|
||||
const agentFm: Record<string, unknown> = {
|
||||
name: opts.name,
|
||||
description: opts.description || undefined,
|
||||
avatar: opts.avatar || undefined,
|
||||
enabled: opts.enabled ?? true,
|
||||
tags: opts.tags,
|
||||
skills: opts.skills,
|
||||
mcp_servers: opts.mcpServers?.length ? opts.mcpServers : undefined,
|
||||
};
|
||||
if (opts.model && opts.model !== "default") {
|
||||
agentFm.model = opts.model;
|
||||
}
|
||||
const agentPath = normalizePath(`${folderPath}/agent.md`);
|
||||
await this.vault.create(agentPath, stringifyMarkdownWithFrontmatter(agentFm, opts.systemPrompt || ""));
|
||||
|
||||
// config.md
|
||||
const configFm: Record<string, unknown> = {
|
||||
model: opts.model || "default",
|
||||
adapter: opts.adapter || "claude-code",
|
||||
timeout: opts.timeout,
|
||||
max_retries: 1,
|
||||
cwd: opts.cwd || "",
|
||||
permission_mode: opts.permissionMode || "bypassPermissions",
|
||||
effort: opts.effort || undefined,
|
||||
approval_required: opts.approvalRequired,
|
||||
memory: opts.memory,
|
||||
memory_max_entries: opts.memoryMaxEntries,
|
||||
};
|
||||
if (typeof opts.autoCompactThreshold === "number") {
|
||||
configFm.auto_compact_threshold = opts.autoCompactThreshold;
|
||||
}
|
||||
if (opts.wikiReferences && opts.wikiReferences.length > 0) {
|
||||
configFm.wiki_references = opts.wikiReferences.map((agent) => ({ agent }));
|
||||
}
|
||||
const configPath = normalizePath(`${folderPath}/config.md`);
|
||||
await this.vault.create(configPath, stringifyMarkdownWithFrontmatter(configFm, ""));
|
||||
|
||||
// SKILLS.md
|
||||
const skillsPath = normalizePath(`${folderPath}/SKILLS.md`);
|
||||
await this.vault.create(skillsPath, stringifyMarkdownWithFrontmatter({}, opts.skillsBody || ""));
|
||||
|
||||
// CONTEXT.md
|
||||
const contextPath = normalizePath(`${folderPath}/CONTEXT.md`);
|
||||
await this.vault.create(contextPath, stringifyMarkdownWithFrontmatter({}, opts.contextBody || ""));
|
||||
|
||||
// permissions.json (only if rules are non-empty)
|
||||
const rules = opts.permissionRules;
|
||||
if (rules && (rules.allow.length > 0 || rules.deny.length > 0)) {
|
||||
const permPath = normalizePath(`${folderPath}/permissions.json`);
|
||||
await this.vault.create(permPath, JSON.stringify(rules, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// Register the new folder agent in-memory + revalidate references now.
|
||||
await this.store.loadFile(agentPath);
|
||||
return agentPath;
|
||||
}
|
||||
|
||||
async createSkillTemplate(name: string): Promise<TFile> {
|
||||
const path = await getAvailablePath(this.vault, this.deps.getSubfolder("skills"), slugify(name));
|
||||
const content = `---\nname: ${slugify(name)}\ndescription: \ntags: []\n---\n\nSkill instructions go here.\n`;
|
||||
const file = await this.vault.create(path, content);
|
||||
// Register in-memory + revalidate (a new skill can resolve an agent's
|
||||
// missing-skill issue) without waiting for the debounced vault reload.
|
||||
await this.store.loadFile(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
async createSkillFolder(opts: CreateSkillFolderOptions): Promise<void> {
|
||||
const folderPath = normalizePath(`${this.deps.getSubfolder("skills")}/${slugify(opts.name)}`);
|
||||
await ensureFolder(this.vault, folderPath);
|
||||
|
||||
const skillFm = {
|
||||
name: opts.name,
|
||||
description: opts.description || undefined,
|
||||
tags: opts.tags.length > 0 ? opts.tags : undefined,
|
||||
};
|
||||
const skillPath = normalizePath(`${folderPath}/skill.md`);
|
||||
await createFileIfMissing(this.vault, skillPath, stringifyMarkdownWithFrontmatter(skillFm, opts.body || "Skill instructions go here."));
|
||||
|
||||
if (opts.toolsBody) {
|
||||
const toolsPath = normalizePath(`${folderPath}/tools.md`);
|
||||
await createFileIfMissing(this.vault, toolsPath, `# Tools\n\n${opts.toolsBody}`);
|
||||
}
|
||||
|
||||
if (opts.referencesBody) {
|
||||
const refsPath = normalizePath(`${folderPath}/references.md`);
|
||||
await createFileIfMissing(this.vault, refsPath, `# References\n\n${opts.referencesBody}`);
|
||||
}
|
||||
|
||||
if (opts.examplesBody) {
|
||||
const examplesPath = normalizePath(`${folderPath}/examples.md`);
|
||||
await createFileIfMissing(this.vault, examplesPath, `# Examples\n\n${opts.examplesBody}`);
|
||||
}
|
||||
|
||||
// Register the new folder skill in-memory + revalidate references now.
|
||||
await this.store.loadFile(skillPath);
|
||||
}
|
||||
|
||||
async updateAgent(agentName: string, updates: AgentUpdates): Promise<void> {
|
||||
const agent = this.store.getAgentByName(agentName);
|
||||
if (!agent) return;
|
||||
|
||||
if (agent.isFolder) {
|
||||
const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, ""));
|
||||
|
||||
// Update agent.md
|
||||
const agentFile = this.vault.getAbstractFileByPath(agent.filePath);
|
||||
if (agentFile instanceof TFile) {
|
||||
const content = await this.vault.cachedRead(agentFile);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
if (updates.description !== undefined) frontmatter.description = updates.description || undefined;
|
||||
if (updates.avatar !== undefined) frontmatter.avatar = updates.avatar || undefined;
|
||||
if (updates.tags !== undefined) frontmatter.tags = updates.tags;
|
||||
if (updates.skills !== undefined) frontmatter.skills = updates.skills;
|
||||
if (updates.mcpServers !== undefined) frontmatter.mcp_servers = updates.mcpServers.length > 0 ? updates.mcpServers : undefined;
|
||||
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
|
||||
if (updates.model !== undefined && updates.model !== "default") frontmatter.model = updates.model;
|
||||
const newBody = updates.systemPrompt !== undefined ? updates.systemPrompt : body;
|
||||
await this.vault.modify(agentFile, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
|
||||
}
|
||||
|
||||
// Update config.md
|
||||
const configPath = normalizePath(`${folderPath}/config.md`);
|
||||
const configFile = this.vault.getAbstractFileByPath(configPath);
|
||||
if (configFile instanceof TFile) {
|
||||
const content = await this.vault.cachedRead(configFile);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
if (updates.model !== undefined) frontmatter.model = updates.model;
|
||||
if (updates.adapter !== undefined) frontmatter.adapter = updates.adapter;
|
||||
if (updates.timeout !== undefined) frontmatter.timeout = updates.timeout;
|
||||
if (updates.cwd !== undefined) frontmatter.cwd = updates.cwd;
|
||||
if (updates.permissionMode !== undefined) frontmatter.permission_mode = updates.permissionMode;
|
||||
if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined;
|
||||
if (updates.approvalRequired !== undefined) frontmatter.approval_required = updates.approvalRequired;
|
||||
if (updates.memory !== undefined) frontmatter.memory = updates.memory;
|
||||
if (updates.memoryTokenBudget !== undefined) frontmatter.memory_token_budget = updates.memoryTokenBudget;
|
||||
if (updates.reflectionEnabled !== undefined) frontmatter.reflection_enabled = updates.reflectionEnabled;
|
||||
if (updates.reflectionSchedule !== undefined) frontmatter.reflection_schedule = updates.reflectionSchedule;
|
||||
if (updates.reflectionProposeSkills !== undefined) frontmatter.reflection_propose_skills = updates.reflectionProposeSkills;
|
||||
if (updates.autoCompactThreshold !== undefined) {
|
||||
frontmatter.auto_compact_threshold = updates.autoCompactThreshold;
|
||||
}
|
||||
if (updates.wikiReferences !== undefined) {
|
||||
frontmatter.wiki_references = updates.wikiReferences.length > 0
|
||||
? updates.wikiReferences.map((agent) => ({ agent }))
|
||||
: undefined;
|
||||
}
|
||||
// Strip dead legacy permission fields whenever we touch config.md —
|
||||
// permissions.json is now the canonical surface for allow/deny.
|
||||
delete frontmatter.allowed_tools;
|
||||
delete frontmatter.blocked_tools;
|
||||
await this.vault.modify(configFile, stringifyMarkdownWithFrontmatter(frontmatter, body));
|
||||
}
|
||||
|
||||
// Update SKILLS.md
|
||||
if (updates.skillsBody !== undefined) {
|
||||
const skillsPath = normalizePath(`${folderPath}/SKILLS.md`);
|
||||
const skillsFile = this.vault.getAbstractFileByPath(skillsPath);
|
||||
if (skillsFile instanceof TFile) {
|
||||
await this.vault.modify(skillsFile, stringifyMarkdownWithFrontmatter({}, updates.skillsBody));
|
||||
} else {
|
||||
await this.vault.create(skillsPath, stringifyMarkdownWithFrontmatter({}, updates.skillsBody));
|
||||
}
|
||||
}
|
||||
|
||||
// Update CONTEXT.md
|
||||
if (updates.contextBody !== undefined) {
|
||||
const contextPath = normalizePath(`${folderPath}/CONTEXT.md`);
|
||||
const contextFile = this.vault.getAbstractFileByPath(contextPath);
|
||||
if (contextFile instanceof TFile) {
|
||||
await this.vault.modify(contextFile, stringifyMarkdownWithFrontmatter({}, updates.contextBody));
|
||||
} else {
|
||||
await this.vault.create(contextPath, stringifyMarkdownWithFrontmatter({}, updates.contextBody));
|
||||
}
|
||||
}
|
||||
|
||||
// Update permissions.json
|
||||
if (updates.permissionRules !== undefined) {
|
||||
const permPath = normalizePath(`${folderPath}/permissions.json`);
|
||||
const permFile = this.vault.getAbstractFileByPath(permPath);
|
||||
const rules = updates.permissionRules;
|
||||
if (rules.allow.length > 0 || rules.deny.length > 0) {
|
||||
const content = JSON.stringify(rules, null, 2) + "\n";
|
||||
if (permFile instanceof TFile) {
|
||||
await this.vault.modify(permFile, content);
|
||||
} else {
|
||||
await this.vault.create(permPath, content);
|
||||
}
|
||||
} else if (permFile instanceof TFile) {
|
||||
// Remove permissions.json if both lists are empty
|
||||
await this.app.fileManager.trashFile(permFile);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy single-file agent
|
||||
const file = this.vault.getAbstractFileByPath(agent.filePath);
|
||||
if (!(file instanceof TFile)) return;
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
if (updates.description !== undefined) frontmatter.description = updates.description || undefined;
|
||||
if (updates.avatar !== undefined) frontmatter.avatar = updates.avatar || undefined;
|
||||
if (updates.tags !== undefined) frontmatter.tags = updates.tags;
|
||||
if (updates.skills !== undefined) frontmatter.skills = updates.skills;
|
||||
if (updates.mcpServers !== undefined) frontmatter.mcp_servers = updates.mcpServers.length > 0 ? updates.mcpServers : undefined;
|
||||
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
|
||||
if (updates.model !== undefined) frontmatter.model = updates.model;
|
||||
if (updates.adapter !== undefined) frontmatter.adapter = updates.adapter;
|
||||
if (updates.timeout !== undefined) frontmatter.timeout = updates.timeout;
|
||||
if (updates.cwd !== undefined) frontmatter.cwd = updates.cwd;
|
||||
if (updates.permissionMode !== undefined) frontmatter.permission_mode = updates.permissionMode;
|
||||
if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined;
|
||||
if (updates.approvalRequired !== undefined) frontmatter.approval_required = updates.approvalRequired;
|
||||
if (updates.memory !== undefined) frontmatter.memory = updates.memory;
|
||||
if (updates.autoCompactThreshold !== undefined) {
|
||||
frontmatter.auto_compact_threshold = updates.autoCompactThreshold;
|
||||
}
|
||||
if (updates.wikiReferences !== undefined) {
|
||||
frontmatter.wiki_references = updates.wikiReferences.length > 0
|
||||
? updates.wikiReferences.map((agent) => ({ agent }))
|
||||
: undefined;
|
||||
}
|
||||
// Strip dead legacy permission fields whenever we touch the file —
|
||||
// permissions.json sidecar is now the canonical surface.
|
||||
delete frontmatter.allowed_tools;
|
||||
delete frontmatter.blocked_tools;
|
||||
const newBody = updates.systemPrompt !== undefined ? updates.systemPrompt : body;
|
||||
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
|
||||
|
||||
// Sidecar permissions.json for flat agents — parity with folder agents.
|
||||
if (updates.permissionRules !== undefined) {
|
||||
const sidecarPath = sidecarPermissionsPath(agent.filePath);
|
||||
const sidecarFile = this.vault.getAbstractFileByPath(sidecarPath);
|
||||
const rules = updates.permissionRules;
|
||||
if (rules.allow.length > 0 || rules.deny.length > 0) {
|
||||
const content = JSON.stringify(rules, null, 2) + "\n";
|
||||
if (sidecarFile instanceof TFile) {
|
||||
await this.vault.modify(sidecarFile, content);
|
||||
} else {
|
||||
await this.vault.create(sidecarPath, content);
|
||||
}
|
||||
} else if (sidecarFile instanceof TFile) {
|
||||
await this.app.fileManager.trashFile(sidecarFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload the edited entity into the in-memory maps and revalidate — the
|
||||
// skills list may now reference a missing skill (or resolve one).
|
||||
await this.store.loadFile(agent.filePath);
|
||||
}
|
||||
|
||||
async updateTask(taskId: string, updates: TaskUpdates): Promise<void> {
|
||||
const task = this.store.getTaskById(taskId);
|
||||
if (!task) return;
|
||||
|
||||
const file = this.vault.getAbstractFileByPath(task.filePath);
|
||||
if (!(file instanceof TFile)) return;
|
||||
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
if (updates.agent !== undefined) frontmatter.agent = updates.agent;
|
||||
if (updates.type !== undefined) frontmatter.type = updates.type;
|
||||
if (updates.schedule !== undefined) frontmatter.schedule = updates.schedule || undefined;
|
||||
if (updates.runAt !== undefined) frontmatter.run_at = updates.runAt || undefined;
|
||||
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
|
||||
if (updates.priority !== undefined) frontmatter.priority = updates.priority;
|
||||
if (updates.catch_up !== undefined) frontmatter.catch_up = updates.catch_up;
|
||||
if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined;
|
||||
if (updates.model !== undefined) frontmatter.model = updates.model || undefined;
|
||||
if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined;
|
||||
if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined;
|
||||
if (updates.tags !== undefined) frontmatter.tags = updates.tags;
|
||||
const newBody = updates.body !== undefined ? updates.body : body;
|
||||
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
|
||||
|
||||
// Reload + revalidate — the task may have been retargeted to a different
|
||||
// (possibly missing) agent.
|
||||
await this.store.loadFile(file);
|
||||
}
|
||||
|
||||
async updateSkill(skillName: string, updates: SkillUpdates): Promise<void> {
|
||||
const skill = this.store.getSkillByName(skillName);
|
||||
if (!skill) return;
|
||||
|
||||
if (skill.isFolder) {
|
||||
const folderPath = normalizePath(skill.filePath.replace(/\/skill\.md$/, ""));
|
||||
|
||||
// Update skill.md
|
||||
const skillFile = this.vault.getAbstractFileByPath(skill.filePath);
|
||||
if (skillFile instanceof TFile) {
|
||||
const content = await this.vault.cachedRead(skillFile);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
if (updates.description !== undefined) frontmatter.description = updates.description || undefined;
|
||||
if (updates.tags !== undefined) frontmatter.tags = updates.tags.length > 0 ? updates.tags : undefined;
|
||||
const newBody = updates.body !== undefined ? updates.body : body;
|
||||
await this.vault.modify(skillFile, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
|
||||
}
|
||||
|
||||
// Update tools.md
|
||||
if (updates.toolsBody !== undefined) {
|
||||
const toolsPath = normalizePath(`${folderPath}/tools.md`);
|
||||
const toolsFile = this.vault.getAbstractFileByPath(toolsPath);
|
||||
if (updates.toolsBody) {
|
||||
if (toolsFile instanceof TFile) {
|
||||
await this.vault.modify(toolsFile, `# Tools\n\n${updates.toolsBody}`);
|
||||
} else {
|
||||
await this.vault.create(toolsPath, `# Tools\n\n${updates.toolsBody}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update references.md
|
||||
if (updates.referencesBody !== undefined) {
|
||||
const refsPath = normalizePath(`${folderPath}/references.md`);
|
||||
const refsFile = this.vault.getAbstractFileByPath(refsPath);
|
||||
if (updates.referencesBody) {
|
||||
if (refsFile instanceof TFile) {
|
||||
await this.vault.modify(refsFile, `# References\n\n${updates.referencesBody}`);
|
||||
} else {
|
||||
await this.vault.create(refsPath, `# References\n\n${updates.referencesBody}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update examples.md
|
||||
if (updates.examplesBody !== undefined) {
|
||||
const examplesPath = normalizePath(`${folderPath}/examples.md`);
|
||||
const examplesFile = this.vault.getAbstractFileByPath(examplesPath);
|
||||
if (updates.examplesBody) {
|
||||
if (examplesFile instanceof TFile) {
|
||||
await this.vault.modify(examplesFile, `# Examples\n\n${updates.examplesBody}`);
|
||||
} else {
|
||||
await this.vault.create(examplesPath, `# Examples\n\n${updates.examplesBody}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy single-file skill
|
||||
const file = this.vault.getAbstractFileByPath(skill.filePath);
|
||||
if (!(file instanceof TFile)) return;
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
if (updates.description !== undefined) frontmatter.description = updates.description || undefined;
|
||||
if (updates.tags !== undefined) frontmatter.tags = updates.tags.length > 0 ? updates.tags : undefined;
|
||||
const newBody = updates.body !== undefined ? updates.body : body;
|
||||
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
|
||||
}
|
||||
|
||||
// Reload + revalidate (skill names can't change here, but keep the
|
||||
// in-memory entity fresh and the validation pass current).
|
||||
await this.store.loadFile(skill.filePath);
|
||||
}
|
||||
|
||||
async deleteSkill(skillName: string): Promise<void> {
|
||||
const skill = this.store.getSkillByName(skillName);
|
||||
if (!skill) return;
|
||||
|
||||
if (skill.isFolder) {
|
||||
const folderPath = normalizePath(skill.filePath.replace(/\/skill\.md$/, ""));
|
||||
const folder = this.vault.getAbstractFileByPath(folderPath);
|
||||
if (folder instanceof TFolder) {
|
||||
await this.app.fileManager.trashFile(folder);
|
||||
}
|
||||
this.store.clearIssuesUnder(folderPath);
|
||||
} else {
|
||||
await trashPath(this.app, skill.filePath);
|
||||
}
|
||||
|
||||
// Drop the entity from the in-memory maps and revalidate immediately so
|
||||
// agents referencing the deleted skill are flagged without a full reload.
|
||||
this.store.clearStoredFile(skill.filePath);
|
||||
this.store.validateReferences();
|
||||
}
|
||||
|
||||
async deleteTask(taskId: string): Promise<void> {
|
||||
const task = this.store.getTaskById(taskId);
|
||||
if (!task) return;
|
||||
await trashPath(this.app, task.filePath);
|
||||
this.store.clearStoredFile(task.filePath);
|
||||
this.store.validateReferences();
|
||||
}
|
||||
|
||||
async updateChannel(channelName: string, updates: ChannelUpdates): Promise<void> {
|
||||
const channel = this.store.getChannelByName(channelName);
|
||||
if (!channel) return;
|
||||
|
||||
const file = this.vault.getAbstractFileByPath(channel.filePath);
|
||||
if (!(file instanceof TFile)) return;
|
||||
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
if (updates.default_agent !== undefined) {
|
||||
frontmatter.default_agent = updates.default_agent;
|
||||
// Remove legacy `agent` field to avoid confusion
|
||||
delete frontmatter.agent;
|
||||
}
|
||||
if (updates.allowed_agents !== undefined) frontmatter.allowed_agents = updates.allowed_agents;
|
||||
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
|
||||
if (updates.credential_ref !== undefined) frontmatter.credential_ref = updates.credential_ref;
|
||||
if (updates.allowed_users !== undefined) frontmatter.allowed_users = updates.allowed_users;
|
||||
if (updates.per_user_sessions !== undefined) frontmatter.per_user_sessions = updates.per_user_sessions;
|
||||
if (updates.channel_context !== undefined) frontmatter.channel_context = updates.channel_context || undefined;
|
||||
if (updates.tags !== undefined) frontmatter.tags = updates.tags;
|
||||
if (updates.type !== undefined) frontmatter.type = updates.type;
|
||||
if (updates.transport !== undefined) frontmatter.transport = updates.transport;
|
||||
const newBody = updates.body !== undefined ? updates.body : body;
|
||||
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
|
||||
|
||||
// Reload + revalidate — default_agent / credential_ref may now dangle.
|
||||
await this.store.loadFile(file);
|
||||
}
|
||||
|
||||
async deleteChannel(channelName: string): Promise<void> {
|
||||
const channel = this.store.getChannelByName(channelName);
|
||||
if (!channel) return;
|
||||
await trashPath(this.app, channel.filePath);
|
||||
this.store.clearStoredFile(channel.filePath);
|
||||
this.store.validateReferences();
|
||||
// Also trash the channel's sessions folder if it exists
|
||||
const sessionsFolder = normalizePath(
|
||||
`${this.deps.getSubfolder("channels")}/${slugify(channelName)}/sessions`,
|
||||
);
|
||||
const folder = this.vault.getAbstractFileByPath(sessionsFolder);
|
||||
if (folder instanceof TFolder) {
|
||||
await this.app.fileManager.trashFile(folder);
|
||||
}
|
||||
}
|
||||
|
||||
async updateHeartbeat(agentName: string, updates: HeartbeatUpdates): Promise<void> {
|
||||
const agent = this.store.getAgentByName(agentName);
|
||||
if (!agent || !agent.isFolder) return;
|
||||
|
||||
const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, ""));
|
||||
const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`);
|
||||
const file = this.vault.getAbstractFileByPath(heartbeatPath);
|
||||
|
||||
if (file instanceof TFile) {
|
||||
// Update existing file
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
|
||||
if (updates.schedule !== undefined) frontmatter.schedule = updates.schedule || undefined;
|
||||
if (updates.notify !== undefined) frontmatter.notify = updates.notify;
|
||||
if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined;
|
||||
if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined;
|
||||
const newBody = updates.body !== undefined ? updates.body : body;
|
||||
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
|
||||
} else {
|
||||
// Create new HEARTBEAT.md
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
enabled: updates.enabled ?? false,
|
||||
};
|
||||
if (updates.schedule) frontmatter.schedule = updates.schedule;
|
||||
if (updates.notify !== undefined) frontmatter.notify = updates.notify;
|
||||
if (updates.channel) frontmatter.channel = updates.channel;
|
||||
if (updates.channelTarget) frontmatter.channel_target = updates.channelTarget;
|
||||
const body = updates.body ?? "";
|
||||
await this.vault.create(
|
||||
heartbeatPath,
|
||||
stringifyMarkdownWithFrontmatter(frontmatter, body),
|
||||
);
|
||||
}
|
||||
|
||||
// Reload the owning folder agent so its heartbeat fields are fresh
|
||||
// in-memory (reference-neutral, but keeps the snapshot consistent).
|
||||
await this.store.loadFile(heartbeatPath);
|
||||
}
|
||||
|
||||
async deleteAgent(agentName: string, deleteTasks: boolean): Promise<{ trashedFiles: string[] }> {
|
||||
const trashedFiles: string[] = [];
|
||||
const agent = this.store.getAgentByName(agentName);
|
||||
if (!agent) return { trashedFiles };
|
||||
|
||||
if (agent.isFolder) {
|
||||
// Trash the entire agent folder
|
||||
const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, ""));
|
||||
const folder = this.vault.getAbstractFileByPath(folderPath);
|
||||
if (folder instanceof TFolder) {
|
||||
const files: TFile[] = [];
|
||||
collectMarkdownChildren(folder, files);
|
||||
// Trash all files inside the folder first, then the folder
|
||||
for (const f of files) {
|
||||
trashedFiles.push(f.path);
|
||||
}
|
||||
await this.app.fileManager.trashFile(folder);
|
||||
}
|
||||
// Issues keyed on member files (permissions.json, …) go with the folder.
|
||||
this.store.clearIssuesUnder(folderPath);
|
||||
} else {
|
||||
// Trash the single agent definition file
|
||||
await trashPath(this.app, agent.filePath);
|
||||
trashedFiles.push(agent.filePath);
|
||||
// Drop any issue keyed on the flat agent's permissions sidecar.
|
||||
this.store.clearIssuesFor(sidecarPermissionsPath(agent.filePath));
|
||||
}
|
||||
this.store.clearStoredFile(agent.filePath);
|
||||
|
||||
// Trash the agent's memory: legacy flat file + v2 memory folder.
|
||||
const memoryPath = this.deps.getMemoryPath(agentName);
|
||||
if (this.vault.getAbstractFileByPath(memoryPath)) {
|
||||
await trashPath(this.app, memoryPath);
|
||||
trashedFiles.push(memoryPath);
|
||||
}
|
||||
const memoryDir = this.deps.getMemoryDir(agentName);
|
||||
const memoryFolder = this.vault.getAbstractFileByPath(memoryDir);
|
||||
if (memoryFolder instanceof TFolder) {
|
||||
await this.app.fileManager.trashFile(memoryFolder);
|
||||
trashedFiles.push(memoryDir);
|
||||
}
|
||||
|
||||
// Optionally trash associated tasks
|
||||
if (deleteTasks) {
|
||||
const tasks = this.store.getTasksForAgent(agentName);
|
||||
for (const task of tasks) {
|
||||
await trashPath(this.app, task.filePath);
|
||||
trashedFiles.push(task.filePath);
|
||||
this.store.clearStoredFile(task.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Revalidate once after all map mutations: tasks/channels referencing the
|
||||
// deleted agent get flagged immediately, without a full reload.
|
||||
this.store.validateReferences();
|
||||
|
||||
return { trashedFiles };
|
||||
}
|
||||
}
|
||||
556
src/repository/entityParsers.ts
Normal file
556
src/repository/entityParsers.ts
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
import { TFile, normalizePath } from "obsidian";
|
||||
import type { Vault } from "obsidian";
|
||||
import {
|
||||
DEFAULT_MEMORY_TOKEN_BUDGET,
|
||||
DEFAULT_REFLECTION_SCHEDULE,
|
||||
DEFAULT_RECURRENCE_THRESHOLD,
|
||||
} from "../constants";
|
||||
import { parseMarkdownWithFrontmatter } from "../utils/markdown";
|
||||
import { asBoolean, asNumber, asString, asStringArray, isRecord } from "./shared";
|
||||
import type {
|
||||
AgentConfig,
|
||||
ChannelConfig,
|
||||
FleetSettings,
|
||||
ReflectionConfig,
|
||||
SkillConfig,
|
||||
TaskConfig,
|
||||
} from "../types";
|
||||
|
||||
export type ParsedEntity = AgentConfig | SkillConfig | TaskConfig;
|
||||
|
||||
/**
|
||||
* Everything the entity parsers need from their host. Extracted verbatim from
|
||||
* the FleetRepository facade — the parsers used to be private methods closing
|
||||
* over `this`; the context object is the same surface, made explicit:
|
||||
*
|
||||
* - `reportIssue` lands parse failures in the load-time validation-issue map
|
||||
* (NOT the reference-issue map — see EntityStore for the two-tier split).
|
||||
* - `clearIssue` drops a stale load-time issue keyed on a member file (e.g.
|
||||
* permissions.json) before that file is re-evaluated on a folder reload.
|
||||
* - The `warned*` sets are owned by the store so one-time console warnings
|
||||
* stay one-time across reloads.
|
||||
*/
|
||||
export interface ParserContext {
|
||||
vault: Vault;
|
||||
settings: FleetSettings;
|
||||
reportIssue(path: string, message: string): void;
|
||||
clearIssue(path: string): void;
|
||||
warnedLegacyPerms: Set<string>;
|
||||
warnedFolderAgentModelConflict: Set<string>;
|
||||
}
|
||||
|
||||
let warnedLegacyBudget = false;
|
||||
|
||||
/**
|
||||
* Resolve an agent's working-memory token budget. Prefers the new
|
||||
* `memory_token_budget`; if absent but the deprecated `memory_max_entries` is
|
||||
* present, fall back to the default budget and emit a one-time console notice
|
||||
* (design §13.4 — the old entry-count knob no longer maps cleanly to a budget).
|
||||
*/
|
||||
function resolveMemoryTokenBudget(
|
||||
fm: Record<string, unknown>,
|
||||
fallback: Record<string, unknown> = {},
|
||||
): number {
|
||||
const explicit = fm.memory_token_budget ?? fallback.memory_token_budget;
|
||||
if (explicit !== undefined) return asNumber(explicit, DEFAULT_MEMORY_TOKEN_BUDGET);
|
||||
const legacy = fm.memory_max_entries ?? fallback.memory_max_entries;
|
||||
if (legacy !== undefined && !warnedLegacyBudget) {
|
||||
warnedLegacyBudget = true;
|
||||
console.info(
|
||||
"Agent Fleet: `memory_max_entries` is deprecated and no longer enforced; " +
|
||||
"memory is now bounded by `memory_token_budget` (default " +
|
||||
`${DEFAULT_MEMORY_TOKEN_BUDGET}). Set that field to tune memory size.`,
|
||||
);
|
||||
}
|
||||
return DEFAULT_MEMORY_TOKEN_BUDGET;
|
||||
}
|
||||
|
||||
/** Build a {@link ReflectionConfig} from frontmatter, with an optional fallback
|
||||
* frontmatter (folder agents split fields across config.md and agent.md). */
|
||||
function parseReflectionConfig(
|
||||
fm: Record<string, unknown>,
|
||||
fallback: Record<string, unknown> = {},
|
||||
): ReflectionConfig {
|
||||
const pick = (key: string): unknown => fm[key] ?? fallback[key];
|
||||
return {
|
||||
enabled: asBoolean(pick("reflection_enabled"), false),
|
||||
schedule: asString(pick("reflection_schedule")) ?? DEFAULT_REFLECTION_SCHEDULE,
|
||||
recurrenceThreshold: asNumber(pick("reflection_recurrence_threshold"), DEFAULT_RECURRENCE_THRESHOLD),
|
||||
proposeSkills: asBoolean(pick("reflection_propose_skills"), false),
|
||||
model: asString(pick("reflection_model")),
|
||||
};
|
||||
}
|
||||
|
||||
/** For a flat agent at `<dir>/<name>.md`, the permissions sidecar lives at
|
||||
* `<dir>/<name>.permissions.json`. Folder agents use `<folder>/permissions.json`
|
||||
* (handled in loadFolderAgent). */
|
||||
export function sidecarPermissionsPath(agentMdPath: string): string {
|
||||
return normalizePath(agentMdPath.replace(/\.md$/, ".permissions.json"));
|
||||
}
|
||||
|
||||
/** Coerce a frontmatter `env:` block into a string→string map (numbers and
|
||||
* booleans stringified, other value shapes dropped). */
|
||||
function parseEnvMap(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value)) return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (typeof v === "string") {
|
||||
result[k] = v;
|
||||
} else if (typeof v === "number" || typeof v === "boolean") {
|
||||
result[k] = String(v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Parse a `wiki_references:` frontmatter list into an array of references.
|
||||
* Accepts either `[{ agent: "name" }, ...]` or shorthand `["name", ...]`.
|
||||
* Returns undefined when the field is absent. */
|
||||
function parseWikiReferences(raw: unknown): AgentConfig["wikiReferences"] {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const refs: Array<{ agent: string }> = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item === "string" && item.trim()) {
|
||||
refs.push({ agent: item.trim() });
|
||||
} else if (item && typeof item === "object") {
|
||||
const agent = (item as Record<string, unknown>).agent;
|
||||
if (typeof agent === "string" && agent.trim()) {
|
||||
refs.push({ agent: agent.trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs.length > 0 ? refs : undefined;
|
||||
}
|
||||
|
||||
/** Parse a `wiki_keeper:` frontmatter block into a WikiKeeperConfig.
|
||||
* Returns undefined when no block is present — agents without this
|
||||
* are regular agents, unaffected. */
|
||||
function parseWikiKeeperConfig(raw: unknown): AgentConfig["wikiKeeper"] {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const r = raw as Record<string, unknown>;
|
||||
return {
|
||||
scopeRoot: asString(r.scope_root) ?? "",
|
||||
inboxPath: asString(r.inbox_path) ?? "_sources/inbox",
|
||||
archivePath: asString(r.archive_path) ?? "_sources/archive",
|
||||
failedPath: asString(r.failed_path) ?? "_sources/failed",
|
||||
topicsRoot: asString(r.topics_root) ?? "_topics",
|
||||
indexPath: asString(r.index_path) ?? "index.md",
|
||||
logPath: asString(r.log_path) ?? "log.md",
|
||||
watchedFolders: asStringArray(r.watched_folders),
|
||||
excludePatterns: asStringArray(r.exclude_patterns),
|
||||
watchedSince: asString(r.watched_since) ?? "",
|
||||
// Default flipped to TRUE in v1.3 — Karpathy compounding requires
|
||||
// substantive answers to file themselves back into the wiki.
|
||||
fileSubstantiveAnswers: asBoolean(r.file_substantive_answers, true),
|
||||
obsidianUrlScheme: asBoolean(r.obsidian_url_scheme, true),
|
||||
maxTokensPerIngest: asNumber(r.max_tokens_per_ingest, 60000),
|
||||
maxTokensPerRefresh: asNumber(r.max_tokens_per_refresh, 30000),
|
||||
indexSplitThreshold: asNumber(r.index_split_threshold, 100),
|
||||
dedupSimilarityThreshold: asNumber(r.dedup_similarity_threshold, 0.82),
|
||||
summaryStaleDays: asNumber(r.summary_stale_days, 30),
|
||||
stateFile: asString(r.state_file) ?? ".wiki-keeper-state.json",
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Flat entity parsers (single .md files)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export function parseAgent(ctx: ParserContext, path: string, content: string): AgentConfig | null {
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
if (!isRecord(frontmatter)) {
|
||||
ctx.reportIssue(path, "Invalid frontmatter.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = asString(frontmatter.name);
|
||||
const model = asString(frontmatter.model) ?? ctx.settings.defaultModel;
|
||||
if (!name || !model) {
|
||||
ctx.reportIssue(path, "Agent requires string field `name` and a valid model or default model setting.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Legacy migration for flat agents: pull rules from frontmatter
|
||||
// allowed_tools/blocked_tools when present. The sidecar
|
||||
// <name>.permissions.json (read in loadFile) will override these if it
|
||||
// exists and parses cleanly. Saving the agent rewrites rules to the
|
||||
// sidecar and stops emitting the legacy frontmatter fields.
|
||||
const legacyAllow = asStringArray(frontmatter.allowed_tools);
|
||||
const legacyDeny = asStringArray(frontmatter.blocked_tools);
|
||||
if ((legacyAllow.length > 0 || legacyDeny.length > 0) && !ctx.warnedLegacyPerms.has(name)) {
|
||||
ctx.warnedLegacyPerms.add(name);
|
||||
console.warn(
|
||||
`Agent Fleet: "${name}" still uses legacy allowed_tools/blocked_tools in its frontmatter. ` +
|
||||
`Permission rules now live in <name>.permissions.json beside the .md. Open Edit and Save to migrate.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: path,
|
||||
name,
|
||||
description: asString(frontmatter.description),
|
||||
model,
|
||||
adapter: asString(frontmatter.adapter) ?? "claude-code",
|
||||
permissionMode: asString(frontmatter.permission_mode) ?? "bypassPermissions",
|
||||
effort: asString(frontmatter.effort),
|
||||
maxRetries: asNumber(frontmatter.max_retries, 1),
|
||||
skills: asStringArray(frontmatter.skills),
|
||||
mcpServers: asStringArray(frontmatter.mcp_servers),
|
||||
cwd: asString(frontmatter.cwd),
|
||||
enabled: asBoolean(frontmatter.enabled, true),
|
||||
timeout: asNumber(frontmatter.timeout, 300),
|
||||
approvalRequired: asStringArray(frontmatter.approval_required),
|
||||
memory: asBoolean(frontmatter.memory, false),
|
||||
memoryMaxEntries: asNumber(frontmatter.memory_max_entries, 100),
|
||||
memoryTokenBudget: resolveMemoryTokenBudget(frontmatter),
|
||||
reflection: parseReflectionConfig(frontmatter),
|
||||
autoCompactThreshold: asNumber(frontmatter.auto_compact_threshold, 85),
|
||||
tags: asStringArray(frontmatter.tags),
|
||||
avatar: asString(frontmatter.avatar) ?? "",
|
||||
body,
|
||||
contextBody: "",
|
||||
skillsBody: "",
|
||||
env: parseEnvMap(frontmatter.env),
|
||||
permissionRules: { allow: legacyAllow, deny: legacyDeny },
|
||||
isFolder: false,
|
||||
heartbeatEnabled: false,
|
||||
heartbeatSchedule: "",
|
||||
heartbeatBody: "",
|
||||
heartbeatNotify: true,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSkill(ctx: ParserContext, path: string, content: string): SkillConfig | null {
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
const name = asString(frontmatter.name);
|
||||
if (!name) {
|
||||
ctx.reportIssue(path, "Skill requires string field `name`.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: path,
|
||||
name,
|
||||
description: asString(frontmatter.description),
|
||||
tags: asStringArray(frontmatter.tags),
|
||||
body,
|
||||
toolsBody: "",
|
||||
referencesBody: "",
|
||||
examplesBody: "",
|
||||
isFolder: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTask(ctx: ParserContext, path: string, content: string): TaskConfig | null {
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
const taskId = asString(frontmatter.task_id);
|
||||
const agent = asString(frontmatter.agent);
|
||||
const type = asString(frontmatter.type) as TaskConfig["type"] | undefined;
|
||||
if (!taskId || !agent || !type) {
|
||||
ctx.reportIssue(path, "Task requires `task_id`, `agent`, and `type`.");
|
||||
return null;
|
||||
}
|
||||
if (type === "recurring" && !asString(frontmatter.schedule)) {
|
||||
ctx.reportIssue(path, "Recurring task requires `schedule`.");
|
||||
return null;
|
||||
}
|
||||
if (type === "once" && !asString(frontmatter.run_at)) {
|
||||
ctx.reportIssue(path, "One-time task requires `run_at`.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawPriority = asString(frontmatter.priority);
|
||||
const validPriorities = ["low", "medium", "high", "critical"];
|
||||
const priority = (rawPriority && validPriorities.includes(rawPriority) ? rawPriority : "medium") as TaskConfig["priority"];
|
||||
|
||||
return {
|
||||
filePath: path,
|
||||
taskId,
|
||||
agent,
|
||||
schedule: asString(frontmatter.schedule),
|
||||
runAt: asString(frontmatter.run_at),
|
||||
type,
|
||||
priority,
|
||||
enabled: asBoolean(frontmatter.enabled, true),
|
||||
created: asString(frontmatter.created) ?? new Date().toISOString(),
|
||||
lastRun: asString(frontmatter.last_run),
|
||||
nextRun: asString(frontmatter.next_run),
|
||||
runCount: asNumber(frontmatter.run_count, 0),
|
||||
catchUp: asBoolean(frontmatter.catch_up, ctx.settings.catchUpMissedTasks),
|
||||
effort: asString(frontmatter.effort),
|
||||
model: asString(frontmatter.model),
|
||||
channel: asString(frontmatter.channel),
|
||||
channelTarget: asString(frontmatter.channel_target),
|
||||
tags: asStringArray(frontmatter.tags),
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseChannelFile(ctx: ParserContext, path: string, content: string): ChannelConfig | null {
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
|
||||
const name = asString(frontmatter.name);
|
||||
if (!name) {
|
||||
ctx.reportIssue(path, "Channel requires string field `name`.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawType = asString(frontmatter.type);
|
||||
const validTypes: readonly string[] = ["slack", "telegram", "discord"];
|
||||
if (!rawType || !validTypes.includes(rawType)) {
|
||||
ctx.reportIssue(
|
||||
path,
|
||||
`Channel \`${name}\` requires \`type\` to be one of: ${validTypes.join(", ")}.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const type = rawType as ChannelConfig["type"];
|
||||
|
||||
// `default_agent` is the canonical field; `agent` is accepted as a backward-compat alias.
|
||||
const defaultAgent = asString(frontmatter.default_agent) ?? asString(frontmatter.agent);
|
||||
if (!defaultAgent) {
|
||||
ctx.reportIssue(path, `Channel \`${name}\` requires \`default_agent\` (or \`agent\`).`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAgents = asStringArray(frontmatter.allowed_agents);
|
||||
|
||||
const credentialRef = asString(frontmatter.credential_ref);
|
||||
if (!credentialRef) {
|
||||
ctx.reportIssue(
|
||||
path,
|
||||
`Channel \`${name}\` requires \`credential_ref\` pointing at a configured credential.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const transport = isRecord(frontmatter.transport) ? frontmatter.transport : {};
|
||||
|
||||
return {
|
||||
filePath: path,
|
||||
name,
|
||||
type,
|
||||
defaultAgent,
|
||||
allowedAgents,
|
||||
enabled: asBoolean(frontmatter.enabled, true),
|
||||
credentialRef,
|
||||
allowedUsers: asStringArray(frontmatter.allowed_users),
|
||||
perUserSessions: asBoolean(frontmatter.per_user_sessions, true),
|
||||
channelContext: asString(frontmatter.channel_context) ?? "",
|
||||
transport,
|
||||
tags: asStringArray(frontmatter.tags),
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Folder entity loaders (agents/<name>/agent.md + member files,
|
||||
// skills/<name>/skill.md + member files)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export async function loadFolderSkill(
|
||||
ctx: ParserContext,
|
||||
folderPath: string,
|
||||
skillMdFile: TFile,
|
||||
): Promise<SkillConfig | null> {
|
||||
const skillContent = await ctx.vault.cachedRead(skillMdFile);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(skillContent);
|
||||
|
||||
const name = asString(frontmatter.name);
|
||||
if (!name) {
|
||||
ctx.reportIssue(skillMdFile.path, "Folder skill skill.md requires string field `name`.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const readBody = async (fileName: string): Promise<string> => {
|
||||
const path = normalizePath(`${folderPath}/${fileName}`);
|
||||
const file = ctx.vault.getAbstractFileByPath(path);
|
||||
if (!(file instanceof TFile)) return "";
|
||||
const content = await ctx.vault.cachedRead(file);
|
||||
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
return parsed.body;
|
||||
};
|
||||
|
||||
return {
|
||||
filePath: skillMdFile.path,
|
||||
name,
|
||||
description: asString(frontmatter.description),
|
||||
tags: asStringArray(frontmatter.tags),
|
||||
body,
|
||||
toolsBody: await readBody("tools.md"),
|
||||
referencesBody: await readBody("references.md"),
|
||||
examplesBody: await readBody("examples.md"),
|
||||
isFolder: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadFolderAgent(
|
||||
ctx: ParserContext,
|
||||
folderPath: string,
|
||||
agentMdFile: TFile,
|
||||
): Promise<AgentConfig | null> {
|
||||
const agentContent = await ctx.vault.cachedRead(agentMdFile);
|
||||
const { frontmatter: agentFm, body: agentBody } = parseMarkdownWithFrontmatter<Record<string, unknown>>(agentContent);
|
||||
|
||||
const name = asString(agentFm.name);
|
||||
if (!name) {
|
||||
ctx.reportIssue(agentMdFile.path, "Folder agent agent.md requires string field `name`.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read config.md
|
||||
let configFm: Record<string, unknown> = {};
|
||||
const configPath = normalizePath(`${folderPath}/config.md`);
|
||||
const configFile = ctx.vault.getAbstractFileByPath(configPath);
|
||||
if (configFile instanceof TFile) {
|
||||
const configContent = await ctx.vault.cachedRead(configFile);
|
||||
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(configContent);
|
||||
configFm = parsed.frontmatter;
|
||||
}
|
||||
|
||||
// Read permissions.json — canonical source of truth for allow/deny rules.
|
||||
// Legacy migration: if permissions.json is missing or empty BUT config.md
|
||||
// carries `allowed_tools`/`blocked_tools` (the dead surface from older
|
||||
// versions of this plugin), surface those values as permissionRules so
|
||||
// the agent keeps working. The next call to updateAgent / updateWiki-
|
||||
// KeeperAgent rewrites them into permissions.json proper.
|
||||
let permissionRules: { allow: string[]; deny: string[] } = { allow: [], deny: [] };
|
||||
const permissionsPath = normalizePath(`${folderPath}/permissions.json`);
|
||||
// Folder reloads bypass clearStoredFile, so drop any stale issue keyed on
|
||||
// the permissions file before re-evaluating it (avoids duplicates).
|
||||
ctx.clearIssue(permissionsPath);
|
||||
const permissionsFile = ctx.vault.getAbstractFileByPath(permissionsPath);
|
||||
if (permissionsFile instanceof TFile) {
|
||||
try {
|
||||
const permContent = await ctx.vault.cachedRead(permissionsFile);
|
||||
const parsed = JSON.parse(permContent) as Record<string, unknown>;
|
||||
permissionRules = {
|
||||
allow: asStringArray(parsed.allow),
|
||||
deny: asStringArray(parsed.deny),
|
||||
};
|
||||
} catch (err) {
|
||||
// Fail-soft (the agent still loads) but loud: with the file unreadable
|
||||
// the agent would otherwise silently run with EMPTY permission rules.
|
||||
console.error(
|
||||
`Agent Fleet: invalid JSON in ${permissionsPath} — agent "${name}" is running with EMPTY permission rules`,
|
||||
err,
|
||||
);
|
||||
ctx.reportIssue(
|
||||
permissionsPath,
|
||||
`Invalid JSON in permissions.json for agent \`${name}\` — permission rules are NOT applied. Fix or delete the file.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (permissionRules.allow.length === 0 && permissionRules.deny.length === 0) {
|
||||
const legacyAllow = asStringArray(configFm.allowed_tools);
|
||||
const legacyDeny = asStringArray(configFm.blocked_tools);
|
||||
if (legacyAllow.length > 0 || legacyDeny.length > 0) {
|
||||
permissionRules = { allow: legacyAllow, deny: legacyDeny };
|
||||
if (!ctx.warnedLegacyPerms.has(name)) {
|
||||
ctx.warnedLegacyPerms.add(name);
|
||||
console.warn(
|
||||
`Agent Fleet: "${name}" still uses legacy allowed_tools/blocked_tools in config.md. ` +
|
||||
`Permission rules now live in permissions.json. Open this agent in Edit and Save to migrate.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read SKILLS.md body
|
||||
let skillsBody = "";
|
||||
const skillsPath = normalizePath(`${folderPath}/SKILLS.md`);
|
||||
const skillsFile = ctx.vault.getAbstractFileByPath(skillsPath);
|
||||
if (skillsFile instanceof TFile) {
|
||||
const skillsContent = await ctx.vault.cachedRead(skillsFile);
|
||||
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(skillsContent);
|
||||
skillsBody = parsed.body;
|
||||
}
|
||||
|
||||
// Read CONTEXT.md body
|
||||
let contextBody = "";
|
||||
const contextPath = normalizePath(`${folderPath}/CONTEXT.md`);
|
||||
const contextFile = ctx.vault.getAbstractFileByPath(contextPath);
|
||||
if (contextFile instanceof TFile) {
|
||||
const contextContent = await ctx.vault.cachedRead(contextFile);
|
||||
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(contextContent);
|
||||
contextBody = parsed.body;
|
||||
}
|
||||
|
||||
// Read HEARTBEAT.md — autonomous periodic run instruction
|
||||
let heartbeatEnabled = false;
|
||||
let heartbeatSchedule = "";
|
||||
let heartbeatBody = "";
|
||||
let heartbeatNotify = true;
|
||||
let heartbeatChannel = "";
|
||||
let heartbeatChannelTarget = "";
|
||||
const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`);
|
||||
const heartbeatFile = ctx.vault.getAbstractFileByPath(heartbeatPath);
|
||||
if (heartbeatFile instanceof TFile) {
|
||||
const heartbeatContent = await ctx.vault.cachedRead(heartbeatFile);
|
||||
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(heartbeatContent);
|
||||
heartbeatEnabled = asBoolean(parsed.frontmatter.enabled, false);
|
||||
heartbeatSchedule = asString(parsed.frontmatter.schedule) ?? "";
|
||||
heartbeatNotify = asBoolean(parsed.frontmatter.notify, true);
|
||||
heartbeatChannel = asString(parsed.frontmatter.channel) ?? "";
|
||||
heartbeatChannelTarget = asString(parsed.frontmatter.channel_target) ?? "";
|
||||
heartbeatBody = parsed.body;
|
||||
}
|
||||
|
||||
// Model canonical home is config.md for folder agents. agent.md's `model:`
|
||||
// field is deprecated but still warned-about when it conflicts with
|
||||
// config.md so users can clean up their vaults.
|
||||
const agentMdModel = asString(agentFm.model);
|
||||
const configModel = asString(configFm.model);
|
||||
if (agentMdModel && configModel && agentMdModel !== configModel) {
|
||||
if (!ctx.warnedFolderAgentModelConflict.has(name)) {
|
||||
ctx.warnedFolderAgentModelConflict.add(name);
|
||||
console.warn(
|
||||
`Agent Fleet: "${name}" has conflicting model fields — agent.md says "${agentMdModel}", config.md says "${configModel}". config.md wins. Remove agent.md's model field or sync the values to silence this warning.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const model = configModel ?? agentMdModel ?? ctx.settings.defaultModel;
|
||||
|
||||
return {
|
||||
filePath: agentMdFile.path,
|
||||
name,
|
||||
description: asString(agentFm.description),
|
||||
model,
|
||||
adapter: asString(configFm.adapter) ?? "claude-code",
|
||||
permissionMode: asString(configFm.permission_mode) ?? "bypassPermissions",
|
||||
effort: asString(configFm.effort),
|
||||
maxRetries: asNumber(configFm.max_retries, 1),
|
||||
skills: asStringArray(agentFm.skills),
|
||||
mcpServers: asStringArray(agentFm.mcp_servers),
|
||||
cwd: asString(configFm.cwd) || asString(agentFm.cwd),
|
||||
enabled: asBoolean(agentFm.enabled, true),
|
||||
timeout: asNumber(configFm.timeout, asNumber(agentFm.timeout, 300)),
|
||||
approvalRequired: asStringArray(configFm.approval_required),
|
||||
memory: asBoolean(configFm.memory, asBoolean(agentFm.memory, false)),
|
||||
memoryMaxEntries: asNumber(configFm.memory_max_entries, 100),
|
||||
memoryTokenBudget: resolveMemoryTokenBudget(configFm, agentFm),
|
||||
reflection: parseReflectionConfig(configFm, agentFm),
|
||||
autoCompactThreshold: asNumber(
|
||||
configFm.auto_compact_threshold ?? agentFm.auto_compact_threshold,
|
||||
85,
|
||||
),
|
||||
tags: asStringArray(agentFm.tags),
|
||||
avatar: asString(agentFm.avatar) ?? "",
|
||||
body: agentBody,
|
||||
contextBody,
|
||||
skillsBody,
|
||||
env: parseEnvMap(configFm.env),
|
||||
permissionRules,
|
||||
isFolder: true,
|
||||
heartbeatEnabled,
|
||||
heartbeatSchedule,
|
||||
heartbeatBody,
|
||||
heartbeatNotify,
|
||||
heartbeatChannel,
|
||||
heartbeatChannelTarget,
|
||||
wikiKeeper: parseWikiKeeperConfig(configFm.wiki_keeper ?? agentFm.wiki_keeper),
|
||||
wikiReferences: parseWikiReferences(configFm.wiki_references ?? agentFm.wiki_references),
|
||||
};
|
||||
}
|
||||
598
src/repository/entityStore.ts
Normal file
598
src/repository/entityStore.ts
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
import { TFile, TFolder, normalizePath } from "obsidian";
|
||||
import type { App, Vault } from "obsidian";
|
||||
import type { FLEET_SUBFOLDERS } from "../constants";
|
||||
import { asStringArray } from "./shared";
|
||||
import {
|
||||
loadFolderAgent,
|
||||
loadFolderSkill,
|
||||
parseAgent,
|
||||
parseChannelFile,
|
||||
parseSkill,
|
||||
parseTask,
|
||||
sidecarPermissionsPath,
|
||||
} from "./entityParsers";
|
||||
import type { ParsedEntity, ParserContext } from "./entityParsers";
|
||||
import type {
|
||||
AgentConfig,
|
||||
ChannelConfig,
|
||||
ChannelCredentialEntry,
|
||||
FleetSettings,
|
||||
FleetSnapshot,
|
||||
McpServer,
|
||||
SkillConfig,
|
||||
TaskConfig,
|
||||
ValidationIssue,
|
||||
} from "../types";
|
||||
|
||||
/** A path-keyed entity map that notifies on every mutation. The store's
|
||||
* name-keyed lookup indexes are invalidated through this callback, so no
|
||||
* mutation site (load, reload, delete, clear) can ever be missed — staleness
|
||||
* is impossible by construction rather than by auditing call sites. */
|
||||
class MutationTrackedMap<V> extends Map<string, V> {
|
||||
constructor(private readonly onMutate: () => void) {
|
||||
super();
|
||||
}
|
||||
override set(key: string, value: V): this {
|
||||
this.onMutate();
|
||||
return super.set(key, value);
|
||||
}
|
||||
override delete(key: string): boolean {
|
||||
this.onMutate();
|
||||
return super.delete(key);
|
||||
}
|
||||
override clear(): void {
|
||||
this.onMutate();
|
||||
super.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity cluster extracted from the FleetRepository facade: in-memory
|
||||
* agent/skill/task/channel/MCP-server maps, the load flow (flat + folder
|
||||
* entities), the two-tier validation-issue bookkeeping, and cross-entity
|
||||
* reference validation. The facade delegates here and keeps only wiring and
|
||||
* cross-store operations. Persisting mutations (create/update/delete entity
|
||||
* files) lives in EntityMutations, which drives this store's maps through
|
||||
* loadFile/clearStoredFile/validateReferences.
|
||||
*/
|
||||
export class EntityStore {
|
||||
private agents = new MutationTrackedMap<AgentConfig>(() => {
|
||||
this.nameIndexesStale = true;
|
||||
});
|
||||
private skills = new MutationTrackedMap<SkillConfig>(() => {
|
||||
this.nameIndexesStale = true;
|
||||
});
|
||||
private tasks = new MutationTrackedMap<TaskConfig>(() => {
|
||||
this.nameIndexesStale = true;
|
||||
});
|
||||
private channels = new Map<string, ChannelConfig>();
|
||||
private mcpServers = new Map<string, McpServer>();
|
||||
/** Load-time issues (parse failures, corrupt permission/sidecar files),
|
||||
* keyed by file path. Set while loading individual files and cleared only
|
||||
* when that file is re-parsed or removed. */
|
||||
private validationIssues = new Map<string, ValidationIssue[]>();
|
||||
/** Cross-entity REFERENCE issues (missing skill/agent/credential, duplicate
|
||||
* names), keyed by file path. Kept separate from {@link validationIssues}
|
||||
* so {@link validateReferences} can be re-run after any mutation: it clears
|
||||
* and recomputes only this map, never touching (or duplicating alongside)
|
||||
* the load-time corrupt-file issues. */
|
||||
private referenceIssues = new Map<string, ValidationIssue[]>();
|
||||
/** True while loadAll() streams files in — suppresses the per-mutation
|
||||
* validateReferences() so a bulk load validates once at the end instead of
|
||||
* once per file. */
|
||||
private bulkLoading = false;
|
||||
|
||||
/** Lazily rebuilt name-keyed indexes over agents/skills/tasks. Keeps the
|
||||
* FIRST entity per name (insertion order) so duplicate names resolve the
|
||||
* same way the previous linear `find()` scans did. */
|
||||
private nameIndexesStale = true;
|
||||
private agentsByName = new Map<string, AgentConfig>();
|
||||
private skillsByName = new Map<string, SkillConfig>();
|
||||
private tasksById = new Map<string, TaskConfig>();
|
||||
|
||||
private channelCredentialGetter?: () => Record<string, ChannelCredentialEntry>;
|
||||
|
||||
/** Agents we've already logged a folder-model-conflict warning for. */
|
||||
private warnedFolderAgentModelConflict = new Set<string>();
|
||||
private warnedLegacyPerms = new Set<string>();
|
||||
|
||||
private readonly vault: Vault;
|
||||
private readonly settings: FleetSettings;
|
||||
/** Context handed to the extracted entity parsers — same surface the
|
||||
* parsers had as private facade methods (vault reads, issue reporting into
|
||||
* {@link validationIssues}, the one-time-warning sets). */
|
||||
private readonly parserCtx: ParserContext;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly deps: {
|
||||
settings: FleetSettings;
|
||||
getFleetRoot: () => string;
|
||||
getSubfolder: (name: (typeof FLEET_SUBFOLDERS)[number]) => string;
|
||||
/** MCP registry files are parsed by McpRegistry; the store only owns the
|
||||
* in-memory server map. Routed through the facade so registry parse
|
||||
* errors keep landing in this store's validation-issue map. */
|
||||
parseMcpServerFile: (path: string, content: string) => McpServer | null;
|
||||
},
|
||||
) {
|
||||
this.vault = app.vault;
|
||||
this.settings = deps.settings;
|
||||
this.parserCtx = {
|
||||
vault: this.vault,
|
||||
settings: this.settings,
|
||||
reportIssue: (path, message) => this.setIssue(path, message),
|
||||
clearIssue: (path) => {
|
||||
this.validationIssues.delete(path);
|
||||
},
|
||||
warnedLegacyPerms: this.warnedLegacyPerms,
|
||||
warnedFolderAgentModelConflict: this.warnedFolderAgentModelConflict,
|
||||
};
|
||||
}
|
||||
|
||||
/** Inject a live credential getter so validation reads from the credential store
|
||||
* instead of the (possibly empty post-migration) settings.channelCredentials. */
|
||||
setChannelCredentialGetter(getter: () => Record<string, ChannelCredentialEntry>): void {
|
||||
this.channelCredentialGetter = getter;
|
||||
}
|
||||
|
||||
async loadAll(): Promise<FleetSnapshot> {
|
||||
this.agents.clear();
|
||||
this.skills.clear();
|
||||
this.tasks.clear();
|
||||
this.channels.clear();
|
||||
this.mcpServers.clear();
|
||||
this.validationIssues.clear();
|
||||
this.referenceIssues.clear();
|
||||
|
||||
this.bulkLoading = true;
|
||||
try {
|
||||
// Load folder-based agents and skills first
|
||||
await this.loadFolderAgents();
|
||||
await this.loadFolderSkills();
|
||||
|
||||
const files = this.vault
|
||||
.getMarkdownFiles()
|
||||
.filter((file) => file.path.startsWith(`${this.deps.getFleetRoot()}/`));
|
||||
|
||||
for (const file of files) {
|
||||
await this.loadFile(file);
|
||||
}
|
||||
} finally {
|
||||
this.bulkLoading = false;
|
||||
}
|
||||
|
||||
this.validateReferences();
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
async loadFile(fileOrPath: TFile | string): Promise<void> {
|
||||
await this.loadFileInternal(fileOrPath);
|
||||
// Entity references may have changed (e.g. an agent's skills list edited on
|
||||
// disk, a task retargeted) — recompute reference issues immediately instead
|
||||
// of waiting for the next full loadAll(). Skipped inside loadAll's own loop
|
||||
// (validated once at the end). Cheap: validateReferences is a pure in-memory
|
||||
// pass over the loaded maps.
|
||||
if (!this.bulkLoading) this.validateReferences();
|
||||
}
|
||||
|
||||
private async loadFileInternal(fileOrPath: TFile | string): Promise<void> {
|
||||
const file = typeof fileOrPath === "string" ? this.vault.getAbstractFileByPath(fileOrPath) : fileOrPath;
|
||||
if (!(file instanceof TFile) || file.extension !== "md") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Files inside agent folders need to reload the whole folder agent
|
||||
if (this.isInsideAgentFolder(file.path)) {
|
||||
await this.reloadFolderAgentContaining(file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Files inside skill folders need to reload the whole folder skill
|
||||
if (this.isInsideSkillFolder(file.path)) {
|
||||
await this.reloadFolderSkillContaining(file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearStoredFile(file.path);
|
||||
|
||||
// Channel files live under _fleet/channels/ and are parsed through a dedicated
|
||||
// path that does NOT flow through parseFile(). This keeps channels out of the
|
||||
// ParsedEntity union (AgentConfig | SkillConfig | TaskConfig) and lets their
|
||||
// validation (type discrimination, approval_required block) live beside the
|
||||
// parser.
|
||||
const channelsPrefix = `${this.deps.getSubfolder("channels")}/`;
|
||||
if (file.path.startsWith(channelsPrefix)) {
|
||||
// Skip nested files (e.g. sessions/<id>.json). Only top-level *.md are channel configs.
|
||||
const rel = file.path.slice(channelsPrefix.length);
|
||||
if (!rel.includes("/")) {
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const channel = parseChannelFile(this.parserCtx, file.path, content);
|
||||
if (channel) {
|
||||
this.channels.set(file.path, channel);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// MCP server registry files live under _fleet/mcp/ and, like channels, are
|
||||
// parsed through a dedicated path that does NOT flow through parseFile()
|
||||
// (they're not part of the ParsedEntity union).
|
||||
const mcpPrefix = `${this.deps.getSubfolder("mcp")}/`;
|
||||
if (file.path.startsWith(mcpPrefix)) {
|
||||
const rel = file.path.slice(mcpPrefix.length);
|
||||
if (!rel.includes("/")) {
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const server = this.deps.parseMcpServerFile(file.path, content);
|
||||
if (server) {
|
||||
this.mcpServers.set(file.path, server);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const parsed = this.parseFile(file.path, content);
|
||||
if (parsed) {
|
||||
if ("taskId" in parsed) {
|
||||
this.tasks.set(file.path, parsed);
|
||||
} else if ("model" in parsed) {
|
||||
// Flat agent: sidecar `<name>.permissions.json` wins over legacy
|
||||
// frontmatter allowed_tools/blocked_tools when both exist.
|
||||
if (!parsed.isFolder) {
|
||||
const sidecarPath = sidecarPermissionsPath(file.path);
|
||||
// clearStoredFile above only clears issues keyed on the .md path;
|
||||
// drop any stale sidecar issue before re-evaluating the file.
|
||||
this.validationIssues.delete(sidecarPath);
|
||||
const sidecarFile = this.vault.getAbstractFileByPath(sidecarPath);
|
||||
if (sidecarFile instanceof TFile) {
|
||||
try {
|
||||
const sidecarContent = await this.vault.cachedRead(sidecarFile);
|
||||
const data = JSON.parse(sidecarContent) as Record<string, unknown>;
|
||||
parsed.permissionRules = {
|
||||
allow: asStringArray(data.allow),
|
||||
deny: asStringArray(data.deny),
|
||||
};
|
||||
} catch (err) {
|
||||
// Invalid sidecar JSON — keep whatever permissionRules parseAgent assigned.
|
||||
console.error(
|
||||
`Agent Fleet: invalid JSON in ${sidecarPath} — agent "${parsed.name}" falls back to legacy frontmatter permission rules`,
|
||||
err,
|
||||
);
|
||||
this.setIssue(
|
||||
sidecarPath,
|
||||
`Invalid JSON in ${sidecarPath} for agent \`${parsed.name}\` — sidecar permission rules are NOT applied.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.agents.set(file.path, parsed);
|
||||
} else {
|
||||
this.skills.set(file.path, parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadFolderAgentContaining(filePath: string): Promise<void> {
|
||||
const agentsDir = `${this.deps.getSubfolder("agents")}/`;
|
||||
const relative = filePath.slice(agentsDir.length);
|
||||
const folderName = relative.split("/")[0];
|
||||
if (!folderName) return;
|
||||
|
||||
const folderPath = normalizePath(`${agentsDir}${folderName}`);
|
||||
const agentMdPath = normalizePath(`${folderPath}/agent.md`);
|
||||
|
||||
// Clear any existing entry for this folder agent
|
||||
this.agents.delete(agentMdPath);
|
||||
|
||||
const folder = this.vault.getAbstractFileByPath(folderPath);
|
||||
if (!(folder instanceof TFolder)) return;
|
||||
|
||||
const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath);
|
||||
if (!(agentMdFile instanceof TFile)) return;
|
||||
|
||||
const agent = await loadFolderAgent(this.parserCtx, folderPath, agentMdFile);
|
||||
if (agent) {
|
||||
this.agents.set(agentMdPath, agent);
|
||||
}
|
||||
}
|
||||
|
||||
private isInsideAgentFolder(path: string): boolean {
|
||||
const agentsDir = `${this.deps.getSubfolder("agents")}/`;
|
||||
if (!path.startsWith(agentsDir)) return false;
|
||||
const relative = path.slice(agentsDir.length);
|
||||
return relative.includes("/");
|
||||
}
|
||||
|
||||
private isInsideSkillFolder(path: string): boolean {
|
||||
const skillsDir = `${this.deps.getSubfolder("skills")}/`;
|
||||
if (!path.startsWith(skillsDir)) return false;
|
||||
const relative = path.slice(skillsDir.length);
|
||||
return relative.includes("/");
|
||||
}
|
||||
|
||||
private async reloadFolderSkillContaining(filePath: string): Promise<void> {
|
||||
const skillsDir = `${this.deps.getSubfolder("skills")}/`;
|
||||
const relative = filePath.slice(skillsDir.length);
|
||||
const folderName = relative.split("/")[0];
|
||||
if (!folderName) return;
|
||||
|
||||
const folderPath = normalizePath(`${skillsDir}${folderName}`);
|
||||
const skillMdPath = normalizePath(`${folderPath}/skill.md`);
|
||||
|
||||
this.skills.delete(skillMdPath);
|
||||
|
||||
const folder = this.vault.getAbstractFileByPath(folderPath);
|
||||
if (!(folder instanceof TFolder)) return;
|
||||
|
||||
const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath);
|
||||
if (!(skillMdFile instanceof TFile)) return;
|
||||
|
||||
const skill = await loadFolderSkill(this.parserCtx, folderPath, skillMdFile);
|
||||
if (skill) {
|
||||
this.skills.set(skillMdPath, skill);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFolderSkills(): Promise<void> {
|
||||
const skillsFolder = this.vault.getAbstractFileByPath(this.deps.getSubfolder("skills"));
|
||||
if (!(skillsFolder instanceof TFolder)) return;
|
||||
|
||||
for (const child of skillsFolder.children) {
|
||||
if (!(child instanceof TFolder)) continue;
|
||||
const skillMdPath = normalizePath(`${child.path}/skill.md`);
|
||||
const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath);
|
||||
if (!(skillMdFile instanceof TFile)) continue;
|
||||
|
||||
const skill = await loadFolderSkill(this.parserCtx, child.path, skillMdFile);
|
||||
if (skill) {
|
||||
this.skills.set(skillMdPath, skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFolderAgents(): Promise<void> {
|
||||
const agentsFolder = this.vault.getAbstractFileByPath(this.deps.getSubfolder("agents"));
|
||||
if (!(agentsFolder instanceof TFolder)) return;
|
||||
|
||||
for (const child of agentsFolder.children) {
|
||||
if (!(child instanceof TFolder)) continue;
|
||||
const agentMdPath = normalizePath(`${child.path}/agent.md`);
|
||||
const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath);
|
||||
if (!(agentMdFile instanceof TFile)) continue;
|
||||
|
||||
const agent = await loadFolderAgent(this.parserCtx, child.path, agentMdFile);
|
||||
if (agent) {
|
||||
this.agents.set(agentMdPath, agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(path: string): void {
|
||||
this.clearStoredFile(path);
|
||||
// A removed entity can orphan references pointing at it — revalidate now.
|
||||
if (!this.bulkLoading) this.validateReferences();
|
||||
}
|
||||
|
||||
getSnapshot(): FleetSnapshot {
|
||||
return {
|
||||
agents: Array.from(this.agents.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
skills: Array.from(this.skills.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
tasks: Array.from(this.tasks.values()).sort((a, b) => a.taskId.localeCompare(b.taskId)),
|
||||
channels: Array.from(this.channels.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
mcpServers: Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
validationIssues: [
|
||||
...Array.from(this.validationIssues.values()).flat(),
|
||||
...Array.from(this.referenceIssues.values()).flat(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private rebuildNameIndexes(): void {
|
||||
if (!this.nameIndexesStale) return;
|
||||
this.agentsByName.clear();
|
||||
this.skillsByName.clear();
|
||||
this.tasksById.clear();
|
||||
for (const agent of this.agents.values()) {
|
||||
if (!this.agentsByName.has(agent.name)) this.agentsByName.set(agent.name, agent);
|
||||
}
|
||||
for (const skill of this.skills.values()) {
|
||||
if (!this.skillsByName.has(skill.name)) this.skillsByName.set(skill.name, skill);
|
||||
}
|
||||
for (const task of this.tasks.values()) {
|
||||
if (!this.tasksById.has(task.taskId)) this.tasksById.set(task.taskId, task);
|
||||
}
|
||||
this.nameIndexesStale = false;
|
||||
}
|
||||
|
||||
getAgentByName(name: string): AgentConfig | undefined {
|
||||
this.rebuildNameIndexes();
|
||||
return this.agentsByName.get(name);
|
||||
}
|
||||
|
||||
getSkillByName(name: string): SkillConfig | undefined {
|
||||
this.rebuildNameIndexes();
|
||||
return this.skillsByName.get(name);
|
||||
}
|
||||
|
||||
getTaskById(taskId: string): TaskConfig | undefined {
|
||||
this.rebuildNameIndexes();
|
||||
return this.tasksById.get(taskId);
|
||||
}
|
||||
|
||||
getTasksForAgent(agentName: string): TaskConfig[] {
|
||||
return Array.from(this.tasks.values()).filter((task) => task.agent === agentName);
|
||||
}
|
||||
|
||||
getChannelByName(name: string): ChannelConfig | undefined {
|
||||
return Array.from(this.channels.values()).find((channel) => channel.name === name);
|
||||
}
|
||||
|
||||
getChannelsForAgent(agentName: string): ChannelConfig[] {
|
||||
return Array.from(this.channels.values()).filter((channel) => channel.defaultAgent === agentName);
|
||||
}
|
||||
|
||||
/** All registered MCP servers, sorted by name. */
|
||||
getMcpServers(): McpServer[] {
|
||||
return Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
getMcpServerByName(name: string): McpServer | undefined {
|
||||
return Array.from(this.mcpServers.values()).find((s) => s.name === name);
|
||||
}
|
||||
|
||||
/** Drop every in-memory entity and issue keyed on `path`. Public within the
|
||||
* repository layer: EntityMutations drives it on delete paths. */
|
||||
clearStoredFile(path: string): void {
|
||||
this.agents.delete(path);
|
||||
this.skills.delete(path);
|
||||
this.tasks.delete(path);
|
||||
this.channels.delete(path);
|
||||
this.mcpServers.delete(path);
|
||||
this.validationIssues.delete(path);
|
||||
this.referenceIssues.delete(path);
|
||||
}
|
||||
|
||||
/** Drop every stored issue keyed under `folderPath/` — folder agents/skills
|
||||
* carry issues keyed on member files (e.g. permissions.json), which
|
||||
* clearStoredFile(<entity>.md) alone would leave behind after a delete. */
|
||||
clearIssuesUnder(folderPath: string): void {
|
||||
const prefix = `${folderPath}/`;
|
||||
for (const key of [...this.validationIssues.keys()]) {
|
||||
if (key.startsWith(prefix)) this.validationIssues.delete(key);
|
||||
}
|
||||
for (const key of [...this.referenceIssues.keys()]) {
|
||||
if (key.startsWith(prefix)) this.referenceIssues.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop both load-time and reference issues keyed on exactly `path` — used
|
||||
* when deleting a flat agent to clear its permissions-sidecar issues. */
|
||||
clearIssuesFor(path: string): void {
|
||||
this.validationIssues.delete(path);
|
||||
this.referenceIssues.delete(path);
|
||||
}
|
||||
|
||||
/** Record a LOAD-TIME issue (parse failure, corrupt permission/sidecar
|
||||
* file). Public within the repository layer: McpRegistry reports its parse
|
||||
* errors here (wired through the facade) so they land in the same map as
|
||||
* every other parser's. */
|
||||
setIssue(path: string, message: string): void {
|
||||
const issues = this.validationIssues.get(path) ?? [];
|
||||
issues.push({ path, message });
|
||||
this.validationIssues.set(path, issues);
|
||||
}
|
||||
|
||||
/** Record a cross-entity reference issue. Lives in {@link referenceIssues}
|
||||
* (recomputed wholesale by validateReferences), NOT in the load-time
|
||||
* {@link validationIssues} map. */
|
||||
private setReferenceIssue(path: string, message: string): void {
|
||||
const issues = this.referenceIssues.get(path) ?? [];
|
||||
issues.push({ path, message });
|
||||
this.referenceIssues.set(path, issues);
|
||||
}
|
||||
|
||||
private parseFile(path: string, content: string): ParsedEntity | null {
|
||||
if (path.startsWith(`${this.deps.getSubfolder("agents")}/`)) {
|
||||
return parseAgent(this.parserCtx, path, content);
|
||||
}
|
||||
if (path.startsWith(`${this.deps.getSubfolder("skills")}/`)) {
|
||||
return parseSkill(this.parserCtx, path, content);
|
||||
}
|
||||
if (path.startsWith(`${this.deps.getSubfolder("tasks")}/`)) {
|
||||
return parseTask(this.parserCtx, path, content);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute every cross-entity reference issue from the in-memory maps.
|
||||
* Pure in-memory pass (no vault I/O — only the loaded maps plus the injected
|
||||
* credential getter), so it is cheap enough to re-run after every mutation.
|
||||
* Idempotent: {@link referenceIssues} is cleared and rebuilt wholesale each
|
||||
* run; load-time corrupt-file issues live in the separate
|
||||
* {@link validationIssues} map and are never touched here.
|
||||
*
|
||||
* Public within the repository layer: EntityMutations re-runs it after each
|
||||
* delete once all map mutations are done.
|
||||
*/
|
||||
validateReferences(): void {
|
||||
this.referenceIssues.clear();
|
||||
|
||||
const skillNames = new Set<string>();
|
||||
for (const skill of this.skills.values()) {
|
||||
if (skillNames.has(skill.name)) {
|
||||
this.setReferenceIssue(skill.filePath, `Duplicate skill name \`${skill.name}\`.`);
|
||||
}
|
||||
skillNames.add(skill.name);
|
||||
}
|
||||
|
||||
const agentNames = new Set<string>();
|
||||
for (const agent of this.agents.values()) {
|
||||
if (agentNames.has(agent.name)) {
|
||||
this.setReferenceIssue(agent.filePath, `Duplicate agent name \`${agent.name}\`.`);
|
||||
}
|
||||
agentNames.add(agent.name);
|
||||
for (const skillName of agent.skills) {
|
||||
if (!skillNames.has(skillName)) {
|
||||
this.setReferenceIssue(agent.filePath, `Agent references missing skill \`${skillName}\`.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (!agentNames.has(task.agent)) {
|
||||
this.setReferenceIssue(task.filePath, `Task references missing agent \`${task.agent}\`.`);
|
||||
}
|
||||
}
|
||||
|
||||
const channelNames = new Set<string>();
|
||||
const agentsByName = new Map<string, AgentConfig>();
|
||||
for (const agent of this.agents.values()) {
|
||||
agentsByName.set(agent.name, agent);
|
||||
}
|
||||
const credentials = this.channelCredentialGetter?.() ?? this.settings.channelCredentials ?? {};
|
||||
|
||||
for (const channel of this.channels.values()) {
|
||||
if (channelNames.has(channel.name)) {
|
||||
this.setReferenceIssue(channel.filePath, `Duplicate channel name \`${channel.name}\`.`);
|
||||
}
|
||||
channelNames.add(channel.name);
|
||||
|
||||
const boundAgent = agentsByName.get(channel.defaultAgent);
|
||||
if (!boundAgent) {
|
||||
this.setReferenceIssue(
|
||||
channel.filePath,
|
||||
`Channel \`${channel.name}\` references missing agent \`${channel.defaultAgent}\`.`,
|
||||
);
|
||||
} else if (boundAgent.approvalRequired.length > 0) {
|
||||
// Channels have no approval UI; a channel turn needing approval would deadlock.
|
||||
// Block the binding at load time — the channel loads but is treated as disabled.
|
||||
this.setReferenceIssue(
|
||||
channel.filePath,
|
||||
`Channel \`${channel.name}\` cannot bind to agent \`${boundAgent.name}\` because the agent has \`approval_required\` set (${boundAgent.approvalRequired.join(", ")}). Clear the approval list on the agent or pick a different agent.`,
|
||||
);
|
||||
}
|
||||
|
||||
const credential = credentials[channel.credentialRef];
|
||||
if (!credential) {
|
||||
this.setReferenceIssue(
|
||||
channel.filePath,
|
||||
`Channel \`${channel.name}\` references missing credential \`${channel.credentialRef}\`. Add it under Settings → Channel Credentials.`,
|
||||
);
|
||||
} else if (credential.type !== channel.type) {
|
||||
this.setReferenceIssue(
|
||||
channel.filePath,
|
||||
`Channel \`${channel.name}\` is type \`${channel.type}\` but credential \`${channel.credentialRef}\` is type \`${credential.type}\`.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mcpNames = new Set<string>();
|
||||
for (const server of this.mcpServers.values()) {
|
||||
if (mcpNames.has(server.name)) {
|
||||
this.setReferenceIssue(server.filePath ?? server.name, `Duplicate MCP server name \`${server.name}\`.`);
|
||||
}
|
||||
mcpNames.add(server.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/repository/mcpRegistry.test.ts
Normal file
104
src/repository/mcpRegistry.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { App } from "obsidian";
|
||||
import type { ValidationIssue } from "../types";
|
||||
import { McpRegistry } from "./mcpRegistry";
|
||||
import { FakeVault, makeApp } from "./testSupport";
|
||||
|
||||
describe("McpRegistry", () => {
|
||||
let vault: FakeVault;
|
||||
let issues: ValidationIssue[];
|
||||
let registry: McpRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
vault = new FakeVault();
|
||||
issues = [];
|
||||
registry = new McpRegistry(makeApp(vault) as App, {
|
||||
getMcpDir: () => "_fleet/mcp",
|
||||
getServerByName: () => undefined,
|
||||
reportIssue: (path, message) => issues.push({ path, message }),
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMcpServerFile", () => {
|
||||
it("parses a valid stdio server with defaults", () => {
|
||||
const server = registry.parseMcpServerFile(
|
||||
"_fleet/mcp/files.md",
|
||||
"---\nname: files\ntransport: stdio\ncommand: npx\n---\n\nLocal file server\n",
|
||||
);
|
||||
expect(server).not.toBeNull();
|
||||
expect(server?.type).toBe("stdio");
|
||||
expect(server?.enabled).toBe(true); // default
|
||||
expect(server?.source).toBe("manual"); // default
|
||||
expect(server?.description).toBe("Local file server"); // body fallback
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts `type` as a transport alias", () => {
|
||||
const server = registry.parseMcpServerFile(
|
||||
"_fleet/mcp/api.md",
|
||||
"---\nname: api\ntype: http\nurl: https://example.com/mcp\n---\n",
|
||||
);
|
||||
expect(server?.type).toBe("http");
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a missing/invalid transport and records an issue", () => {
|
||||
const server = registry.parseMcpServerFile(
|
||||
"_fleet/mcp/bad.md",
|
||||
"---\nname: bad\ntransport: carrier-pigeon\n---\n",
|
||||
);
|
||||
expect(server).toBeNull();
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.path).toBe("_fleet/mcp/bad.md");
|
||||
});
|
||||
|
||||
it("rejects stdio without command and http without url", () => {
|
||||
expect(
|
||||
registry.parseMcpServerFile("_fleet/mcp/a.md", "---\nname: a\ntransport: stdio\n---\n"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
registry.parseMcpServerFile("_fleet/mcp/b.md", "---\nname: b\ntransport: http\n---\n"),
|
||||
).toBeNull();
|
||||
expect(issues).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveMcpServer", () => {
|
||||
it("creates a new registry file with a de-duplicated slug path", async () => {
|
||||
vault.addFile("_fleet/mcp/files.md", "---\nname: files\ntransport: stdio\ncommand: old\n---\n");
|
||||
const path = await registry.saveMcpServer({
|
||||
name: "files",
|
||||
type: "stdio",
|
||||
enabled: true,
|
||||
source: "manual",
|
||||
command: "npx",
|
||||
args: [],
|
||||
status: "disconnected",
|
||||
scope: "user",
|
||||
tools: [],
|
||||
toolDetails: [],
|
||||
});
|
||||
expect(path).toBe("_fleet/mcp/files-2.md"); // existing file → suffixed
|
||||
expect(vault.contents.get(path)).toContain("command: npx");
|
||||
});
|
||||
|
||||
it("rewrites in place when filePath is set", async () => {
|
||||
vault.addFile("_fleet/mcp/files.md", "---\nname: files\ntransport: stdio\ncommand: old\n---\n");
|
||||
const path = await registry.saveMcpServer({
|
||||
name: "files",
|
||||
filePath: "_fleet/mcp/files.md",
|
||||
type: "stdio",
|
||||
enabled: false,
|
||||
source: "manual",
|
||||
command: "npx",
|
||||
args: [],
|
||||
status: "disconnected",
|
||||
scope: "user",
|
||||
tools: [],
|
||||
toolDetails: [],
|
||||
});
|
||||
expect(path).toBe("_fleet/mcp/files.md");
|
||||
expect(vault.contents.get(path)).toContain("enabled: false");
|
||||
});
|
||||
});
|
||||
});
|
||||
170
src/repository/mcpRegistry.ts
Normal file
170
src/repository/mcpRegistry.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { TFile } from "obsidian";
|
||||
import type { App, Vault } from "obsidian";
|
||||
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
|
||||
import type { McpServer } from "../types";
|
||||
import { asBoolean, asString, asStringArray, asStringMap, ensureFolder, getAvailablePath, isRecord, trashPath } from "./shared";
|
||||
|
||||
/**
|
||||
* MCP server registry: parse + persist `_fleet/mcp/<name>.md` files.
|
||||
* Extracted verbatim from FleetRepository. The facade still owns the
|
||||
* in-memory server map (load flow + snapshot); parse errors are reported
|
||||
* through the injected `reportIssue` so they land in the same
|
||||
* validation-issue map as every other parser.
|
||||
*/
|
||||
export class McpRegistry {
|
||||
private readonly vault: Vault;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly deps: {
|
||||
getMcpDir: () => string;
|
||||
getServerByName: (name: string) => McpServer | undefined;
|
||||
reportIssue: (path: string, message: string) => void;
|
||||
},
|
||||
) {
|
||||
this.vault = app.vault;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one `_fleet/mcp/<name>.md` registry file. The frontmatter holds the
|
||||
* non-secret server definition; the body is a human description. Secrets
|
||||
* (bearer/OAuth tokens, secret env values) are NEVER read here — they live in
|
||||
* SecretStore and are resolved at projection time.
|
||||
*/
|
||||
parseMcpServerFile(path: string, content: string): McpServer | null {
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
|
||||
const name = asString(frontmatter.name);
|
||||
if (!name) {
|
||||
this.deps.reportIssue(path, "MCP server requires string field `name`.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// `transport` is the canonical frontmatter key; `type` is accepted as an
|
||||
// alias (matches the native config vocabulary).
|
||||
const rawTransport = (asString(frontmatter.transport) ?? asString(frontmatter.type) ?? "").toLowerCase();
|
||||
const validTransports: readonly string[] = ["stdio", "http", "sse"];
|
||||
if (!validTransports.includes(rawTransport)) {
|
||||
this.deps.reportIssue(
|
||||
path,
|
||||
`MCP server \`${name}\` requires \`transport\` to be one of: ${validTransports.join(", ")}.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const type = rawTransport as McpServer["type"];
|
||||
|
||||
if (type === "stdio") {
|
||||
if (!asString(frontmatter.command)) {
|
||||
this.deps.reportIssue(path, `MCP server \`${name}\` (stdio) requires a \`command\`.`);
|
||||
return null;
|
||||
}
|
||||
} else if (!asString(frontmatter.url)) {
|
||||
this.deps.reportIssue(path, `MCP server \`${name}\` (${type}) requires a \`url\`.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawAuth = (asString(frontmatter.auth) ?? "").toLowerCase();
|
||||
const auth: McpServer["auth"] =
|
||||
rawAuth === "bearer" || rawAuth === "oauth" || rawAuth === "none" ? rawAuth : undefined;
|
||||
|
||||
let oauth: McpServer["oauth"];
|
||||
if (isRecord(frontmatter.oauth)) {
|
||||
const o = frontmatter.oauth;
|
||||
oauth = {
|
||||
clientId: asString(o.client_id) ?? asString(o.clientId),
|
||||
resource: asString(o.resource),
|
||||
scopes: asStringArray(o.scopes),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
filePath: path,
|
||||
type,
|
||||
enabled: asBoolean(frontmatter.enabled, true),
|
||||
description: asString(frontmatter.description) ?? (body.trim() || undefined),
|
||||
source: asString(frontmatter.source) === "imported" ? "imported" : "manual",
|
||||
command: asString(frontmatter.command),
|
||||
args: asStringArray(frontmatter.args),
|
||||
env: asStringMap(frontmatter.env),
|
||||
envSecretKeys: asStringArray(frontmatter.env_secret_keys),
|
||||
url: asString(frontmatter.url),
|
||||
headers: asStringMap(frontmatter.headers),
|
||||
auth,
|
||||
oauth,
|
||||
// Runtime-only fields — filled in by an on-demand probe, not persisted.
|
||||
status: "disconnected",
|
||||
scope: "user",
|
||||
tools: [],
|
||||
toolDetails: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Build `_fleet/mcp/<name>.md` frontmatter from a server definition,
|
||||
* omitting empty/runtime fields. Secrets are never written here. */
|
||||
private mcpServerFrontmatter(server: McpServer): Record<string, unknown> {
|
||||
const fm: Record<string, unknown> = {
|
||||
name: server.name,
|
||||
transport: server.type,
|
||||
enabled: server.enabled,
|
||||
};
|
||||
if (server.source) fm.source = server.source;
|
||||
if (server.type === "stdio") {
|
||||
if (server.command) fm.command = server.command;
|
||||
if (server.args && server.args.length > 0) fm.args = server.args;
|
||||
if (server.env && Object.keys(server.env).length > 0) fm.env = server.env;
|
||||
if (server.envSecretKeys && server.envSecretKeys.length > 0) fm.env_secret_keys = server.envSecretKeys;
|
||||
} else {
|
||||
if (server.url) fm.url = server.url;
|
||||
if (server.headers && Object.keys(server.headers).length > 0) fm.headers = server.headers;
|
||||
if (server.auth) fm.auth = server.auth;
|
||||
if (server.oauth && (server.oauth.clientId || server.oauth.resource || (server.oauth.scopes?.length ?? 0) > 0)) {
|
||||
fm.oauth = {
|
||||
client_id: server.oauth.clientId || undefined,
|
||||
resource: server.oauth.resource || undefined,
|
||||
scopes: server.oauth.scopes && server.oauth.scopes.length > 0 ? server.oauth.scopes : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update an MCP server registry file. When `server.filePath` is set
|
||||
* the existing file is rewritten in place; otherwise a new
|
||||
* `_fleet/mcp/<slug>.md` is created. Returns the file path. Secrets are NOT
|
||||
* handled here — callers store tokens/secret env values in SecretStore.
|
||||
*/
|
||||
async saveMcpServer(server: McpServer, body = ""): Promise<string> {
|
||||
const fm = this.mcpServerFrontmatter(server);
|
||||
const content = stringifyMarkdownWithFrontmatter(fm, body.trim());
|
||||
const existing = server.filePath ? this.vault.getAbstractFileByPath(server.filePath) : null;
|
||||
if (existing instanceof TFile) {
|
||||
await this.vault.modify(existing, content);
|
||||
return existing.path;
|
||||
}
|
||||
const path = await getAvailablePath(this.vault, this.deps.getMcpDir(), slugify(server.name));
|
||||
await ensureFolder(this.vault, this.deps.getMcpDir());
|
||||
await this.vault.create(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Flip an MCP server's `enabled` flag in its registry file. */
|
||||
async setMcpServerEnabled(name: string, enabled: boolean): Promise<void> {
|
||||
const server = this.deps.getServerByName(name);
|
||||
if (!server?.filePath) return;
|
||||
const file = this.vault.getAbstractFileByPath(server.filePath);
|
||||
if (!(file instanceof TFile)) return;
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
frontmatter.enabled = enabled;
|
||||
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, body));
|
||||
}
|
||||
|
||||
/** Trash an MCP server's registry file. */
|
||||
async deleteMcpServer(name: string): Promise<void> {
|
||||
const server = this.deps.getServerByName(name);
|
||||
if (!server?.filePath) return;
|
||||
await trashPath(this.app, server.filePath);
|
||||
}
|
||||
}
|
||||
108
src/repository/memoryStore.test.ts
Normal file
108
src/repository/memoryStore.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MockInstance } from "vitest";
|
||||
|
||||
// memoryStore references FileSystemAdapter at runtime (instanceof); the shared
|
||||
// obsidian stub lacks it — extend it here, mirroring fleetRepository.test.ts.
|
||||
vi.mock("obsidian", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
App: class App {},
|
||||
FileSystemAdapter: class FileSystemAdapter {},
|
||||
};
|
||||
});
|
||||
|
||||
import { MEMORY_SCHEMA_VERSION, emptyWorkingMemory } from "../utils/memoryFormat";
|
||||
import { MemoryStore } from "./memoryStore";
|
||||
import { FakeVault, makeApp } from "./testSupport";
|
||||
|
||||
const WORKING = "_fleet/memory/bot/working.md";
|
||||
|
||||
describe("MemoryStore working-memory schema guard", () => {
|
||||
let vault: FakeVault;
|
||||
let store: MemoryStore;
|
||||
let warnSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
vault = new FakeVault();
|
||||
store = new MemoryStore(makeApp(vault), () => "_fleet/memory");
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("parses a current-schema working.md normally", async () => {
|
||||
vault.addFile(
|
||||
WORKING,
|
||||
`---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION}\n---\n\n## Observations\n- knows things\n`,
|
||||
);
|
||||
const wm = await store.readWorkingMemory("bot");
|
||||
expect(wm?.schema).toBe(MEMORY_SCHEMA_VERSION);
|
||||
expect(wm?.sections[0]?.entries[0]?.text).toBe("knows things");
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a missing schema field as current (pre-schema files keep working)", async () => {
|
||||
vault.addFile(WORKING, "---\nagent: bot\n---\n\n## Observations\n- old fact\n");
|
||||
const wm = await store.readWorkingMemory("bot");
|
||||
expect(wm?.schema).toBe(MEMORY_SCHEMA_VERSION);
|
||||
expect(wm?.sections[0]?.entries[0]?.text).toBe("old fact");
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps parsing an OLDER-but-known numeric schema (current behavior)", async () => {
|
||||
vault.addFile(WORKING, "---\nagent: bot\nschema: 1\n---\n\n## Observations\n- v1 fact\n");
|
||||
const wm = await store.readWorkingMemory("bot");
|
||||
expect(wm?.schema).toBe(1);
|
||||
expect(wm?.sections[0]?.entries[0]?.text).toBe("v1 fact");
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns and returns null on a NEWER schema instead of misparsing it", async () => {
|
||||
vault.addFile(
|
||||
WORKING,
|
||||
`---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION + 1}\n---\n\n## Observations\n- future fact\n`,
|
||||
);
|
||||
expect(await store.readWorkingMemory("bot")).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
// getMemory (the back-compat shim) inherits the guard.
|
||||
expect(await store.getMemory("bot")).toBeNull();
|
||||
});
|
||||
|
||||
it("warns and returns null on a non-numeric schema marker", async () => {
|
||||
vault.addFile(WORKING, "---\nagent: bot\nschema: v9-beta\n---\n\n## Observations\n- weird\n");
|
||||
expect(await store.readWorkingMemory("bot")).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to overwrite a newer-schema working.md (never destroys data)", async () => {
|
||||
const newer =
|
||||
`---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION + 1}\n---\n\n## Observations\n- future fact\n`;
|
||||
vault.addFile(WORKING, newer);
|
||||
|
||||
// Simulate the capture path after the read guard fired: read → null →
|
||||
// rebuild from scratch → write. The write must be a warned no-op.
|
||||
const rebuilt = emptyWorkingMemory(WORKING, "bot");
|
||||
await store.writeWorkingMemory("bot", rebuilt);
|
||||
|
||||
expect(vault.contents.get(WORKING)).toBe(newer);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still writes normally over a current-schema file", async () => {
|
||||
vault.addFile(
|
||||
WORKING,
|
||||
`---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION}\n---\n\n## Observations\n- old\n`,
|
||||
);
|
||||
const wm = await store.readWorkingMemory("bot");
|
||||
expect(wm).not.toBeNull();
|
||||
await store.writeWorkingMemory("bot", {
|
||||
...wm!,
|
||||
sections: [{ name: "Observations", entries: [{ text: "new fact", pinned: false }] }],
|
||||
});
|
||||
expect(vault.contents.get(WORKING)).toContain("new fact");
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
367
src/repository/memoryStore.ts
Normal file
367
src/repository/memoryStore.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import { join } from "path";
|
||||
import { FileSystemAdapter, TFile, TFolder, normalizePath } from "obsidian";
|
||||
import type { App, Vault } from "obsidian";
|
||||
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
|
||||
import {
|
||||
MEMORY_SCHEMA_VERSION,
|
||||
migrateLegacyBody,
|
||||
parseWorkingMemory,
|
||||
renderSections,
|
||||
serializeWorkingMemory,
|
||||
} from "../utils/memoryFormat";
|
||||
import type { MemoryFile, SkillCandidate, WorkingMemory } from "../types";
|
||||
import { createFileIfMissing, ensureFolder, trashPath } from "./shared";
|
||||
|
||||
/**
|
||||
* Agent memory store: v2 folder layout (working.md, raw/ archive, pending/
|
||||
* capture inbox, candidates.json), the legacy flat-file layout, and the
|
||||
* legacy→v2 migration — including the in-flight migration promise map.
|
||||
* Extracted verbatim from FleetRepository.
|
||||
*/
|
||||
export class MemoryStore {
|
||||
/** In-flight legacy→v2 memory migrations keyed by agent name, so the
|
||||
* unlocked reflection path and the lock-held capture path share a single
|
||||
* migration: concurrent callers await the same promise instead of either
|
||||
* double-seeding raw or returning before the migration has finished. */
|
||||
private migratingMemory = new Map<string, Promise<void>>();
|
||||
|
||||
private readonly vault: Vault;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
/** `_fleet/memory` (lazy — the fleet folder is a live setting). */
|
||||
private readonly getMemoryRoot: () => string,
|
||||
) {
|
||||
this.vault = app.vault;
|
||||
}
|
||||
|
||||
/** @deprecated Legacy flat memory file path. New layout uses
|
||||
* {@link getWorkingMemoryPath}. Retained for migration + back-compat. */
|
||||
getMemoryPath(agentName: string): string {
|
||||
return normalizePath(`${this.getMemoryRoot()}/${slugify(agentName)}.md`);
|
||||
}
|
||||
|
||||
/** Folder holding an agent's v2 memory: working.md, candidates.md, raw/. */
|
||||
getMemoryDir(agentName: string): string {
|
||||
return normalizePath(`${this.getMemoryRoot()}/${slugify(agentName)}`);
|
||||
}
|
||||
|
||||
/** Path to the curated, injected working-memory file (§6.2). */
|
||||
getWorkingMemoryPath(agentName: string): string {
|
||||
return normalizePath(`${this.getMemoryDir(agentName)}/working.md`);
|
||||
}
|
||||
|
||||
/** Path to a day's append-only raw archive (§6.3). */
|
||||
getRawMemoryPath(agentName: string, dateIso: string): string {
|
||||
const day = dateIso.slice(0, 10); // YYYY-MM-DD
|
||||
return normalizePath(`${this.getMemoryDir(agentName)}/raw/${day}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an agent's working memory as the structured v2 object. Resolves either
|
||||
* layout: the v2 `working.md` if present, otherwise the legacy flat file
|
||||
* migrated in-memory (the first write persists the folder layout). Returns
|
||||
* null when the agent has no memory yet.
|
||||
*/
|
||||
async readWorkingMemory(agentName: string): Promise<WorkingMemory | null> {
|
||||
const workingPath = this.getWorkingMemoryPath(agentName);
|
||||
const workingFile = this.vault.getAbstractFileByPath(workingPath);
|
||||
if (workingFile instanceof TFile) {
|
||||
const content = await this.vault.cachedRead(workingFile);
|
||||
// Schema guard: a working.md written by a NEWER plugin version (or with a
|
||||
// mangled `schema` field) must not be parsed as if it were the current
|
||||
// format — fields could be silently misread. Fail soft the way the other
|
||||
// corrupt-file reads here do (warn + treat as absent); the matching guard
|
||||
// in writeWorkingMemory keeps the file from being clobbered afterwards.
|
||||
if (this.hasIncompatibleSchema(content, workingPath)) return null;
|
||||
return parseWorkingMemory(content, workingPath, agentName);
|
||||
}
|
||||
|
||||
const legacyPath = this.getMemoryPath(agentName);
|
||||
const legacyFile = this.vault.getAbstractFileByPath(legacyPath);
|
||||
if (legacyFile instanceof TFile) {
|
||||
const content = await this.vault.cachedRead(legacyFile);
|
||||
const { body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
return migrateLegacyBody(body, workingPath, agentName);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist working memory to `working.md`, creating the folder layout and
|
||||
* trashing the legacy flat file if one still exists (completes migration).
|
||||
*/
|
||||
async writeWorkingMemory(agentName: string, wm: WorkingMemory): Promise<void> {
|
||||
const workingPath = this.getWorkingMemoryPath(agentName);
|
||||
await ensureFolder(this.vault, this.getMemoryDir(agentName));
|
||||
const content = serializeWorkingMemory(wm);
|
||||
const existing = this.vault.getAbstractFileByPath(workingPath);
|
||||
if (existing instanceof TFile) {
|
||||
// Write-side schema guard (pairs with readWorkingMemory): if the on-disk
|
||||
// file carries a newer/unknown schema, readWorkingMemory returned null and
|
||||
// `wm` was rebuilt from scratch — writing it would DESTROY the newer
|
||||
// file's data. Skip the write instead; captures still land in the
|
||||
// append-only raw archive, so no ground truth is lost.
|
||||
if (this.hasIncompatibleSchema(await this.vault.cachedRead(existing), workingPath)) return;
|
||||
await this.vault.modify(existing, content);
|
||||
} else {
|
||||
await createFileIfMissing(this.vault, workingPath, content);
|
||||
}
|
||||
|
||||
// Complete migration: retire the legacy flat file once v2 exists.
|
||||
const legacyPath = this.getMemoryPath(agentName);
|
||||
if (legacyPath !== workingPath && this.vault.getAbstractFileByPath(legacyPath)) {
|
||||
await trashPath(this.app, legacyPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the file's frontmatter `schema` is unrecognized: newer than this
|
||||
* plugin's {@link MEMORY_SCHEMA_VERSION} or not a finite number at all. A
|
||||
* MISSING field stays compatible — pre-schema files have always parsed as the
|
||||
* current version (parseWorkingMemory defaults it), and changing that would
|
||||
* orphan them. An older-but-known numeric schema also stays compatible
|
||||
* (current behavior: parse it; the value is preserved on write). Warns once
|
||||
* per call so both the read and write guards are loud in the console.
|
||||
*/
|
||||
private hasIncompatibleSchema(content: string, path: string): boolean {
|
||||
const { frontmatter } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
const raw = frontmatter.schema;
|
||||
if (raw === undefined) return false;
|
||||
if (typeof raw === "number" && Number.isFinite(raw) && raw <= MEMORY_SCHEMA_VERSION) return false;
|
||||
console.warn(
|
||||
`Agent Fleet: ${path} has unrecognized memory schema ${JSON.stringify(raw)} ` +
|
||||
`(this plugin supports up to ${MEMORY_SCHEMA_VERSION}) — treating working memory as ` +
|
||||
`unavailable and leaving the file untouched. Update the plugin to use this memory.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration of an agent's legacy flat memory file to the v2 folder
|
||||
* layout. Crucially, the legacy content is SEEDED INTO THE RAW ARCHIVE first,
|
||||
* so "ground truth is never destroyed" (§ design overview, §13.1) holds even
|
||||
* if the first reflection later summarizes the working copy. Without this, a
|
||||
* reflection running before any capture could permanently drop pre-v2 memory.
|
||||
* No-op when already migrated (working.md exists) or there is no legacy file.
|
||||
*/
|
||||
async migrateLegacyMemory(agentName: string): Promise<void> {
|
||||
// A concurrent in-flight migration of the same agent (the reflection path
|
||||
// calls this unlocked while a capture may run it under the MemoryWriter
|
||||
// lock) is awaited rather than skipped — an early return would let the
|
||||
// second caller proceed before the migration had actually completed.
|
||||
const inFlight = this.migratingMemory.get(agentName);
|
||||
if (inFlight) return inFlight;
|
||||
const workingPath = this.getWorkingMemoryPath(agentName);
|
||||
if (this.vault.getAbstractFileByPath(workingPath)) return; // already v2
|
||||
const legacyPath = this.getMemoryPath(agentName);
|
||||
const legacyFile = this.vault.getAbstractFileByPath(legacyPath);
|
||||
if (!(legacyFile instanceof TFile)) return;
|
||||
const migration = (async () => {
|
||||
const content = await this.vault.cachedRead(legacyFile);
|
||||
const { body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// 1. Seed the raw archive with the legacy content (permanent ground truth).
|
||||
const seedLines = body
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/^\s*[-*]\s+/, "").trim())
|
||||
.filter((l) => l.length > 0 && !/^#{1,6}\s/.test(l));
|
||||
const rawLines = [
|
||||
`- ${nowIso} [migrated] Imported ${seedLines.length} legacy memory ` +
|
||||
`entr${seedLines.length === 1 ? "y" : "ies"} (pre-v2).`,
|
||||
...seedLines.map((t) => `- ${nowIso} [migrated] ${t}`),
|
||||
];
|
||||
await this.appendRawMemory(agentName, rawLines, nowIso);
|
||||
|
||||
// 2. Write the v2 working memory; writeWorkingMemory trashes the legacy file.
|
||||
const wm = migrateLegacyBody(body, workingPath, agentName, nowIso);
|
||||
await this.writeWorkingMemory(agentName, wm);
|
||||
})();
|
||||
this.migratingMemory.set(agentName, migration);
|
||||
try {
|
||||
await migration;
|
||||
} finally {
|
||||
this.migratingMemory.delete(agentName);
|
||||
}
|
||||
}
|
||||
|
||||
/** Vault-relative path to the agent's MCP-tool capture inbox directory (§7.5).
|
||||
* Each capture is written as its own JSON file inside it. */
|
||||
getPendingDir(agentName: string): string {
|
||||
return normalizePath(`${this.getMemoryDir(agentName)}/pending`);
|
||||
}
|
||||
|
||||
/** Absolute on-disk path to the pending inbox dir, for the MCP server's env.
|
||||
* Null when the vault is not a local filesystem (e.g. mobile). */
|
||||
getPendingDirAbsolutePath(agentName: string): string | null {
|
||||
const adapter = this.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) return null;
|
||||
return join(adapter.getBasePath(), this.getPendingDir(agentName));
|
||||
}
|
||||
|
||||
/** Ensure the agent's memory folder exists. */
|
||||
async ensureMemoryDir(agentName: string): Promise<void> {
|
||||
await ensureFolder(this.vault, this.getMemoryDir(agentName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the MCP-tool capture inbox: read every JSON file the external server
|
||||
* wrote, delete each one, and return their lines. Goes through the raw vault
|
||||
* ADAPTER (not the TFile cache) because those files are created out-of-process
|
||||
* and may not be in Obsidian's metadata index yet. Reading + deleting whole
|
||||
* files means we never truncate a file mid-append, so a capture landing during
|
||||
* a drain is just picked up by the next drain — no read-then-clear loss.
|
||||
* Caller serializes via the MemoryWriter lock.
|
||||
*/
|
||||
async readAndClearPending(agentName: string): Promise<string[]> {
|
||||
const adapter = this.vault.adapter;
|
||||
const dir = this.getPendingDir(agentName);
|
||||
let files: string[];
|
||||
try {
|
||||
if (!(await adapter.exists(dir))) return [];
|
||||
files = (await adapter.list(dir)).files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const lines: string[] = [];
|
||||
for (const filePath of files) {
|
||||
if (!filePath.endsWith(".json")) continue; // ignore .tmp partials
|
||||
try {
|
||||
const content = await adapter.read(filePath);
|
||||
// Remove BEFORE folding the lines in: if remove() fails we skip this
|
||||
// file (retried next drain) rather than folding it now and re-folding
|
||||
// the same capture next time — which would duplicate raw-archive lines.
|
||||
await adapter.remove(filePath);
|
||||
for (const l of content.split("\n")) if (l.trim().length > 0) lines.push(l);
|
||||
} catch (err) {
|
||||
// skip an unreadable / not-yet-removable file (picked up next drain)
|
||||
console.warn(`Agent Fleet: skipping pending capture file ${filePath} (retried next drain)`, err);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Read the recent raw archive (last `days` daily files) for reflection. */
|
||||
async readRecentRaw(agentName: string, days = 2): Promise<string> {
|
||||
const dir = normalizePath(`${this.getMemoryDir(agentName)}/raw`);
|
||||
const folder = this.vault.getAbstractFileByPath(dir);
|
||||
if (!(folder instanceof TFolder)) return "";
|
||||
const files = folder.children
|
||||
.filter((c): c is TFile => c instanceof TFile && c.extension === "md")
|
||||
.sort((a, b) => b.name.localeCompare(a.name))
|
||||
.slice(0, days);
|
||||
const parts: string[] = [];
|
||||
for (const f of files.reverse()) {
|
||||
parts.push(`### ${f.basename}\n${await this.vault.cachedRead(f)}`);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
/** Path to the agent's skill-candidate ledger. */
|
||||
getCandidatesPath(agentName: string): string {
|
||||
return normalizePath(`${this.getMemoryDir(agentName)}/candidates.json`);
|
||||
}
|
||||
|
||||
async readCandidates(agentName: string): Promise<SkillCandidate[]> {
|
||||
const path = this.getCandidatesPath(agentName);
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (!(file instanceof TFile)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(await this.vault.cachedRead(file)) as unknown;
|
||||
return Array.isArray(parsed) ? (parsed as SkillCandidate[]) : [];
|
||||
} catch (err) {
|
||||
console.warn(`Agent Fleet: invalid JSON in ${path} — treating skill-candidate ledger as empty`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async writeCandidates(agentName: string, candidates: SkillCandidate[]): Promise<void> {
|
||||
const path = this.getCandidatesPath(agentName);
|
||||
await ensureFolder(this.vault, this.getMemoryDir(agentName));
|
||||
const content = JSON.stringify(candidates, null, 2);
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) await this.vault.modify(file, content);
|
||||
else await createFileIfMissing(this.vault, path, content);
|
||||
}
|
||||
|
||||
/** Append timestamped lines to the day's raw archive (§6.3). Ground truth —
|
||||
* append-only, never injected. Caller serializes via the MemoryWriter lock. */
|
||||
async appendRawMemory(agentName: string, lines: string[], dateIso: string): Promise<void> {
|
||||
if (lines.length === 0) return;
|
||||
const path = this.getRawMemoryPath(agentName, dateIso);
|
||||
await ensureFolder(this.vault, path.replace(/\/[^/]+$/, ""));
|
||||
const block = lines.map((l) => l.trimEnd()).join("\n");
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
const existing = await this.vault.cachedRead(file);
|
||||
await this.vault.modify(file, `${existing.trimEnd()}\n${block}\n`);
|
||||
} else {
|
||||
await createFileIfMissing(this.vault, path, `${block}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Back-compat shim. Returns a flat {@link MemoryFile} view,
|
||||
* preferring the v2 working memory (rendered body) and falling back to the
|
||||
* legacy flat file. New code should use {@link readWorkingMemory}.
|
||||
*/
|
||||
async getMemory(agentName: string): Promise<MemoryFile | null> {
|
||||
const wm = await this.readWorkingMemory(agentName);
|
||||
if (wm) {
|
||||
return {
|
||||
filePath: wm.filePath,
|
||||
agent: wm.agent,
|
||||
lastUpdated: wm.lastUpdated,
|
||||
body: renderSections(wm.sections),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Dead code — no remaining callers. Memory capture now flows
|
||||
* through MemoryWriter (per-agent FIFO lock) into the v2 layout. This method
|
||||
* does an UNSERIALIZED read-modify-write of the legacy flat memory file, so
|
||||
* concurrent calls can lose appends; do not resurrect it without routing it
|
||||
* through MemoryWriter's lock.
|
||||
*/
|
||||
async appendMemory(agentName: string, entries: string[]): Promise<void> {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = this.getMemoryPath(agentName);
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
const timestamp = new Date().toISOString();
|
||||
const entryBlock = entries.map((entry) => `- ${entry.trim()}`).join("\n");
|
||||
|
||||
if (file instanceof TFile) {
|
||||
const existing = await this.getMemory(agentName);
|
||||
const body = `${existing?.body.trim() || "## Learned Context"}\n\n${entryBlock}`.trim();
|
||||
await this.vault.modify(
|
||||
file,
|
||||
stringifyMarkdownWithFrontmatter(
|
||||
{
|
||||
agent: agentName,
|
||||
last_updated: timestamp,
|
||||
},
|
||||
body,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await createFileIfMissing(this.vault,
|
||||
path,
|
||||
stringifyMarkdownWithFrontmatter(
|
||||
{
|
||||
agent: agentName,
|
||||
last_updated: timestamp,
|
||||
},
|
||||
`## Learned Context\n\n${entryBlock}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
src/repository/proposalStore.test.ts
Normal file
96
src/repository/proposalStore.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { SkillConfig, SkillProposal } from "../types";
|
||||
import { ProposalStore } from "./proposalStore";
|
||||
import { FakeVault, makeApp } from "./testSupport";
|
||||
|
||||
function proposal(overrides: Partial<SkillProposal>): SkillProposal {
|
||||
return {
|
||||
id: "p1",
|
||||
type: "skill_create",
|
||||
agent: "bot",
|
||||
status: "pending",
|
||||
created: "2026-07-01T00:00:00Z",
|
||||
evidence: [],
|
||||
rationale: "recurring pattern",
|
||||
body: "Do the thing.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProposalStore", () => {
|
||||
let vault: FakeVault;
|
||||
let skills: Map<string, SkillConfig>;
|
||||
let store: ProposalStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vault = new FakeVault();
|
||||
skills = new Map();
|
||||
store = new ProposalStore(makeApp(vault), {
|
||||
getFleetRoot: () => "_fleet",
|
||||
getSkillsDir: () => "_fleet/skills",
|
||||
getSkillByName: (name) => skills.get(name),
|
||||
});
|
||||
});
|
||||
|
||||
it("write → read → setStatus round-trips through the proposal file", async () => {
|
||||
await store.writeProposal(proposal({ id: "p1" }));
|
||||
expect(vault.files.has("_fleet/proposals/p1.md")).toBe(true);
|
||||
|
||||
const read = await store.readProposal("p1");
|
||||
expect(read?.status).toBe("pending");
|
||||
expect(read?.agent).toBe("bot");
|
||||
|
||||
await store.setProposalStatus("p1", "accepted");
|
||||
expect((await store.readProposal("p1"))?.status).toBe("accepted");
|
||||
});
|
||||
|
||||
it("applyProposal (skill_create) uses the de-duplicated filename as the skill name", async () => {
|
||||
vault.addFile("_fleet/skills/my-skill.md", "---\nname: my-skill\n---\n\nExisting\n");
|
||||
|
||||
const path = await store.applyProposal(proposal({ targetSkill: "My Skill" }));
|
||||
|
||||
expect(path).toBe("_fleet/skills/my-skill-2.md");
|
||||
// Frontmatter name matches the de-duplicated file name, not the original.
|
||||
expect(vault.contents.get(path ?? "")).toContain("name: my-skill-2");
|
||||
});
|
||||
|
||||
it("applyProposal (skill_modify) appends an addendum to the target skill", async () => {
|
||||
const file = vault.addFile("_fleet/skills/target.md", "---\nname: target\n---\n\nOriginal body\n");
|
||||
skills.set("target", {
|
||||
filePath: file.path,
|
||||
name: "target",
|
||||
tags: [],
|
||||
body: "Original body",
|
||||
toolsBody: "",
|
||||
referencesBody: "",
|
||||
examplesBody: "",
|
||||
isFolder: false,
|
||||
});
|
||||
|
||||
const path = await store.applyProposal(
|
||||
proposal({ type: "skill_modify", targetSkill: "target", body: "New guidance." }),
|
||||
);
|
||||
|
||||
expect(path).toBe("_fleet/skills/target.md");
|
||||
const content = vault.contents.get("_fleet/skills/target.md") ?? "";
|
||||
expect(content).toContain("Original body");
|
||||
expect(content).toContain("## Proposed update");
|
||||
expect(content).toContain("New guidance.");
|
||||
});
|
||||
|
||||
it("applyProposal (skill_modify) is a no-op when the target skill is missing", async () => {
|
||||
expect(await store.applyProposal(proposal({ type: "skill_modify", targetSkill: "ghost" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteProposal trashes the file; listProposals sorts newest first", async () => {
|
||||
await store.writeProposal(proposal({ id: "old", created: "2026-01-01T00:00:00Z" }));
|
||||
await store.writeProposal(proposal({ id: "new", created: "2026-07-01T00:00:00Z" }));
|
||||
|
||||
const list = await store.listProposals();
|
||||
expect(list.map((p) => p.id)).toEqual(["new", "old"]);
|
||||
|
||||
await store.deleteProposal("old");
|
||||
expect(await store.readProposal("old")).toBeNull();
|
||||
expect((await store.listProposals()).map((p) => p.id)).toEqual(["new"]);
|
||||
});
|
||||
});
|
||||
136
src/repository/proposalStore.ts
Normal file
136
src/repository/proposalStore.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { TFile, TFolder, normalizePath } from "obsidian";
|
||||
import type { App, Vault } from "obsidian";
|
||||
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
|
||||
import type { SkillConfig, SkillProposal } from "../types";
|
||||
import { asString, asStringArray, createFileIfMissing, ensureFolder, getAvailablePath, trashPath } from "./shared";
|
||||
|
||||
/**
|
||||
* Skill proposals (memory → skills, §9): `_fleet/proposals/<id>.md` files.
|
||||
* Extracted verbatim from FleetRepository. `applyProposal` needs the live
|
||||
* skill index and skills directory, injected as callbacks so the store never
|
||||
* holds a stale snapshot.
|
||||
*/
|
||||
export class ProposalStore {
|
||||
private readonly vault: Vault;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly deps: {
|
||||
getFleetRoot: () => string;
|
||||
getSkillsDir: () => string;
|
||||
getSkillByName: (name: string) => SkillConfig | undefined;
|
||||
},
|
||||
) {
|
||||
this.vault = app.vault;
|
||||
}
|
||||
|
||||
getProposalsDir(): string {
|
||||
return normalizePath(`${this.deps.getFleetRoot()}/proposals`);
|
||||
}
|
||||
|
||||
/** List all proposals, newest first. */
|
||||
async listProposals(): Promise<SkillProposal[]> {
|
||||
const folder = this.vault.getAbstractFileByPath(this.getProposalsDir());
|
||||
if (!(folder instanceof TFolder)) return [];
|
||||
const out: SkillProposal[] = [];
|
||||
for (const child of folder.children) {
|
||||
if (!(child instanceof TFile) || child.extension !== "md") continue;
|
||||
const p = await this.readProposal(child.basename);
|
||||
if (p) out.push(p);
|
||||
}
|
||||
out.sort((a, b) => b.created.localeCompare(a.created));
|
||||
return out;
|
||||
}
|
||||
|
||||
async readProposal(id: string): Promise<SkillProposal | null> {
|
||||
const path = normalizePath(`${this.getProposalsDir()}/${id}.md`);
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (!(file instanceof TFile)) return null;
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(
|
||||
await this.vault.cachedRead(file),
|
||||
);
|
||||
const type = frontmatter.type === "skill_modify" ? "skill_modify" : "skill_create";
|
||||
const status =
|
||||
frontmatter.status === "accepted" || frontmatter.status === "rejected"
|
||||
? frontmatter.status
|
||||
: "pending";
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
agent: asString(frontmatter.agent) ?? "",
|
||||
status,
|
||||
created: asString(frontmatter.created) ?? "",
|
||||
targetSkill: asString(frontmatter.target_skill),
|
||||
candidate: asString(frontmatter.candidate),
|
||||
evidence: asStringArray(frontmatter.evidence),
|
||||
rationale: asString(frontmatter.rationale) ?? "",
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
async writeProposal(p: SkillProposal): Promise<void> {
|
||||
await ensureFolder(this.vault, this.getProposalsDir());
|
||||
const path = normalizePath(`${this.getProposalsDir()}/${p.id}.md`);
|
||||
const fm: Record<string, unknown> = {
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
agent: p.agent,
|
||||
status: p.status,
|
||||
created: p.created,
|
||||
target_skill: p.targetSkill || undefined,
|
||||
candidate: p.candidate || undefined,
|
||||
evidence: p.evidence.length ? p.evidence : undefined,
|
||||
rationale: p.rationale || undefined,
|
||||
};
|
||||
const content = stringifyMarkdownWithFrontmatter(fm, p.body || "");
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) await this.vault.modify(file, content);
|
||||
else await createFileIfMissing(this.vault, path, content);
|
||||
}
|
||||
|
||||
async setProposalStatus(id: string, status: SkillProposal["status"]): Promise<void> {
|
||||
const p = await this.readProposal(id);
|
||||
if (!p) return;
|
||||
p.status = status;
|
||||
await this.writeProposal(p);
|
||||
}
|
||||
|
||||
/** Apply an accepted proposal: create the new skill (skill_create) or append
|
||||
* the proposed change to the target skill (skill_modify). Returns the path of
|
||||
* the affected skill, or null on no-op. */
|
||||
async applyProposal(p: SkillProposal): Promise<string | null> {
|
||||
if (p.type === "skill_create") {
|
||||
const name = p.targetSkill || `learned-${slugify(p.rationale).slice(0, 24) || "skill"}`;
|
||||
const path = await getAvailablePath(this.vault, this.deps.getSkillsDir(), slugify(name));
|
||||
// Use the actual (de-duplicated) filename as the skill name so two
|
||||
// proposals for the same pattern can't mint colliding skill names —
|
||||
// getAvailablePath only de-dupes the file path, not the frontmatter name.
|
||||
const finalName = path.split("/").pop()?.replace(/\.md$/, "") || slugify(name);
|
||||
const fm = {
|
||||
name: finalName,
|
||||
description: p.rationale || `Auto-proposed from recurring pattern for ${p.agent}.`,
|
||||
tags: ["proposed"],
|
||||
};
|
||||
await this.vault.create(path, stringifyMarkdownWithFrontmatter(fm, p.body || "Skill instructions go here."));
|
||||
return path;
|
||||
}
|
||||
// skill_modify: append the proposed change to the target skill as an addendum.
|
||||
if (p.targetSkill) {
|
||||
const skill = this.deps.getSkillByName(p.targetSkill);
|
||||
if (skill) {
|
||||
const file = this.vault.getAbstractFileByPath(skill.filePath);
|
||||
if (file instanceof TFile) {
|
||||
const existing = await this.vault.cachedRead(file);
|
||||
const addendum = `\n\n## Proposed update (${new Date().toISOString().slice(0, 10)})\n${p.body}`;
|
||||
await this.vault.modify(file, `${existing.trimEnd()}${addendum}\n`);
|
||||
return skill.filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async deleteProposal(id: string): Promise<void> {
|
||||
await trashPath(this.app, normalizePath(`${this.getProposalsDir()}/${id}.md`));
|
||||
}
|
||||
}
|
||||
245
src/repository/runLogStore.ts
Normal file
245
src/repository/runLogStore.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { TFile, TFolder, normalizePath } from "obsidian";
|
||||
import type { App, Vault } from "obsidian";
|
||||
import { DEFAULT_SETTINGS } from "../constants";
|
||||
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
|
||||
import { splitLines } from "../utils/platform";
|
||||
import type { RunLogData } from "../types";
|
||||
import { asNumber, asString, asStringArray, collectMarkdownChildren, ensureFolder, isRecord } from "./shared";
|
||||
|
||||
/**
|
||||
* Run-log store: reads/writes `_fleet/runs/YYYY-MM-DD/*.md` files and owns
|
||||
* the parsed-run cache. Extracted verbatim from FleetRepository — the facade
|
||||
* delegates its public run-log methods here.
|
||||
*/
|
||||
export class RunLogStore {
|
||||
/** Parsed run-log cache keyed by file path, invalidated on mtime/size
|
||||
* mismatch. `vault.cachedRead` already avoids disk I/O, but re-parsing
|
||||
* every run markdown on each dashboard refresh was still O(runs). Cached
|
||||
* objects are shared — callers must not mutate them (none do today). */
|
||||
private runLogCache = new Map<string, { mtime: number; size: number; run: RunLogData }>();
|
||||
|
||||
private readonly vault: Vault;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly getRunsRoot: () => string,
|
||||
) {
|
||||
this.vault = app.vault;
|
||||
}
|
||||
|
||||
async listRecentRuns(limit = 50): Promise<RunLogData[]> {
|
||||
const runsFolder = this.vault.getAbstractFileByPath(this.getRunsRoot());
|
||||
if (!(runsFolder instanceof TFolder)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: TFile[] = [];
|
||||
collectMarkdownChildren(runsFolder, files);
|
||||
|
||||
files.sort((a, b) => b.path.localeCompare(a.path));
|
||||
const selected = files.slice(0, limit);
|
||||
const parsed: RunLogData[] = [];
|
||||
|
||||
for (const file of selected) {
|
||||
const run = await this.readRunLog(file);
|
||||
if (run) {
|
||||
parsed.push(run);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed.sort((a, b) => b.started.localeCompare(a.started));
|
||||
}
|
||||
|
||||
/** Return all runs whose date folder is on or after `sinceDate`. Used by
|
||||
* the dashboard chart so a busy fleet can't push older days out of the
|
||||
* visible window the way the count-capped `listRecentRuns` does.
|
||||
* Walks only the relevant date subfolders, so cost is bounded by the
|
||||
* window size, not the total number of historical runs. */
|
||||
async listRunsSince(sinceDate: Date): Promise<RunLogData[]> {
|
||||
const runsFolder = this.vault.getAbstractFileByPath(this.getRunsRoot());
|
||||
if (!(runsFolder instanceof TFolder)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sinceStr = `${sinceDate.getFullYear()}-${String(sinceDate.getMonth() + 1).padStart(2, "0")}-${String(sinceDate.getDate()).padStart(2, "0")}`;
|
||||
const files: TFile[] = [];
|
||||
for (const child of runsFolder.children) {
|
||||
if (!(child instanceof TFolder)) continue;
|
||||
// Date folders are named `YYYY-MM-DD`; lexicographic >= matches calendar >=.
|
||||
if (child.name < sinceStr) continue;
|
||||
collectMarkdownChildren(child, files);
|
||||
}
|
||||
|
||||
const parsed: RunLogData[] = [];
|
||||
for (const file of files) {
|
||||
const run = await this.readRunLog(file);
|
||||
if (run) {
|
||||
parsed.push(run);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed.sort((a, b) => b.started.localeCompare(a.started));
|
||||
}
|
||||
|
||||
async readRunLog(file: TFile): Promise<RunLogData | null> {
|
||||
const cached = this.runLogCache.get(file.path);
|
||||
if (cached && cached.mtime === file.stat.mtime && cached.size === file.stat.size) {
|
||||
return cached.run;
|
||||
}
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
// `## Prompt` terminates at either `## Result` (new format) or
|
||||
// `## Output` (legacy format). `## Result` is optional and missing on
|
||||
// runs created before finalResult was extracted — those runs fall back
|
||||
// to rendering `output` in the panel.
|
||||
const promptMatch = body.match(/## Prompt\n([\s\S]*?)(?:\n## Result\n|\n## Output\n|$)/);
|
||||
const resultMatch = body.match(/## Result\n([\s\S]*?)(?:\n## Output\n|$)/);
|
||||
const outputMatch = body.match(/## Output\n([\s\S]*?)(?:\n## Tools Used\n|$)/);
|
||||
const toolsMatch = body.match(/## Tools Used\n([\s\S]*?)(?:\n## STDERR\n|$)/);
|
||||
const run: RunLogData = {
|
||||
filePath: file.path,
|
||||
runId: asString(frontmatter.run_id) ?? file.basename,
|
||||
agent: asString(frontmatter.agent) ?? "unknown",
|
||||
task: asString(frontmatter.task) ?? "unknown",
|
||||
status: (asString(frontmatter.status) as RunLogData["status"]) ?? "failure",
|
||||
started: asString(frontmatter.started) ?? new Date(file.stat.ctime).toISOString(),
|
||||
completed: asString(frontmatter.completed),
|
||||
durationSeconds: asNumber(frontmatter.duration_seconds, 0),
|
||||
tokensUsed: typeof frontmatter.tokens_used === "number" ? frontmatter.tokens_used : undefined,
|
||||
costUsd: typeof frontmatter.cost_usd === "number" ? frontmatter.cost_usd : undefined,
|
||||
model: asString(frontmatter.model) ?? DEFAULT_SETTINGS.defaultModel,
|
||||
modelSource: ((): RunLogData["modelSource"] => {
|
||||
const raw = asString(frontmatter.model_source);
|
||||
if (raw === "task" || raw === "agent" || raw === "settings" || raw === "cli-default") return raw;
|
||||
return undefined;
|
||||
})(),
|
||||
concreteModel: asString(frontmatter.resolved_concrete_model),
|
||||
exitCode: typeof frontmatter.exit_code === "number" ? frontmatter.exit_code : null,
|
||||
tags: asStringArray(frontmatter.tags),
|
||||
prompt: promptMatch?.[1]?.trim() ?? "",
|
||||
output: outputMatch?.[1]?.trim() ?? "",
|
||||
finalResult: resultMatch?.[1]?.trim() || undefined,
|
||||
toolsUsed: toolsMatch?.[1]
|
||||
? splitLines(toolsMatch[1])
|
||||
.map((line) => line.replace(/^- /, "").trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
approvals: this.parseApprovals(frontmatter.approvals),
|
||||
};
|
||||
// Cheap unbounded-growth guard; a full reset just costs one re-parse pass.
|
||||
if (this.runLogCache.size >= 5000) this.runLogCache.clear();
|
||||
this.runLogCache.set(file.path, { mtime: file.stat.mtime, size: file.stat.size, run });
|
||||
return run;
|
||||
}
|
||||
|
||||
async writeRunLog(run: RunLogData): Promise<string> {
|
||||
const started = new Date(run.started);
|
||||
const dateFolder = normalizePath(`${this.getRunsRoot()}/${started.toISOString().slice(0, 10)}`);
|
||||
await ensureFolder(this.vault, dateFolder);
|
||||
const filename = `${started.toISOString().slice(11, 19).replace(/:/g, "")}-${slugify(run.agent)}-${slugify(run.task)}.md`;
|
||||
const path = normalizePath(`${dateFolder}/${filename}`);
|
||||
const content = stringifyMarkdownWithFrontmatter(
|
||||
{
|
||||
run_id: run.runId,
|
||||
agent: run.agent,
|
||||
task: run.task,
|
||||
status: run.status,
|
||||
started: run.started,
|
||||
completed: run.completed,
|
||||
duration_seconds: run.durationSeconds,
|
||||
tokens_used: run.tokensUsed,
|
||||
cost_usd: run.costUsd,
|
||||
model: run.model,
|
||||
model_source: run.modelSource,
|
||||
resolved_concrete_model: run.concreteModel,
|
||||
exit_code: run.exitCode,
|
||||
tags: run.tags,
|
||||
approvals: run.approvals,
|
||||
},
|
||||
[
|
||||
"## Prompt",
|
||||
"",
|
||||
run.prompt.trim(),
|
||||
"",
|
||||
// `## Result` carries the final answer without narration, from the
|
||||
// CLI's `type: "result"` event. Omitted when absent so legacy-format
|
||||
// run files stay identical.
|
||||
...(run.finalResult && run.finalResult.trim()
|
||||
? ["## Result", "", run.finalResult.trim(), ""]
|
||||
: []),
|
||||
"## Output",
|
||||
"",
|
||||
run.output.trim() || "(no output)",
|
||||
"",
|
||||
"## Tools Used",
|
||||
"",
|
||||
...(run.toolsUsed.length > 0 ? run.toolsUsed.map((tool) => `- ${tool}`) : ["- none"]),
|
||||
...(run.stderr ? ["", "## STDERR", "", run.stderr.trim()] : []),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const existing = this.vault.getAbstractFileByPath(path);
|
||||
if (existing instanceof TFile) {
|
||||
await this.vault.modify(existing, content);
|
||||
} else {
|
||||
await this.vault.create(path, content);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
async setApprovalDecision(runPath: string, tool: string, decision: "approved" | "rejected"): Promise<void> {
|
||||
const file = this.vault.getAbstractFileByPath(runPath);
|
||||
if (!(file instanceof TFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
const approvals = (this.parseApprovals(frontmatter.approvals) ?? []).map((approval) =>
|
||||
approval.tool === tool
|
||||
? {
|
||||
...approval,
|
||||
status: decision,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
}
|
||||
: approval,
|
||||
);
|
||||
|
||||
await this.vault.modify(
|
||||
file,
|
||||
stringifyMarkdownWithFrontmatter(
|
||||
{
|
||||
...frontmatter,
|
||||
approvals,
|
||||
},
|
||||
body,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private parseApprovals(value: unknown): RunLogData["approvals"] {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.flatMap((item) => {
|
||||
if (!isRecord(item) || !asString(item.tool)) {
|
||||
return [];
|
||||
}
|
||||
const tool = asString(item.tool);
|
||||
if (!tool) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
tool,
|
||||
command: asString(item.command),
|
||||
reason: asString(item.reason),
|
||||
status: (asString(item.status) as "pending" | "approved" | "rejected") ?? "pending",
|
||||
resolvedAt: asString(item.resolvedAt),
|
||||
note: asString(item.note),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
108
src/repository/shared.ts
Normal file
108
src/repository/shared.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { TFile, TFolder, normalizePath } from "obsidian";
|
||||
import type { App, Vault } from "obsidian";
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Frontmatter coercion helpers — shared by the FleetRepository facade's
|
||||
// entity parsers and the extracted stores. All are tolerant: bad shapes
|
||||
// coerce to a safe fallback instead of throwing.
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function asBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
/** Coerce a frontmatter value into a `Record<string,string>`, dropping any
|
||||
* non-string values. Returns undefined when there's nothing usable so callers
|
||||
* can omit the field entirely. */
|
||||
export function asStringMap(value: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (typeof v === "string") out[k] = v;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Vault helpers — tolerant create/trash/list primitives shared by the
|
||||
// facade and the stores.
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/** Create a folder if it doesn't exist; tolerate a concurrent create. */
|
||||
export async function ensureFolder(vault: Vault, path: string): Promise<void> {
|
||||
if (vault.getAbstractFileByPath(path)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await vault.createFolder(path);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("Folder already exists")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a file only when absent; tolerate a concurrent create. */
|
||||
export async function createFileIfMissing(vault: Vault, path: string, content: string): Promise<void> {
|
||||
if (vault.getAbstractFileByPath(path)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await vault.create(path, content);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("File already exists")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Trash the file/folder at `path` if it exists (no-op otherwise). */
|
||||
export async function trashPath(app: App, path: string): Promise<void> {
|
||||
const file = app.vault.getAbstractFileByPath(path);
|
||||
if (file) {
|
||||
await app.fileManager.trashFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect every markdown TFile under `folder` into `acc`. */
|
||||
export function collectMarkdownChildren(folder: TFolder, acc: TFile[]): void {
|
||||
for (const child of folder.children) {
|
||||
if (child instanceof TFile && child.extension === "md") {
|
||||
acc.push(child);
|
||||
}
|
||||
if (child instanceof TFolder) {
|
||||
collectMarkdownChildren(child, acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** First free `<folder>/<baseName>[-N].md` path (N starts at 2). */
|
||||
export async function getAvailablePath(vault: Vault, folder: string, baseName: string): Promise<string> {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
const suffix = attempt === 0 ? "" : `-${attempt + 1}`;
|
||||
const candidate = normalizePath(`${folder}/${baseName}${suffix}.md`);
|
||||
if (!vault.getAbstractFileByPath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
109
src/repository/testSupport.ts
Normal file
109
src/repository/testSupport.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Test-only fakes for the repository store tests. NOT imported by production
|
||||
* code (esbuild never bundles it). Mirrors the FakeVault pattern used in
|
||||
* src/fleetRepository.test.ts against the `test-support/obsidian.ts` stub.
|
||||
*/
|
||||
import type { App } from "obsidian";
|
||||
import { TFile, TFolder } from "obsidian";
|
||||
|
||||
/** Minimal in-memory vault: enough surface for the store paths under test. */
|
||||
export class FakeVault {
|
||||
files = new Map<string, TFile>();
|
||||
contents = new Map<string, string>();
|
||||
folders = new Map<string, TFolder>();
|
||||
private clock = 1;
|
||||
|
||||
addFile(path: string, content: string): TFile {
|
||||
const existing = this.files.get(path);
|
||||
if (existing) {
|
||||
this.contents.set(path, content);
|
||||
existing.stat.mtime = this.clock++;
|
||||
existing.stat.size = content.length;
|
||||
return existing;
|
||||
}
|
||||
const file = new TFile();
|
||||
file.path = path;
|
||||
const base = path.split("/").pop() ?? "";
|
||||
file.basename = base.replace(/\.[^.]+$/, "");
|
||||
file.extension = base.split(".").pop() ?? "";
|
||||
file.stat = { ctime: this.clock, mtime: this.clock++, size: content.length };
|
||||
this.files.set(path, file);
|
||||
this.contents.set(path, content);
|
||||
this.ensureFolderChain(path.slice(0, path.lastIndexOf("/")));
|
||||
return file;
|
||||
}
|
||||
|
||||
private ensureFolderChain(path: string): void {
|
||||
if (!path || this.folders.has(path)) return;
|
||||
const folder = new TFolder();
|
||||
folder.path = path;
|
||||
this.folders.set(path, folder);
|
||||
const parent = path.slice(0, path.lastIndexOf("/"));
|
||||
if (parent) this.ensureFolderChain(parent);
|
||||
}
|
||||
|
||||
getAbstractFileByPath(path: string): TFile | TFolder | null {
|
||||
const file = this.files.get(path);
|
||||
if (file) return file;
|
||||
const folder = this.folders.get(path);
|
||||
if (!folder) return null;
|
||||
const children: Array<TFile | TFolder> = [];
|
||||
for (const candidate of [...this.folders.values(), ...this.files.values()]) {
|
||||
if (!candidate.path.startsWith(`${path}/`)) continue;
|
||||
if (candidate.path.slice(path.length + 1).includes("/")) continue;
|
||||
children.push(candidate);
|
||||
}
|
||||
folder.children = children;
|
||||
return folder;
|
||||
}
|
||||
|
||||
async cachedRead(file: TFile): Promise<string> {
|
||||
const content = this.contents.get(file.path);
|
||||
if (content === undefined) throw new Error(`no such file: ${file.path}`);
|
||||
return content;
|
||||
}
|
||||
|
||||
async create(path: string, content: string): Promise<TFile> {
|
||||
if (this.files.has(path)) throw new Error("File already exists");
|
||||
return this.addFile(path, content);
|
||||
}
|
||||
|
||||
async createFolder(path: string): Promise<void> {
|
||||
if (this.folders.has(path)) throw new Error("Folder already exists");
|
||||
this.ensureFolderChain(path);
|
||||
}
|
||||
|
||||
async modify(file: TFile, content: string): Promise<void> {
|
||||
this.addFile(file.path, content);
|
||||
}
|
||||
|
||||
getMarkdownFiles(): TFile[] {
|
||||
return [...this.files.values()].filter((f) => f.extension === "md");
|
||||
}
|
||||
|
||||
removeTree(path: string): void {
|
||||
this.files.delete(path);
|
||||
this.contents.delete(path);
|
||||
this.folders.delete(path);
|
||||
for (const p of [...this.files.keys()]) {
|
||||
if (p.startsWith(`${path}/`)) {
|
||||
this.files.delete(p);
|
||||
this.contents.delete(p);
|
||||
}
|
||||
}
|
||||
for (const p of [...this.folders.keys()]) {
|
||||
if (p.startsWith(`${path}/`)) this.folders.delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function makeApp(vault: FakeVault): App {
|
||||
return {
|
||||
vault,
|
||||
fileManager: {
|
||||
trashFile: async (file: TFile | TFolder) => {
|
||||
vault.removeTree(file.path);
|
||||
},
|
||||
},
|
||||
} as unknown as App;
|
||||
}
|
||||
96
src/repository/usageLedger.test.ts
Normal file
96
src/repository/usageLedger.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { App } from "obsidian";
|
||||
import type { UsageRecord } from "../types";
|
||||
import { UsageLedger } from "./usageLedger";
|
||||
|
||||
/** Adapter-level fake — the ledger goes through vault.adapter, not TFiles. */
|
||||
class FakeAdapterVault {
|
||||
data = new Map<string, string>();
|
||||
|
||||
adapter = {
|
||||
exists: async (p: string): Promise<boolean> =>
|
||||
this.data.has(p) || [...this.data.keys()].some((k) => k.startsWith(`${p}/`)),
|
||||
list: async (p: string) => ({
|
||||
files: [...this.data.keys()].filter(
|
||||
(k) => k.startsWith(`${p}/`) && !k.slice(p.length + 1).includes("/"),
|
||||
),
|
||||
folders: [] as string[],
|
||||
}),
|
||||
read: async (p: string): Promise<string> => {
|
||||
const c = this.data.get(p);
|
||||
if (c === undefined) throw new Error(`missing: ${p}`);
|
||||
return c;
|
||||
},
|
||||
write: async (p: string, c: string): Promise<void> => {
|
||||
this.data.set(p, c);
|
||||
},
|
||||
append: async (p: string, c: string): Promise<void> => {
|
||||
this.data.set(p, (this.data.get(p) ?? "") + c);
|
||||
},
|
||||
};
|
||||
|
||||
getAbstractFileByPath(_path: string): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async createFolder(_path: string): Promise<void> {
|
||||
/* folders are implicit in the adapter fake */
|
||||
}
|
||||
}
|
||||
|
||||
function record(ts: string, agent = "bot"): UsageRecord {
|
||||
return { ts, agent, source: "chat", tokensUsed: 10 } as unknown as UsageRecord;
|
||||
}
|
||||
|
||||
describe("UsageLedger", () => {
|
||||
let vault: FakeAdapterVault;
|
||||
let ledger: UsageLedger;
|
||||
|
||||
beforeEach(() => {
|
||||
vault = new FakeAdapterVault();
|
||||
ledger = new UsageLedger({ vault } as unknown as App, () => "_fleet/usage");
|
||||
});
|
||||
|
||||
it("appends one JSONL line per record into the day's file", async () => {
|
||||
await ledger.appendUsage(record("2026-07-01T10:00:00Z"));
|
||||
await ledger.appendUsage(record("2026-07-01T11:00:00Z"));
|
||||
await ledger.appendUsage(record("2026-07-02T09:00:00Z"));
|
||||
|
||||
const day1 = vault.data.get("_fleet/usage/2026-07-01.jsonl") ?? "";
|
||||
expect(day1.trim().split("\n")).toHaveLength(2);
|
||||
expect(vault.data.has("_fleet/usage/2026-07-02.jsonl")).toBe(true);
|
||||
});
|
||||
|
||||
it("readUsageSince filters by ledger file date and skips corrupt lines", async () => {
|
||||
vault.data.set("_fleet/usage/2026-06-01.jsonl", `${JSON.stringify(record("2026-06-01T00:00:00Z"))}\n`);
|
||||
vault.data.set(
|
||||
"_fleet/usage/2026-07-01.jsonl",
|
||||
`${JSON.stringify(record("2026-07-01T00:00:00Z"))}\n{corrupt\n${JSON.stringify(record("2026-07-01T01:00:00Z"))}\n`,
|
||||
);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const out = await ledger.readUsageSince(new Date("2026-06-15T00:00:00Z"));
|
||||
|
||||
expect(out).toHaveLength(2); // June file excluded, corrupt line skipped
|
||||
expect(out.every((r) => r.ts.startsWith("2026-07-01"))).toBe(true);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("migrateUsageLedgerCosts runs once and is guarded by the marker file", async () => {
|
||||
vault.data.set("_fleet/usage/2026-07-01.jsonl", `${JSON.stringify(record("2026-07-01T00:00:00Z"))}\n`);
|
||||
|
||||
const first = await ledger.migrateUsageLedgerCosts();
|
||||
expect(first).not.toBeNull();
|
||||
expect(first?.rows).toBe(1);
|
||||
expect(vault.data.has("_fleet/usage/.cost-delta-v1")).toBe(true);
|
||||
|
||||
const second = await ledger.migrateUsageLedgerCosts();
|
||||
expect(second).toBeNull(); // marker guard
|
||||
});
|
||||
|
||||
it("migrateUsageLedgerCosts is a no-op when the usage dir doesn't exist", async () => {
|
||||
expect(await ledger.migrateUsageLedgerCosts()).toBeNull();
|
||||
expect(vault.data.size).toBe(0);
|
||||
});
|
||||
});
|
||||
132
src/repository/usageLedger.ts
Normal file
132
src/repository/usageLedger.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { normalizePath } from "obsidian";
|
||||
import type { App, Vault } from "obsidian";
|
||||
import { deltaizeCumulativeCosts } from "../utils/usageMigration";
|
||||
import type { UsageRecord } from "../types";
|
||||
import { ensureFolder } from "./shared";
|
||||
|
||||
/**
|
||||
* Usage ledger: append-only chat/channel token+cost records in
|
||||
* `_fleet/usage/YYYY-MM-DD.jsonl`, plus the one-time cumulative-cost repair
|
||||
* migration. Extracted verbatim from FleetRepository.
|
||||
*/
|
||||
export class UsageLedger {
|
||||
private readonly vault: Vault;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly getUsageDir: () => string,
|
||||
) {
|
||||
this.vault = app.vault;
|
||||
}
|
||||
|
||||
private usageLedgerPath(ts: string): string {
|
||||
return normalizePath(`${this.getUsageDir()}/${ts.slice(0, 10)}.jsonl`);
|
||||
}
|
||||
|
||||
/** Append one usage record to the day's JSONL ledger (one line per turn).
|
||||
* Uses the raw adapter so it doesn't go through the markdown pipeline. */
|
||||
async appendUsage(record: UsageRecord): Promise<void> {
|
||||
await ensureFolder(this.vault, this.getUsageDir());
|
||||
const path = this.usageLedgerPath(record.ts);
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
const adapter = this.vault.adapter;
|
||||
if (await adapter.exists(path)) {
|
||||
await adapter.append(path, line);
|
||||
} else {
|
||||
await adapter.write(path, line);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read all usage records on or after `sinceDate` (by ledger file date). */
|
||||
async readUsageSince(sinceDate: Date): Promise<UsageRecord[]> {
|
||||
const dir = this.getUsageDir();
|
||||
const adapter = this.vault.adapter;
|
||||
if (!(await adapter.exists(dir))) return [];
|
||||
const sinceStr = `${sinceDate.getFullYear()}-${String(sinceDate.getMonth() + 1).padStart(2, "0")}-${String(sinceDate.getDate()).padStart(2, "0")}`;
|
||||
const out: UsageRecord[] = [];
|
||||
const listing = await adapter.list(dir);
|
||||
for (const filePath of listing.files) {
|
||||
if (!filePath.endsWith(".jsonl")) continue;
|
||||
const base = (filePath.split("/").pop() ?? "").replace(/\.jsonl$/, "");
|
||||
// Files are named YYYY-MM-DD; lexicographic >= matches calendar >=.
|
||||
if (base < sinceStr) continue;
|
||||
let content: string;
|
||||
try {
|
||||
content = await adapter.read(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const raw of content.split("\n")) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
out.push(JSON.parse(trimmed) as UsageRecord);
|
||||
} catch (err) {
|
||||
// skip a corrupt line rather than failing the whole read
|
||||
console.warn(`Agent Fleet: skipping corrupt usage record in ${filePath}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time repair of historical usage rows that stored Claude's CUMULATIVE
|
||||
* `total_cost_usd` as a per-turn cost (see `deltaizeCumulativeCosts`). Reads
|
||||
* every `*.jsonl` ledger file, reconstructs per-turn costs per agent across
|
||||
* the whole ledger (a process can't be assumed to stay within one day), and
|
||||
* rewrites each file in place. Guarded by a marker file so it runs at most
|
||||
* once; idempotent and fail-soft (any error leaves the ledger untouched).
|
||||
*/
|
||||
async migrateUsageLedgerCosts(): Promise<{ files: number; rows: number; changed: number } | null> {
|
||||
const dir = this.getUsageDir();
|
||||
const adapter = this.vault.adapter;
|
||||
if (!(await adapter.exists(dir))) return null;
|
||||
const marker = normalizePath(`${dir}/.cost-delta-v1`);
|
||||
if (await adapter.exists(marker)) return null;
|
||||
|
||||
try {
|
||||
const listing = await adapter.list(dir);
|
||||
const files: Array<{ path: string; records: UsageRecord[] }> = [];
|
||||
const all: UsageRecord[] = [];
|
||||
for (const filePath of listing.files) {
|
||||
if (!filePath.endsWith(".jsonl")) continue;
|
||||
let content: string;
|
||||
try {
|
||||
content = await adapter.read(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const records: UsageRecord[] = [];
|
||||
for (const raw of content.split("\n")) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
records.push(JSON.parse(trimmed) as UsageRecord);
|
||||
} catch (err) {
|
||||
// skip a corrupt line rather than failing the whole migration
|
||||
console.warn(`Agent Fleet: skipping corrupt usage record in ${filePath} during cost migration`, err);
|
||||
}
|
||||
}
|
||||
files.push({ path: filePath, records });
|
||||
all.push(...records);
|
||||
}
|
||||
|
||||
// Mutates `costUsd` on the same objects held in `files[].records`.
|
||||
const changed = deltaizeCumulativeCosts(all);
|
||||
|
||||
if (changed > 0) {
|
||||
for (const { path, records } of files) {
|
||||
if (records.length === 0) continue;
|
||||
const body = records.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
||||
await adapter.write(path, body);
|
||||
}
|
||||
}
|
||||
await adapter.write(marker, `migrated ${all.length} rows; corrected ${changed}\n`);
|
||||
return { files: files.length, rows: all.length, changed };
|
||||
} catch (err) {
|
||||
console.error("Agent Fleet: usage-ledger cost migration failed (ledger left untouched)", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -224,6 +224,22 @@ export class ChannelManager {
|
|||
await Promise.allSettled(Array.from(this.conversationLocks.values()));
|
||||
}
|
||||
|
||||
// Drain in-flight turns too — they run OUTSIDE the conversation locks
|
||||
// (chained via turnTails), so a mid-stream turn would otherwise race the
|
||||
// hibernation below. Race against a timeout so a stuck turn can't hang
|
||||
// shutdown forever.
|
||||
if (this.turnTails.size > 0) {
|
||||
let timer: number | null = null;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = window.setTimeout(resolve, 10_000);
|
||||
});
|
||||
await Promise.race([
|
||||
Promise.allSettled(Array.from(this.turnTails.values())).then(() => undefined),
|
||||
timeout,
|
||||
]);
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
}
|
||||
|
||||
// Hibernate all sessions (no pending turns — if there are, swallow the rejection).
|
||||
for (const entry of this.sessions.values()) {
|
||||
try {
|
||||
|
|
@ -440,7 +456,10 @@ export class ChannelManager {
|
|||
"_Rate limit exceeded. Please slow down and try again in a few minutes._",
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Agent Fleet: rate-limit reply failed on ${channel.name}`, err);
|
||||
console.warn(
|
||||
`Agent Fleet: rate-limit reply failed on ${channel.name} (user ${msg.externalUserId}, conversation ${msg.conversationId})`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
27
src/services/channels/backoff.ts
Normal file
27
src/services/channels/backoff.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Tiny exponential backoff shared by the channel adapters' reconnect/poll loops.
|
||||
* Delay sequence: base, base*2, base*4, … capped at `cap`; `reset()` returns to
|
||||
* base (adapters call it on a successful connection / poll).
|
||||
*/
|
||||
export class ExponentialBackoff {
|
||||
private readonly base: number;
|
||||
private readonly cap: number;
|
||||
private delayMs: number;
|
||||
|
||||
constructor(base = 1000, cap = 30_000) {
|
||||
this.base = base;
|
||||
this.cap = cap;
|
||||
this.delayMs = base;
|
||||
}
|
||||
|
||||
/** Return the current delay and advance to the next (doubled, capped) one. */
|
||||
nextDelay(): number {
|
||||
const delay = this.delayMs;
|
||||
this.delayMs = Math.min(this.cap, this.delayMs * 2);
|
||||
return delay;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.delayMs = this.base;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ import type {
|
|||
InboundMessage,
|
||||
StatusHandler,
|
||||
} from "./adapter";
|
||||
import { splitText } from "./formatter";
|
||||
import { ExponentialBackoff } from "./backoff";
|
||||
|
||||
/**
|
||||
* Discord adapter using the Gateway (WebSocket) for inbound events and the REST
|
||||
|
|
@ -79,7 +81,7 @@ export class DiscordAdapter implements ChannelAdapter {
|
|||
private ws: WebSocket | null = null;
|
||||
private status: ChannelStatus = "stopped";
|
||||
private stopping = false;
|
||||
private backoffMs = 1000;
|
||||
private readonly backoff = new ExponentialBackoff();
|
||||
private reconnectTimer: number | null = null;
|
||||
|
||||
// Gateway session state (for RESUME).
|
||||
|
|
@ -124,7 +126,7 @@ export class DiscordAdapter implements ChannelAdapter {
|
|||
|
||||
async start(): Promise<void> {
|
||||
this.stopping = false;
|
||||
this.backoffMs = 1000;
|
||||
this.backoff.reset();
|
||||
this.canResume = false;
|
||||
await this.connect();
|
||||
}
|
||||
|
|
@ -411,14 +413,14 @@ export class DiscordAdapter implements ChannelAdapter {
|
|||
this.selfUserId = ready.user?.id ?? null;
|
||||
this.applicationId = ready.application?.id ?? null;
|
||||
this.canResume = true;
|
||||
this.backoffMs = 1000;
|
||||
this.backoff.reset();
|
||||
this.setStatus("connected");
|
||||
void this.registerAgentsCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "RESUMED") {
|
||||
this.backoffMs = 1000;
|
||||
this.backoff.reset();
|
||||
this.setStatus("connected");
|
||||
return;
|
||||
}
|
||||
|
|
@ -485,8 +487,7 @@ export class DiscordAdapter implements ChannelAdapter {
|
|||
private scheduleReconnect(): void {
|
||||
if (this.stopping) return;
|
||||
if (this.reconnectTimer) return;
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = Math.min(30_000, this.backoffMs * 2);
|
||||
const delay = this.backoff.nextDelay();
|
||||
console.warn(`Agent Fleet: Discord channel ${this.config.name} scheduling reconnect in ${delay}ms`);
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
|
|
@ -835,18 +836,3 @@ export function describeDiscordError(rawBody: string | undefined): string {
|
|||
}
|
||||
return raw || "no body";
|
||||
}
|
||||
|
||||
function splitText(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
let cutAt = remaining.lastIndexOf("\n\n", limit);
|
||||
if (cutAt < limit / 2) cutAt = remaining.lastIndexOf("\n", limit);
|
||||
if (cutAt < limit / 2) cutAt = limit;
|
||||
chunks.push(remaining.slice(0, cutAt));
|
||||
remaining = remaining.slice(cutAt).replace(/^\n+/, "");
|
||||
}
|
||||
if (remaining) chunks.push(remaining);
|
||||
return chunks;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,6 +175,28 @@ export function splitForTransport(text: string, limit: number = SLACK_TEXT_LIMIT
|
|||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain chunker for transports that render standard markdown (Discord, Telegram):
|
||||
* prefer a paragraph break (`\n\n`), then a line break (`\n`), then a hard split
|
||||
* at the limit. Unlike `splitForTransport` it is NOT fence-aware — deliberately,
|
||||
* since these transports tolerate a fence broken across messages and the callers
|
||||
* relied on this exact cut behavior before extraction.
|
||||
*/
|
||||
export function splitText(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
let cutAt = remaining.lastIndexOf("\n\n", limit);
|
||||
if (cutAt < limit / 2) cutAt = remaining.lastIndexOf("\n", limit);
|
||||
if (cutAt < limit / 2) cutAt = limit;
|
||||
chunks.push(remaining.slice(0, cutAt));
|
||||
remaining = remaining.slice(cutAt).replace(/^\n+/, "");
|
||||
}
|
||||
if (remaining) chunks.push(remaining);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** Count the number of triple-backtick occurrences in a string. */
|
||||
function countFences(s: string): number {
|
||||
let n = 0;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
StatusHandler,
|
||||
} from "./adapter";
|
||||
import { markdownToMrkdwn } from "./formatter";
|
||||
import { ExponentialBackoff } from "./backoff";
|
||||
|
||||
/**
|
||||
* Slack Socket Mode adapter.
|
||||
|
|
@ -108,7 +109,7 @@ export class SlackAdapter implements ChannelAdapter {
|
|||
private ws: WebSocket | null = null;
|
||||
private status: ChannelStatus = "stopped";
|
||||
private stopping = false;
|
||||
private backoffMs = 1000;
|
||||
private readonly backoff = new ExponentialBackoff();
|
||||
private reconnectTimer: number | null = null;
|
||||
|
||||
private readonly inboundHandlers = new Set<InboundHandler>();
|
||||
|
|
@ -383,7 +384,7 @@ export class SlackAdapter implements ChannelAdapter {
|
|||
|
||||
// `hello` — initial handshake completed; we're ready to receive events.
|
||||
if (envelope.type === "hello") {
|
||||
this.backoffMs = 1000; // reset backoff on a successful connection
|
||||
this.backoff.reset(); // reset backoff on a successful connection
|
||||
this.setStatus("connected");
|
||||
return;
|
||||
}
|
||||
|
|
@ -656,8 +657,7 @@ export class SlackAdapter implements ChannelAdapter {
|
|||
private scheduleReconnect(): void {
|
||||
if (this.stopping) return;
|
||||
if (this.reconnectTimer) return;
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = Math.min(30_000, this.backoffMs * 2);
|
||||
const delay = this.backoff.nextDelay();
|
||||
console.warn(
|
||||
`Agent Fleet: Slack channel ${this.config.name} scheduling reconnect in ${delay}ms`,
|
||||
);
|
||||
|
|
@ -749,10 +749,15 @@ export class SlackAdapter implements ChannelAdapter {
|
|||
console.warn(`Agent Fleet: Slack send queue error for ${channel}`, err);
|
||||
});
|
||||
this.sendQueues.set(channel, wrapped);
|
||||
await next;
|
||||
// GC: if no subsequent send chained onto this channel, the queue is idle
|
||||
if (this.sendQueues.get(channel) === wrapped) {
|
||||
this.sendQueues.delete(channel);
|
||||
try {
|
||||
await next;
|
||||
} finally {
|
||||
// GC: if no subsequent send chained onto this channel, the queue is idle.
|
||||
// Runs even when the send throws so a failed channel doesn't leave a
|
||||
// settled promise behind in sendQueues.
|
||||
if (this.sendQueues.get(channel) === wrapped) {
|
||||
this.sendQueues.delete(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import type {
|
|||
InboundMessage,
|
||||
StatusHandler,
|
||||
} from "./adapter";
|
||||
import { splitText } from "./formatter";
|
||||
import { ExponentialBackoff } from "./backoff";
|
||||
|
||||
/**
|
||||
* Telegram Bot API adapter using long-polling (getUpdates).
|
||||
|
|
@ -39,7 +41,7 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||
private stopping = false;
|
||||
private pollOffset = 0;
|
||||
private pollTimer: number | null = null;
|
||||
private backoffMs = 1000;
|
||||
private readonly backoff = new ExponentialBackoff();
|
||||
private typingIntervals = new Map<string, number>();
|
||||
|
||||
/** AbortController for the current long-poll request — lets stop() cancel a 30s wait. */
|
||||
|
|
@ -268,7 +270,7 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
this.backoffMs = 1000; // Reset on success
|
||||
this.backoff.reset(); // Reset on success
|
||||
if (this.status !== "connected") this.setStatus("connected");
|
||||
} catch (err) {
|
||||
// Abort errors from stop() are expected — don't log or backoff
|
||||
|
|
@ -278,9 +280,8 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.setStatus(msg.includes("401") || msg.includes("Unauthorized") ? "needs-auth" : "error");
|
||||
}
|
||||
// Backoff
|
||||
await new Promise((r) => window.setTimeout(r, this.backoffMs));
|
||||
this.backoffMs = Math.min(30_000, this.backoffMs * 2);
|
||||
// Backoff — wait the current delay; the next failure waits double.
|
||||
await new Promise((r) => window.setTimeout(r, this.backoff.nextDelay()));
|
||||
}
|
||||
|
||||
// Schedule next poll
|
||||
|
|
@ -594,18 +595,3 @@ function threadIdFromConversationId(conversationId: string): string | undefined
|
|||
if (parts[2] === "topic" && parts[3]) return parts[3];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function splitText(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
let cutAt = remaining.lastIndexOf("\n\n", limit);
|
||||
if (cutAt < limit / 2) cutAt = remaining.lastIndexOf("\n", limit);
|
||||
if (cutAt < limit / 2) cutAt = limit;
|
||||
chunks.push(remaining.slice(0, cutAt));
|
||||
remaining = remaining.slice(cutAt).replace(/^\n+/, "");
|
||||
}
|
||||
if (remaining) chunks.push(remaining);
|
||||
return chunks;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ChatSession } from "./chatSession";
|
||||
import type { AgentConfig, FleetSettings } from "../types";
|
||||
import { ExecutionManager } from "./executionManager";
|
||||
import type { AgentConfig, FleetSettings, SkillConfig, TaskConfig, WorkingMemory } from "../types";
|
||||
import type { FleetRepository } from "../fleetRepository";
|
||||
import { MEMORY_CAPTURE_INSTRUCTION } from "../utils/memoryFormat";
|
||||
|
||||
// ChatSession imports Vault from "obsidian" (type-only, erased at runtime) and
|
||||
// uses TFile/normalizePath which the test stub provides. We don't drive any
|
||||
|
|
@ -171,6 +173,195 @@ describe("ChatSession.buildBasePrompt", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ─── Characterization fixtures for the prompt-parity tests below.
|
||||
// Mirrors the fixtures in executionManager.test.ts (kept separate so the
|
||||
// two test files don't import each other's registered tests). ───
|
||||
|
||||
function makeTask(overrides: Partial<TaskConfig> = {}): TaskConfig {
|
||||
return {
|
||||
filePath: "_fleet/tasks/summarize.md",
|
||||
taskId: "summarize",
|
||||
agent: "test-agent",
|
||||
type: "recurring",
|
||||
priority: "medium",
|
||||
enabled: true,
|
||||
created: "2026-01-01",
|
||||
runCount: 0,
|
||||
catchUp: false,
|
||||
tags: [],
|
||||
body: "Summarize the news.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkill(): SkillConfig {
|
||||
return {
|
||||
filePath: "_fleet/skills/research.md",
|
||||
name: "research",
|
||||
tags: [],
|
||||
body: "Research things thoroughly.",
|
||||
toolsBody: "Use WebSearch.",
|
||||
referencesBody: "See RESEARCH.md.",
|
||||
examplesBody: "Example: find competitors.",
|
||||
isFolder: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWorkingMemory(): WorkingMemory {
|
||||
return {
|
||||
filePath: "_fleet/memory/test-agent.md",
|
||||
agent: "test-agent",
|
||||
schema: 2,
|
||||
tokenEstimate: 0,
|
||||
sections: [
|
||||
{ name: "Preferences", entries: [{ text: "Prefers concise answers", pinned: true }] },
|
||||
{
|
||||
name: "Recent",
|
||||
entries: [{ text: "Deploy uses GitHub Actions", pinned: false, source: "run", date: "2026-06-30" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeKeeperAgent(): AgentConfig {
|
||||
return makeAgent({
|
||||
name: "wiki-keeper-acme",
|
||||
filePath: "_fleet/agents/wiki-keeper-acme.md",
|
||||
wikiKeeper: {
|
||||
scopeRoot: "Acme",
|
||||
inboxPath: "wiki/inbox",
|
||||
archivePath: "wiki/archive",
|
||||
failedPath: "wiki/failed",
|
||||
topicsRoot: "wiki/topics",
|
||||
indexPath: "wiki/index.md",
|
||||
logPath: "wiki/log.md",
|
||||
watchedFolders: [],
|
||||
excludePatterns: [],
|
||||
watchedSince: "",
|
||||
fileSubstantiveAnswers: false,
|
||||
obsidianUrlScheme: false,
|
||||
maxTokensPerIngest: 4000,
|
||||
maxTokensPerRefresh: 4000,
|
||||
dedupSimilarityThreshold: 0.8,
|
||||
summaryStaleDays: 30,
|
||||
indexSplitThreshold: 50,
|
||||
stateFile: ".wiki-state.json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The fully-loaded agent used by the byte-exact characterization tests. */
|
||||
function makeFullAgent(): AgentConfig {
|
||||
return makeAgent({
|
||||
skills: ["research"],
|
||||
skillsBody: "Custom agent skill notes.",
|
||||
contextBody: "Working on Project Apollo.",
|
||||
memory: true,
|
||||
wikiReferences: [{ agent: "wiki-keeper-acme" }],
|
||||
});
|
||||
}
|
||||
|
||||
function makeFullRepoStub(): FleetRepository {
|
||||
const skills = [makeSkill()];
|
||||
const agents = [makeKeeperAgent()];
|
||||
const wm = makeWorkingMemory();
|
||||
return {
|
||||
getSkillByName: (name: string) => skills.find((s) => s.name === name),
|
||||
readWorkingMemory: async () => wm,
|
||||
getAgentByName: (name: string) => agents.find((a) => a.name === name),
|
||||
} as unknown as FleetRepository;
|
||||
}
|
||||
|
||||
async function callBuildBasePrompt(session: ChatSession): Promise<string> {
|
||||
return (session as unknown as { buildBasePrompt(): Promise<string> }).buildBasePrompt();
|
||||
}
|
||||
|
||||
// Characterization tests — they capture the CURRENT byte-exact prompt output of
|
||||
// the chat path (and its parity with the one-shot run path) so the shared
|
||||
// prompt-assembly extraction is provably behavior-preserving.
|
||||
describe("ChatSession.buildBasePrompt — characterization / run-path parity", () => {
|
||||
it("base prompt + '## Task' framing is byte-identical to ExecutionManager.buildPrompt for the same agent", async () => {
|
||||
const repo = makeFullRepoStub();
|
||||
const agent = makeFullAgent();
|
||||
|
||||
const session = new ChatSession(agent, makeSettings(), repo, vaultStub);
|
||||
const basePrompt = await callBuildBasePrompt(session);
|
||||
// sendMessage frames the first turn as `${basePrompt}\n\n## Task\n${messageText}`
|
||||
const chatFirstTurn = `${basePrompt}\n\n## Task\nSummarize the news.`;
|
||||
|
||||
const manager = new ExecutionManager(makeSettings(), repo);
|
||||
const runPrompt = await manager.buildPrompt(agent, makeTask({ body: "Summarize the news." }));
|
||||
|
||||
expect(chatFirstTurn).toBe(runPrompt);
|
||||
});
|
||||
|
||||
it("fully-loaded agent with channel context: byte-exact section order (memory → channel → wiki)", async () => {
|
||||
const repo = makeFullRepoStub();
|
||||
const session = new ChatSession(makeFullAgent(), makeSettings(), repo, vaultStub, {
|
||||
channelName: "my-slack",
|
||||
conversationId: "slack:T1:C1:U1",
|
||||
channelContext: "You are being contacted via Slack.",
|
||||
});
|
||||
const prompt = await callBuildBasePrompt(session);
|
||||
|
||||
// The channel-context section sits between memory and wiki access —
|
||||
// this exact ordering is load-bearing (captured pre-refactor).
|
||||
const memorySection =
|
||||
`## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\n` +
|
||||
"## Preferences\n- [pin] Prefers concise answers\n\n" +
|
||||
"## Recent (uncurated)\n- Deploy uses GitHub Actions <!-- src:run 2026-06-30 -->";
|
||||
|
||||
expect(prompt).toContain(
|
||||
`${memorySection}\n\n## Channel Context\nYou are being contacted via Slack.\n\n## Wiki Access\n`,
|
||||
);
|
||||
expect(prompt.startsWith("You are a helpful test agent.\n\n## Skill: research\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("thread mode section comes after wiki access (last section)", async () => {
|
||||
const repo = makeFullRepoStub();
|
||||
const agent = makeFullAgent();
|
||||
const parent = new ChatSession(agent, makeSettings(), repo, vaultStub);
|
||||
parent.messages = [
|
||||
{ id: "m0", role: "user", content: "hi", timestamp: "t0" },
|
||||
{ id: "m1", role: "assistant", content: "hello there", timestamp: "t1" },
|
||||
];
|
||||
const thread = new ChatSession(agent, makeSettings(), repo, vaultStub, {
|
||||
threadAnchorId: "m1",
|
||||
parentSession: parent,
|
||||
});
|
||||
(thread as unknown as { threadAnchorIndex: number }).threadAnchorIndex = 1;
|
||||
|
||||
const prompt = await callBuildBasePrompt(thread);
|
||||
|
||||
const wikiIdx = prompt.indexOf("## Wiki Access");
|
||||
const threadIdx = prompt.indexOf("## Thread Mode");
|
||||
expect(wikiIdx).toBeGreaterThan(-1);
|
||||
expect(threadIdx).toBeGreaterThan(wikiIdx);
|
||||
// Replay content is exact: preamble, then a "## Conversation so far" replay.
|
||||
expect(prompt.endsWith(
|
||||
"## Thread Mode\n" +
|
||||
"You are continuing a side thread from this conversation. The user is " +
|
||||
"following up on one of your earlier replies and wants to explore " +
|
||||
"something specific without adding to the main thread. Your answers " +
|
||||
"here stay in this thread only and will NOT be added back to the " +
|
||||
"main conversation.\n\n" +
|
||||
"## Conversation so far\nUser: hi\nAssistant: hello there",
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("memory-enabled chat agent gets the memory section unconditionally (no chat-side suppression)", async () => {
|
||||
const repo = makeFullRepoStub();
|
||||
const session = new ChatSession(makeAgent({ memory: true }), makeSettings(), repo, vaultStub);
|
||||
const prompt = await callBuildBasePrompt(session);
|
||||
expect(prompt).toBe(
|
||||
"You are a helpful test agent.\n\n" +
|
||||
`## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\n` +
|
||||
"## Preferences\n- [pin] Prefers concise answers\n\n" +
|
||||
"## Recent (uncurated)\n- Deploy uses GitHub Actions <!-- src:run 2026-06-30 -->",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatSession.hibernate / clearSessionId", () => {
|
||||
it("hibernate refuses to run while a turn is streaming", () => {
|
||||
const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub);
|
||||
|
|
@ -450,6 +641,94 @@ describe("ChatSession.detachProcessListeners", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("ChatSession.handleStdout — partial-line buffer cap", () => {
|
||||
type StdoutInternals = { stdoutBuffer: string; handleStdout(chunk: string): void };
|
||||
|
||||
it("keeps a small incomplete trailing line buffered", () => {
|
||||
const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub);
|
||||
const s = session as unknown as StdoutInternals;
|
||||
s.handleStdout('{"type":"sys');
|
||||
expect(s.stdoutBuffer).toBe('{"type":"sys');
|
||||
});
|
||||
|
||||
it("drops a pathological oversized partial line instead of buffering unboundedly", () => {
|
||||
const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub);
|
||||
const s = session as unknown as StdoutInternals;
|
||||
// One giant chunk with no newline — must not be retained.
|
||||
s.handleStdout("x".repeat(10 * 1024 * 1024 + 1));
|
||||
expect(s.stdoutBuffer).toBe("");
|
||||
// Subsequent well-formed lines still parse (session id is captured).
|
||||
s.handleStdout('{"type":"system","session_id":"s-after-drop"}\n');
|
||||
expect(
|
||||
(session as unknown as { claudeSessionId: string | null }).claudeSessionId,
|
||||
).toBe("s-after-drop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatSession.handleProcessClose — streaming reset between turns", () => {
|
||||
it("resets streaming state even when no turn resolve is pending", () => {
|
||||
const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub);
|
||||
type Internals = {
|
||||
pendingTurns: number;
|
||||
setStreaming(active: boolean): void;
|
||||
handleProcessClose(): void;
|
||||
turnResolve: unknown;
|
||||
};
|
||||
const s = session as unknown as Internals;
|
||||
// Simulate a wedged state: spinner on, no resolver to end the turn.
|
||||
s.setStreaming(true);
|
||||
s.pendingTurns = 1;
|
||||
expect(s.turnResolve).toBeNull();
|
||||
|
||||
s.handleProcessClose();
|
||||
|
||||
expect(session.isStreaming).toBe(false);
|
||||
expect(session.pendingTurnCount).toBe(0);
|
||||
expect(session.isProcessAlive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatSession codex queue — failed follow-up start is reported, not silent", () => {
|
||||
it("emits an error event naming the dropped queued messages when startCodexTurn rejects", async () => {
|
||||
const session = new ChatSession(
|
||||
makeAgent({ adapter: "codex" }),
|
||||
makeSettings(),
|
||||
makeRepositoryStub(),
|
||||
vaultStub,
|
||||
);
|
||||
type Internals = {
|
||||
codexQueue: string[];
|
||||
pendingTurns: number;
|
||||
activeOnEvent: ((ev: { type: string; errorMessage?: string }) => void) | null;
|
||||
startCodexTurn(text: string): Promise<void>;
|
||||
handleTurnEnd(): void;
|
||||
turnReject: ((e: Error) => void) | null;
|
||||
};
|
||||
const s = session as unknown as Internals;
|
||||
const events: Array<{ type: string; errorMessage?: string }> = [];
|
||||
const rejections: Error[] = [];
|
||||
s.activeOnEvent = (ev) => events.push(ev);
|
||||
s.turnReject = (e) => rejections.push(e);
|
||||
s.codexQueue = ["queued follow-up", "second follow-up"];
|
||||
s.pendingTurns = 3;
|
||||
s.startCodexTurn = () => Promise.reject(new Error("spawn ENOENT"));
|
||||
|
||||
s.handleTurnEnd();
|
||||
// Let the startCodexTurn rejection propagate through the catch handler.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const errEvent = events.find((e) => e.type === "error");
|
||||
// Both the shifted message and the one still queued are reported.
|
||||
expect(errEvent?.errorMessage).toMatch(/dropping 2 queued messages/);
|
||||
expect(errEvent?.errorMessage).toContain("spawn ENOENT");
|
||||
// The turn promise still rejects (handleProcessError ran) and the queue
|
||||
// is cleared — no stale entries linger for the next turn.
|
||||
expect(rejections.map((e) => e.message)).toEqual(["spawn ENOENT"]);
|
||||
expect(s.codexQueue).toEqual([]);
|
||||
expect(session.isStreaming).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatSession.refreshAgent — picks up post-construction permission edits", () => {
|
||||
it("swaps in the latest AgentConfig from the repository when invoked", () => {
|
||||
const constructionTime = makeAgent({
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import type { FleetRepository } from "../fleetRepository";
|
|||
import { slugify } from "../utils/markdown";
|
||||
import { resolveModel, shouldPassModelFlag } from "./../utils/modelResolution";
|
||||
import { spawnCli, splitLines } from "../utils/platform";
|
||||
import { buildWikiReferencesContext } from "../utils/wikiReferences";
|
||||
import { buildMemorySection, extractCaptures, redactRememberForDisplay, stripRememberTags } from "../utils/memoryFormat";
|
||||
import { extractCaptures, redactRememberForDisplay, stripRememberTags } from "../utils/memoryFormat";
|
||||
import { MemoryWriter } from "./memoryWriter";
|
||||
import type { McpAuthManager } from "./mcpAuth";
|
||||
import { buildAgentPromptSections } from "./promptAssembly";
|
||||
import {
|
||||
installMcpProjection,
|
||||
resolveProjectedServers,
|
||||
|
|
@ -176,6 +176,11 @@ export class ChatSession {
|
|||
private claudeResumeAttempted = false;
|
||||
private vault: Vault;
|
||||
private stdoutBuffer = "";
|
||||
/** Cap on the buffered partial stdout line. Stream-json is line-delimited,
|
||||
* so the buffer only ever holds one incomplete line — a line this size
|
||||
* means the CLI is emitting something pathological, and buffering it
|
||||
* further would grow memory unboundedly. */
|
||||
private static readonly STDOUT_LINE_CAP = 10 * 1024 * 1024;
|
||||
/** Bound event handlers for the current process — kept so we can removeListener on kill. */
|
||||
private processListeners: {
|
||||
onStdout: (chunk: Buffer | string) => void;
|
||||
|
|
@ -779,6 +784,15 @@ export class ChatSession {
|
|||
this.stdoutBuffer += chunk.toString();
|
||||
const lines = splitLines(this.stdoutBuffer);
|
||||
this.stdoutBuffer = lines.pop() ?? ""; // keep incomplete trailing line
|
||||
if (this.stdoutBuffer.length > ChatSession.STDOUT_LINE_CAP) {
|
||||
// Drop the oversized partial line. When its tail (and newline) finally
|
||||
// arrives, the leftover fragment fails JSON.parse and is skipped, so
|
||||
// line-based parsing of subsequent events continues unharmed.
|
||||
console.warn(
|
||||
`Agent Fleet: chat stdout line exceeded ${ChatSession.STDOUT_LINE_CAP} chars — dropping partial line`,
|
||||
);
|
||||
this.stdoutBuffer = "";
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
|
@ -1151,6 +1165,18 @@ export class ChatSession {
|
|||
if (next !== undefined) {
|
||||
this.armWatchdog();
|
||||
void this.startCodexTurn(next).catch((err: unknown) => {
|
||||
// The dequeued message never reached the CLI and handleProcessError
|
||||
// clears the rest of the queue — report what was dropped so the
|
||||
// failure isn't silent. (Re-queuing and retrying here could loop
|
||||
// forever on a persistent spawn failure, so we drop-and-report.)
|
||||
const dropped = 1 + this.codexQueue.length;
|
||||
this.activeOnEvent?.({
|
||||
type: "error",
|
||||
content: "",
|
||||
errorMessage:
|
||||
`failed to start queued Codex turn — dropping ${dropped} queued ` +
|
||||
`message${dropped === 1 ? "" : "s"}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
this.handleProcessError(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
return;
|
||||
|
|
@ -1205,14 +1231,16 @@ export class ChatSession {
|
|||
this.mcpProjection = null;
|
||||
|
||||
// If a turn was pending, resolve with whatever we accumulated
|
||||
if (this.turnResolve) {
|
||||
const resolve = this.turnResolve;
|
||||
let result: { text: string; toolCalls: ToolCall[] } | null = null;
|
||||
if (resolve) {
|
||||
// Resumed turn that produced nothing → the session is almost certainly
|
||||
// expired/missing. Drop the id so the next turn starts fresh instead of
|
||||
// re-resuming a dead session and staying silent.
|
||||
if (this.claudeResumeAttempted && !this.turnResponseText.trim()) {
|
||||
this.clearSessionId();
|
||||
}
|
||||
const result = { text: this.turnResponseText, toolCalls: [...this.turnToolCalls] };
|
||||
result = { text: this.turnResponseText, toolCalls: [...this.turnToolCalls] };
|
||||
|
||||
if (this.turnResponseText.trim()) {
|
||||
this.messages.push({
|
||||
|
|
@ -1223,19 +1251,22 @@ export class ChatSession {
|
|||
toolCalls: this.turnToolCalls.length > 0 ? [...this.turnToolCalls] : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingTurns = 0;
|
||||
this.turnResponseText = "";
|
||||
this.turnToolCalls = [];
|
||||
this.clearWatchdog();
|
||||
this.setStreaming(false);
|
||||
// Reset streaming state unconditionally — a process closing between turns
|
||||
// (no pending resolve) must still drop the spinner, otherwise isStreaming
|
||||
// wedges at true with no process left to end the turn.
|
||||
this.pendingTurns = 0;
|
||||
this.turnResponseText = "";
|
||||
this.turnToolCalls = [];
|
||||
this.clearWatchdog();
|
||||
this.setStreaming(false);
|
||||
|
||||
if (resolve && result) {
|
||||
void this.persist();
|
||||
|
||||
const resolve = this.turnResolve;
|
||||
this.turnResolve = null;
|
||||
this.turnReject = null;
|
||||
resolve?.(result);
|
||||
resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1447,7 +1478,13 @@ export class ChatSession {
|
|||
proc.stdin!.write(invocation.stdinPayload ?? messageText);
|
||||
proc.stdin!.end();
|
||||
} catch (err) {
|
||||
// Detach BEFORE killing so the kill's close event can't fire
|
||||
// handleCodexProcessClose and double-settle the turn the caller is
|
||||
// about to fail (it would also leak the listeners otherwise).
|
||||
this.detachProcessListeners();
|
||||
try { proc.kill(); } catch { /* ignore */ }
|
||||
this.process = null;
|
||||
this.isProcessAlive = false;
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
|
@ -1628,42 +1665,15 @@ export class ChatSession {
|
|||
* memory entirely for channel-bound agents.
|
||||
*/
|
||||
private async buildBasePrompt(): Promise<string> {
|
||||
const sections: string[] = [this.agent.body.trim()];
|
||||
|
||||
for (const skillName of this.agent.skills) {
|
||||
const skill = this.repository.getSkillByName(skillName);
|
||||
if (skill) {
|
||||
const parts = [skill.body.trim()];
|
||||
if (skill.toolsBody.trim()) parts.push(`### Tools\n${skill.toolsBody.trim()}`);
|
||||
if (skill.referencesBody.trim()) parts.push(`### References\n${skill.referencesBody.trim()}`);
|
||||
if (skill.examplesBody.trim()) parts.push(`### Examples\n${skill.examplesBody.trim()}`);
|
||||
sections.push(`## Skill: ${skill.name}\n${parts.join("\n\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.agent.skillsBody.trim()) {
|
||||
sections.push(`## Agent Skills\n${this.agent.skillsBody.trim()}`);
|
||||
}
|
||||
|
||||
if (this.agent.contextBody.trim()) {
|
||||
sections.push(`## Agent Context\n${this.agent.contextBody.trim()}`);
|
||||
}
|
||||
|
||||
if (this.agent.memory) {
|
||||
const wm = await this.repository.readWorkingMemory(this.agent.name);
|
||||
const memorySection = buildMemorySection(this.agent, wm);
|
||||
if (memorySection) sections.push(memorySection);
|
||||
}
|
||||
|
||||
// Channel context appended LAST so it takes priority over earlier sections
|
||||
// without shadowing the agent's identity.
|
||||
if (this.channelContext && this.channelContext.trim()) {
|
||||
sections.push(`## Channel Context\n${this.channelContext.trim()}`);
|
||||
}
|
||||
|
||||
// Wiki references — consumer-mode access to one or more Wiki Keeper scopes.
|
||||
const wikiContext = buildWikiReferencesContext(this.agent, this.repository);
|
||||
if (wikiContext) sections.push(wikiContext);
|
||||
// Common sections (body/skills/context/memory/wiki) come from the shared
|
||||
// assembly so the chat and one-shot run paths cannot drift. The chat-only
|
||||
// channel context travels as an option because it sits between memory and
|
||||
// wiki access; the thread-mode replay below is appended caller-side since
|
||||
// it is always the final section.
|
||||
const sections = await buildAgentPromptSections(this.repository, this.agent, {
|
||||
memoryActive: this.agent.memory,
|
||||
channelContext: this.channelContext,
|
||||
});
|
||||
|
||||
// Thread mode: append soft-fork preamble + replay parent history up to
|
||||
// and including the anchor message. See CHAT_THREADING_DESIGN.md §4.2.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { extractConcreteModel, extractRememberEntries } from "./executionManager";
|
||||
import { ExecutionManager, extractConcreteModel, extractRememberEntries } from "./executionManager";
|
||||
import type { AgentConfig, FleetSettings, SkillConfig, TaskConfig, WorkingMemory } from "../types";
|
||||
import type { FleetRepository } from "../fleetRepository";
|
||||
import { MEMORY_CAPTURE_INSTRUCTION } from "../utils/memoryFormat";
|
||||
|
||||
describe("execution helpers", () => {
|
||||
it("extracts remember directives", () => {
|
||||
|
|
@ -53,3 +56,304 @@ Middle
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Shared prompt-assembly test fixtures (also used by chatSession.test.ts's
|
||||
// cross-path parity test) ───
|
||||
|
||||
function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
||||
return {
|
||||
filePath: "_fleet/agents/test-agent.md",
|
||||
name: "test-agent",
|
||||
description: "An agent for testing",
|
||||
model: "default",
|
||||
adapter: "claude-code",
|
||||
permissionMode: "bypassPermissions",
|
||||
maxRetries: 1,
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
approvalRequired: [],
|
||||
memory: false,
|
||||
memoryMaxEntries: 100,
|
||||
memoryTokenBudget: 1500,
|
||||
reflection: { enabled: false, schedule: "0 3 * * *", recurrenceThreshold: 3, proposeSkills: false },
|
||||
tags: [],
|
||||
avatar: "",
|
||||
body: "You are a helpful test agent.",
|
||||
contextBody: "",
|
||||
skillsBody: "",
|
||||
env: {},
|
||||
permissionRules: { allow: [], deny: [] },
|
||||
isFolder: false,
|
||||
heartbeatEnabled: false,
|
||||
heartbeatSchedule: "",
|
||||
heartbeatBody: "",
|
||||
heartbeatNotify: true,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSettings(overrides: Partial<FleetSettings> = {}): FleetSettings {
|
||||
return {
|
||||
fleetFolder: "_fleet",
|
||||
claudeCliPath: "claude",
|
||||
codexCliPath: "codex",
|
||||
defaultModel: "default",
|
||||
awsRegion: "us-east-1",
|
||||
maxConcurrentRuns: 2,
|
||||
runLogRetentionDays: 30,
|
||||
catchUpMissedTasks: true,
|
||||
notificationLevel: "all",
|
||||
showStatusBar: true,
|
||||
mcpApiKeys: {},
|
||||
mcpTokens: {},
|
||||
channelCredentials: {},
|
||||
maxConcurrentChannelSessions: 5,
|
||||
channelIdleTimeoutMinutes: 15,
|
||||
channelRateLimitPerConversation: 20,
|
||||
channelRateLimitWindowMinutes: 5,
|
||||
chatWatchdogMinutes: 10,
|
||||
defaultFileHashes: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTask(overrides: Partial<TaskConfig> = {}): TaskConfig {
|
||||
return {
|
||||
filePath: "_fleet/tasks/summarize.md",
|
||||
taskId: "summarize",
|
||||
agent: "test-agent",
|
||||
type: "recurring",
|
||||
priority: "medium",
|
||||
enabled: true,
|
||||
created: "2026-01-01",
|
||||
runCount: 0,
|
||||
catchUp: false,
|
||||
tags: [],
|
||||
body: "Summarize the news.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkill(overrides: Partial<SkillConfig> = {}): SkillConfig {
|
||||
return {
|
||||
filePath: "_fleet/skills/research.md",
|
||||
name: "research",
|
||||
tags: [],
|
||||
body: "Research things thoroughly.",
|
||||
toolsBody: "Use WebSearch.",
|
||||
referencesBody: "See RESEARCH.md.",
|
||||
examplesBody: "Example: find competitors.",
|
||||
isFolder: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWorkingMemory(): WorkingMemory {
|
||||
return {
|
||||
filePath: "_fleet/memory/test-agent.md",
|
||||
agent: "test-agent",
|
||||
schema: 2,
|
||||
tokenEstimate: 0,
|
||||
sections: [
|
||||
{ name: "Preferences", entries: [{ text: "Prefers concise answers", pinned: true }] },
|
||||
{
|
||||
name: "Recent",
|
||||
entries: [{ text: "Deploy uses GitHub Actions", pinned: false, source: "run", date: "2026-06-30" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A wiki-keeper agent that `wikiReferences: [{ agent: "wiki-keeper-acme" }]` resolves to. */
|
||||
function makeKeeperAgent(): AgentConfig {
|
||||
return makeAgent({
|
||||
name: "wiki-keeper-acme",
|
||||
filePath: "_fleet/agents/wiki-keeper-acme.md",
|
||||
wikiKeeper: {
|
||||
scopeRoot: "Acme",
|
||||
inboxPath: "wiki/inbox",
|
||||
archivePath: "wiki/archive",
|
||||
failedPath: "wiki/failed",
|
||||
topicsRoot: "wiki/topics",
|
||||
indexPath: "wiki/index.md",
|
||||
logPath: "wiki/log.md",
|
||||
watchedFolders: [],
|
||||
excludePatterns: [],
|
||||
watchedSince: "",
|
||||
fileSubstantiveAnswers: false,
|
||||
obsidianUrlScheme: false,
|
||||
maxTokensPerIngest: 4000,
|
||||
maxTokensPerRefresh: 4000,
|
||||
dedupSimilarityThreshold: 0.8,
|
||||
summaryStaleDays: 30,
|
||||
indexSplitThreshold: 50,
|
||||
stateFile: ".wiki-state.json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makePromptRepoStub(opts: {
|
||||
skills?: SkillConfig[];
|
||||
workingMemory?: WorkingMemory | null;
|
||||
agents?: AgentConfig[];
|
||||
} = {}): FleetRepository {
|
||||
return {
|
||||
getSkillByName: (name: string) => opts.skills?.find((s) => s.name === name),
|
||||
readWorkingMemory: async () => opts.workingMemory ?? null,
|
||||
getAgentByName: (name: string) => opts.agents?.find((a) => a.name === name),
|
||||
} as unknown as FleetRepository;
|
||||
}
|
||||
|
||||
/** The exact `## Memory` block buildPrompt emits for {@link makeWorkingMemory}. */
|
||||
const EXPECTED_MEMORY_SECTION =
|
||||
`## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\n` +
|
||||
"## Preferences\n- [pin] Prefers concise answers\n\n" +
|
||||
"## Recent (uncurated)\n- Deploy uses GitHub Actions <!-- src:run 2026-06-30 -->";
|
||||
|
||||
/** The exact `## Wiki Access` block for {@link makeKeeperAgent}. */
|
||||
const EXPECTED_WIKI_SECTION = [
|
||||
"## Wiki Access",
|
||||
"You have read access to the following wikis maintained by other agents. " +
|
||||
"Use the `wiki-query` skill in consumer mode when the user asks something " +
|
||||
"a wiki may already cover.",
|
||||
"",
|
||||
"### Wiki: `wiki-keeper-acme`",
|
||||
"- scope root: `Acme`",
|
||||
"- topics: `Acme/wiki/topics/`",
|
||||
"- index: `Acme/wiki/index.md`",
|
||||
"- inbox: `Acme/wiki/inbox/`",
|
||||
"",
|
||||
"### Rules",
|
||||
"- **Cite every factual claim** from a wiki using `[[<topics-path>/<page>]]`. " +
|
||||
"If a claim has no page, say the wiki doesn't cover it — do not fabricate.",
|
||||
"- **When the user shares something durable** that isn't yet in a wiki " +
|
||||
"(a decision, a new entity mention, a competitor change, a meeting outcome), " +
|
||||
"write a short markdown file to the relevant wiki's inbox at " +
|
||||
"`<inbox>/YYYY-MM-DD-<slug>.md` with a one-line note + the source. " +
|
||||
"The wiki keeper files it canonically on its next ingest.",
|
||||
"- **Do NOT write to `<topics-path>/` directly.** The wiki keeper is the " +
|
||||
"canonical curator of topic pages. Use the inbox as your deposit point.",
|
||||
"- **When the question spans multiple wikis**, be explicit about which " +
|
||||
"scope each cited page belongs to.",
|
||||
].join("\n");
|
||||
|
||||
const EXPECTED_SKILL_SECTION =
|
||||
"## Skill: research\nResearch things thoroughly.\n\n" +
|
||||
"### Tools\nUse WebSearch.\n\n" +
|
||||
"### References\nSee RESEARCH.md.\n\n" +
|
||||
"### Examples\nExample: find competitors.";
|
||||
|
||||
/** The fully-loaded agent used by the byte-exact characterization tests. */
|
||||
function makeFullAgent(): AgentConfig {
|
||||
return makeAgent({
|
||||
skills: ["research"],
|
||||
skillsBody: "Custom agent skill notes.",
|
||||
contextBody: "Working on Project Apollo.",
|
||||
memory: true,
|
||||
wikiReferences: [{ agent: "wiki-keeper-acme" }],
|
||||
});
|
||||
}
|
||||
|
||||
function makeFullRepoStub(): FleetRepository {
|
||||
return makePromptRepoStub({
|
||||
skills: [makeSkill()],
|
||||
workingMemory: makeWorkingMemory(),
|
||||
agents: [makeKeeperAgent()],
|
||||
});
|
||||
}
|
||||
|
||||
// Characterization tests — these capture the CURRENT byte-exact prompt output
|
||||
// of the one-shot run path so the shared prompt-assembly extraction is provably
|
||||
// behavior-preserving. Do not "improve" the expected strings; they are the spec.
|
||||
describe("ExecutionManager.buildPrompt — characterization", () => {
|
||||
function makeManager(repo: FleetRepository): ExecutionManager {
|
||||
return new ExecutionManager(makeSettings(), repo);
|
||||
}
|
||||
|
||||
it("assembles body + skill + agent skills + context + memory + wiki + task, byte-exact", async () => {
|
||||
const manager = makeManager(makeFullRepoStub());
|
||||
const prompt = await manager.buildPrompt(makeFullAgent(), makeTask());
|
||||
|
||||
expect(prompt).toBe(
|
||||
[
|
||||
"You are a helpful test agent.",
|
||||
EXPECTED_SKILL_SECTION,
|
||||
"## Agent Skills\nCustom agent skill notes.",
|
||||
"## Agent Context\nWorking on Project Apollo.",
|
||||
EXPECTED_MEMORY_SECTION,
|
||||
EXPECTED_WIKI_SECTION,
|
||||
"## Task\nSummarize the news.",
|
||||
].join("\n\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("minimal agent: just body + task", async () => {
|
||||
const manager = makeManager(makePromptRepoStub());
|
||||
const prompt = await manager.buildPrompt(makeAgent(), makeTask());
|
||||
expect(prompt).toBe("You are a helpful test agent.\n\n## Task\nSummarize the news.");
|
||||
});
|
||||
|
||||
it("promptOverride (heartbeat path) replaces the task body and is trimmed", async () => {
|
||||
const manager = makeManager(makePromptRepoStub());
|
||||
const prompt = await manager.buildPrompt(
|
||||
makeAgent(),
|
||||
makeTask(),
|
||||
" Check all site monitors and report anomalies.\n",
|
||||
);
|
||||
expect(prompt).toBe(
|
||||
"You are a helpful test agent.\n\n## Task\nCheck all site monitors and report anomalies.",
|
||||
);
|
||||
});
|
||||
|
||||
it("memory enabled but no working-memory file yet → fresh-agent placeholder", async () => {
|
||||
const manager = makeManager(makePromptRepoStub({ workingMemory: null }));
|
||||
const prompt = await manager.buildPrompt(makeAgent({ memory: true }), makeTask());
|
||||
expect(prompt).toBe(
|
||||
[
|
||||
"You are a helpful test agent.",
|
||||
`## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\nNothing yet — this is a fresh agent.`,
|
||||
"## Task\nSummarize the news.",
|
||||
].join("\n\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("memoryActive=false (reflection suppression) omits the memory section even for a memory agent", async () => {
|
||||
const manager = makeManager(makePromptRepoStub({ workingMemory: makeWorkingMemory() }));
|
||||
const prompt = await manager.buildPrompt(makeAgent({ memory: true }), makeTask(), undefined, false);
|
||||
expect(prompt).toBe("You are a helpful test agent.\n\n## Task\nSummarize the news.");
|
||||
});
|
||||
|
||||
it("unknown skill names are silently skipped", async () => {
|
||||
const manager = makeManager(makePromptRepoStub({ skills: [makeSkill()] }));
|
||||
const prompt = await manager.buildPrompt(
|
||||
makeAgent({ skills: ["missing-skill", "research"] }),
|
||||
makeTask(),
|
||||
);
|
||||
expect(prompt).toBe(
|
||||
["You are a helpful test agent.", EXPECTED_SKILL_SECTION, "## Task\nSummarize the news."].join("\n\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skill sub-bodies are optional — empty ones drop their heading", async () => {
|
||||
const skill = makeSkill({ toolsBody: "", referencesBody: " ", examplesBody: "" });
|
||||
const manager = makeManager(makePromptRepoStub({ skills: [skill] }));
|
||||
const prompt = await manager.buildPrompt(makeAgent({ skills: ["research"] }), makeTask());
|
||||
expect(prompt).toBe(
|
||||
[
|
||||
"You are a helpful test agent.",
|
||||
"## Skill: research\nResearch things thoroughly.",
|
||||
"## Task\nSummarize the news.",
|
||||
].join("\n\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("empty agent body is filtered out (no leading blank section)", async () => {
|
||||
const manager = makeManager(makePromptRepoStub());
|
||||
const prompt = await manager.buildPrompt(makeAgent({ body: " " }), makeTask());
|
||||
expect(prompt).toBe("## Task\nSummarize the news.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import type { FleetRepository } from "../fleetRepository";
|
|||
import { getAdapter } from "../adapters";
|
||||
import { resolveModel, shouldPassModelFlag } from "../utils/modelResolution";
|
||||
import { spawnCli, splitLines } from "../utils/platform";
|
||||
import { buildWikiReferencesContext } from "../utils/wikiReferences";
|
||||
import { buildMemorySection } from "../utils/memoryFormat";
|
||||
import type { McpAuthManager } from "./mcpAuth";
|
||||
import { buildAgentPromptSections } from "./promptAssembly";
|
||||
import {
|
||||
installMcpProjection,
|
||||
resolveProjectedServers,
|
||||
|
|
@ -18,6 +17,12 @@ import {
|
|||
// Claude Code adapter when execution became adapter-dispatched.
|
||||
export { extractConcreteModel, extractFinalResult } from "../adapters/claudeCodeAdapter";
|
||||
|
||||
// Output caps — a runaway CLI (e.g. a tool looping on huge output) would
|
||||
// otherwise grow these buffers unbounded until the renderer OOMs. Exceeding
|
||||
// either cap kills the process and fails the run, mirroring the timeout path.
|
||||
const MAX_STDOUT_LENGTH = 10 * 1024 * 1024;
|
||||
const MAX_STDERR_LENGTH = 1024 * 1024;
|
||||
|
||||
export function extractRememberEntries(output: string): string[] {
|
||||
const matches = output.matchAll(/\[REMEMBER\]([\s\S]*?)\[\/REMEMBER\]/g);
|
||||
return Array.from(matches)
|
||||
|
|
@ -51,40 +56,10 @@ export class ExecutionManager {
|
|||
promptOverride?: string,
|
||||
memoryActive = agent.memory,
|
||||
): Promise<string> {
|
||||
const sections: string[] = [agent.body.trim()];
|
||||
|
||||
// Shared skills
|
||||
for (const skillName of agent.skills) {
|
||||
const skill = this.repository.getSkillByName(skillName);
|
||||
if (skill) {
|
||||
const parts = [skill.body.trim()];
|
||||
if (skill.toolsBody.trim()) parts.push(`### Tools\n${skill.toolsBody.trim()}`);
|
||||
if (skill.referencesBody.trim()) parts.push(`### References\n${skill.referencesBody.trim()}`);
|
||||
if (skill.examplesBody.trim()) parts.push(`### Examples\n${skill.examplesBody.trim()}`);
|
||||
sections.push(`## Skill: ${skill.name}\n${parts.join("\n\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Agent-specific skills (from SKILLS.md in folder agents)
|
||||
if (agent.skillsBody.trim()) {
|
||||
sections.push(`## Agent Skills\n${agent.skillsBody.trim()}`);
|
||||
}
|
||||
|
||||
// Agent context (from CONTEXT.md in folder agents)
|
||||
if (agent.contextBody.trim()) {
|
||||
sections.push(`## Agent Context\n${agent.contextBody.trim()}`);
|
||||
}
|
||||
|
||||
if (memoryActive) {
|
||||
const wm = await this.repository.readWorkingMemory(agent.name);
|
||||
const memorySection = buildMemorySection(agent, wm);
|
||||
if (memorySection) sections.push(memorySection);
|
||||
}
|
||||
|
||||
// Wiki references — consumer-mode access to one or more Wiki Keeper scopes.
|
||||
const wikiContext = buildWikiReferencesContext(agent, this.repository);
|
||||
if (wikiContext) sections.push(wikiContext);
|
||||
|
||||
// Common sections (body/skills/context/memory/wiki) come from the shared
|
||||
// assembly so the run and chat paths cannot drift; only the `## Task`
|
||||
// framing is run-specific.
|
||||
const sections = await buildAgentPromptSections(this.repository, agent, { memoryActive });
|
||||
sections.push(`## Task\n${(promptOverride ?? task.body).trim()}`);
|
||||
return sections.filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
|
@ -179,6 +154,7 @@ export class ExecutionManager {
|
|||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
let outputLimitError: string | null = null;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
|
|
@ -186,8 +162,14 @@ export class ExecutionManager {
|
|||
}, agent.timeout * 1000);
|
||||
|
||||
proc.stdout!.on("data", (chunk: Buffer | string) => {
|
||||
if (outputLimitError) return; // already killed — drop buffered chunks
|
||||
const text = chunk.toString();
|
||||
stdout += text;
|
||||
if (stdout.length > MAX_STDOUT_LENGTH) {
|
||||
outputLimitError = `Run output exceeded the ${MAX_STDOUT_LENGTH / (1024 * 1024)}MB stdout limit — process killed.`;
|
||||
proc.kill();
|
||||
return;
|
||||
}
|
||||
if (useStreaming && onOutput) {
|
||||
// Parse stream lines for displayable content
|
||||
for (const line of splitLines(text)) {
|
||||
|
|
@ -198,7 +180,12 @@ export class ExecutionManager {
|
|||
});
|
||||
|
||||
proc.stderr!.on("data", (chunk: Buffer | string) => {
|
||||
if (outputLimitError) return;
|
||||
stderr += chunk.toString();
|
||||
if (stderr.length > MAX_STDERR_LENGTH) {
|
||||
outputLimitError = `Run stderr exceeded the ${MAX_STDERR_LENGTH / (1024 * 1024)}MB limit — process killed.`;
|
||||
proc.kill();
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
|
|
@ -210,6 +197,11 @@ export class ExecutionManager {
|
|||
window.clearTimeout(timer);
|
||||
this.runningProcesses.delete(agent.name);
|
||||
|
||||
if (outputLimitError) {
|
||||
reject(new Error(outputLimitError));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = adapter.parseExecOutput(stdout, stderr, useStreaming);
|
||||
|
||||
resolve({
|
||||
|
|
|
|||
238
src/services/fleetRuntime.test.ts
Normal file
238
src/services/fleetRuntime.test.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FleetRepository } from "../fleetRepository";
|
||||
import type { RunLogData } from "../types";
|
||||
import { DEFAULT_SETTINGS } from "../constants";
|
||||
import { FleetRuntime } from "./fleetRuntime";
|
||||
|
||||
// The obsidian test stub doesn't export Notice; provide one that records
|
||||
// messages so notify() behavior can be asserted.
|
||||
const notices = vi.hoisted(() => [] as Array<{ message: string; timeout?: number }>);
|
||||
vi.mock("obsidian", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
Notice: class {
|
||||
constructor(message: string, timeout?: number) {
|
||||
notices.push({ message, timeout });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/** Private members exercised directly (they have no public trigger that
|
||||
* doesn't spawn a CLI). */
|
||||
interface RuntimeInternals {
|
||||
refreshRunCaches(): Promise<void>;
|
||||
emitRunOutput(agentName: string, chunk: string): void;
|
||||
resetRunOutput(agentName: string): void;
|
||||
clearRunOutput(agentName: string): void;
|
||||
notify(run: RunLogData, capturedCount?: number): void;
|
||||
recentRuns: RunLogData[];
|
||||
}
|
||||
|
||||
function makeRun(overrides: Partial<RunLogData> = {}): RunLogData {
|
||||
return {
|
||||
runId: `run-${Math.random().toString(36).slice(2)}`,
|
||||
agent: "scout",
|
||||
task: "task-1",
|
||||
status: "success",
|
||||
started: new Date().toISOString(),
|
||||
completed: new Date().toISOString(),
|
||||
durationSeconds: 1,
|
||||
model: "default",
|
||||
exitCode: 0,
|
||||
tags: [],
|
||||
prompt: "do the thing",
|
||||
output: "All done.",
|
||||
toolsUsed: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(getRuns: () => RunLogData[] = () => []) {
|
||||
const repository = {
|
||||
listRecentRuns: vi.fn(async () => getRuns()),
|
||||
listRunsSince: vi.fn(async () => []),
|
||||
readUsageSince: vi.fn(async () => []),
|
||||
} as unknown as FleetRepository;
|
||||
const runtime = new FleetRuntime(repository, { ...DEFAULT_SETTINGS });
|
||||
const internals = runtime as unknown as RuntimeInternals;
|
||||
return { runtime, internals };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notices.length = 0;
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-01T12:00:00"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("getFleetStatus", () => {
|
||||
it("counts today's runs and pending approvals", async () => {
|
||||
const runs = [
|
||||
makeRun({ started: "2026-07-01T08:00:00" }),
|
||||
makeRun({ started: "2026-07-01T09:30:00" }),
|
||||
makeRun({ started: "2026-06-30T23:59:00" }), // yesterday — excluded
|
||||
makeRun({
|
||||
started: "2026-07-01T10:00:00",
|
||||
status: "pending_approval",
|
||||
approvals: [
|
||||
{ tool: "Bash", status: "pending" },
|
||||
{ tool: "Write", status: "approved" },
|
||||
],
|
||||
}),
|
||||
];
|
||||
const { internals, runtime } = makeRuntime(() => [...runs]);
|
||||
await internals.refreshRunCaches();
|
||||
|
||||
expect(runtime.getFleetStatus()).toEqual({ running: 0, pending: 1, completedToday: 3 });
|
||||
});
|
||||
|
||||
it("caches on run-list identity: in-place mutation is invisible, refresh is picked up", async () => {
|
||||
const data = [makeRun({ started: "2026-07-01T08:00:00" })];
|
||||
const { internals, runtime } = makeRuntime(() => [...data]);
|
||||
await internals.refreshRunCaches();
|
||||
expect(runtime.getFleetStatus().completedToday).toBe(1);
|
||||
|
||||
// Mutating the cached array in place is NOT a supported mutation point
|
||||
// (recentRuns is only ever replaced wholesale by refreshRunCaches) — the
|
||||
// cache deliberately keys on array identity, so this stays at 1.
|
||||
internals.recentRuns.push(makeRun({ started: "2026-07-01T09:00:00" }));
|
||||
expect(runtime.getFleetStatus().completedToday).toBe(1);
|
||||
|
||||
// Recording a run goes through refreshRunCaches → new array → recompute.
|
||||
data.push(makeRun({ started: "2026-07-01T09:00:00" }));
|
||||
await internals.refreshRunCaches();
|
||||
expect(runtime.getFleetStatus().completedToday).toBe(2);
|
||||
});
|
||||
|
||||
it("invalidates the cache when the local day rolls over", async () => {
|
||||
const { internals, runtime } = makeRuntime(() => [makeRun({ started: "2026-07-01T23:00:00" })]);
|
||||
vi.setSystemTime(new Date("2026-07-01T23:30:00"));
|
||||
await internals.refreshRunCaches();
|
||||
expect(runtime.getFleetStatus().completedToday).toBe(1);
|
||||
|
||||
vi.setSystemTime(new Date("2026-07-02T00:01:00"));
|
||||
expect(runtime.getFleetStatus().completedToday).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("run output batching", () => {
|
||||
it("appends the buffer eagerly but flushes listeners at most every 100ms", () => {
|
||||
const { internals, runtime } = makeRuntime();
|
||||
const received: string[] = [];
|
||||
runtime.onRunOutput("scout", (chunk) => received.push(chunk));
|
||||
|
||||
internals.emitRunOutput("scout", "one ");
|
||||
internals.emitRunOutput("scout", "two");
|
||||
|
||||
// Buffer is current immediately; listeners haven't been pinged yet.
|
||||
expect(runtime.getRunOutputBuffer("scout")).toBe("one two");
|
||||
expect(received).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(received).toEqual(["one two"]);
|
||||
|
||||
// A later chunk starts a fresh batch.
|
||||
internals.emitRunOutput("scout", " three");
|
||||
expect(received).toEqual(["one two"]);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(received).toEqual(["one two", " three"]);
|
||||
});
|
||||
|
||||
it("flushes pending chunks on run end before tearing down", () => {
|
||||
const { internals, runtime } = makeRuntime();
|
||||
const received: string[] = [];
|
||||
runtime.onRunOutput("scout", (chunk) => received.push(chunk));
|
||||
|
||||
internals.emitRunOutput("scout", "tail");
|
||||
internals.clearRunOutput("scout");
|
||||
|
||||
expect(received).toEqual(["tail"]);
|
||||
expect(runtime.getRunOutputBuffer("scout")).toBe("");
|
||||
// No dangling timer fires after teardown.
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(received).toEqual(["tail"]);
|
||||
});
|
||||
|
||||
it("does not double-deliver pending chunks to a subscriber that got the buffer snapshot", () => {
|
||||
const { internals, runtime } = makeRuntime();
|
||||
const first: string[] = [];
|
||||
const second: string[] = [];
|
||||
runtime.onRunOutput("scout", (chunk) => first.push(chunk));
|
||||
|
||||
internals.emitRunOutput("scout", "hello");
|
||||
// Subscribing flushes the batch to existing listeners, then hands the new
|
||||
// listener the full buffer — so nothing is delivered twice.
|
||||
runtime.onRunOutput("scout", (chunk) => second.push(chunk));
|
||||
|
||||
expect(first).toEqual(["hello"]);
|
||||
expect(second).toEqual(["hello"]);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(first).toEqual(["hello"]);
|
||||
expect(second).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("resetRunOutput drops stale batches so the next run starts clean", () => {
|
||||
const { internals, runtime } = makeRuntime();
|
||||
const received: string[] = [];
|
||||
runtime.onRunOutput("scout", (chunk) => received.push(chunk));
|
||||
|
||||
internals.emitRunOutput("scout", "stale");
|
||||
internals.resetRunOutput("scout");
|
||||
|
||||
expect(runtime.getRunOutputBuffer("scout")).toBe("");
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(received).toEqual([]);
|
||||
|
||||
internals.emitRunOutput("scout", "fresh");
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(received).toEqual(["fresh"]);
|
||||
});
|
||||
|
||||
it("stops delivering after unsubscribe", () => {
|
||||
const { internals, runtime } = makeRuntime();
|
||||
const received: string[] = [];
|
||||
const unsub = runtime.onRunOutput("scout", (chunk) => received.push(chunk));
|
||||
|
||||
internals.emitRunOutput("scout", "a");
|
||||
vi.advanceTimersByTime(100);
|
||||
unsub();
|
||||
internals.emitRunOutput("scout", "b");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(received).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify memory-capture suffix", () => {
|
||||
it("appends the captured count to the success notice", () => {
|
||||
const { internals } = makeRuntime();
|
||||
internals.notify(makeRun({ output: "Report ready." }), 3);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0]?.message).toBe("✅ scout: Report ready. · captured 3 memory facts");
|
||||
});
|
||||
|
||||
it("uses singular wording for one capture", () => {
|
||||
const { internals } = makeRuntime();
|
||||
internals.notify(makeRun({ output: "Report ready." }), 1);
|
||||
expect(notices[0]?.message).toContain("· captured 1 memory fact");
|
||||
expect(notices[0]?.message).not.toContain("memory facts");
|
||||
});
|
||||
|
||||
it("leaves the notice unchanged when nothing was captured", () => {
|
||||
const { internals } = makeRuntime();
|
||||
internals.notify(makeRun({ output: "Report ready." }));
|
||||
expect(notices[0]?.message).toBe("✅ scout: Report ready.");
|
||||
});
|
||||
|
||||
it("does not append the suffix to failure notices", () => {
|
||||
const { internals } = makeRuntime();
|
||||
internals.notify(makeRun({ status: "failure", exitCode: 1, output: "boom" }), 2);
|
||||
expect(notices[0]?.message).toBe("❌ scout: boom");
|
||||
});
|
||||
});
|
||||
|
|
@ -54,6 +54,21 @@ export class FleetRuntime {
|
|||
private statusChangeListeners = new Set<() => void>();
|
||||
private runOutputListeners = new Map<string, Set<(chunk: string) => void>>();
|
||||
private runOutputBuffers = new Map<string, string>();
|
||||
/** Chunks accumulated since the last listener flush, per agent. Live output
|
||||
* is fanned out to listeners at most every OUTPUT_FLUSH_INTERVAL_MS (plus a
|
||||
* final flush at run end) so large/chatty CLI output doesn't cause a render
|
||||
* per stdout chunk. `runOutputBuffers` is still appended eagerly, so
|
||||
* getRunOutputBuffer() is always current. */
|
||||
private runOutputPending = new Map<string, string>();
|
||||
private runOutputFlushTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private static readonly OUTPUT_FLUSH_INTERVAL_MS = 100;
|
||||
/** Cached expensive parts of getFleetStatus() — it's called on every render.
|
||||
* Keyed on the `recentRuns` array identity (refreshRunCaches() replaces the
|
||||
* array wholesale and nothing mutates it in place, so a changed reference
|
||||
* means runs/approvals changed) plus the local date, so `completedToday`
|
||||
* rolls over at midnight. `running` is recomputed each call (it depends on
|
||||
* runtimeState and is O(#agents)). */
|
||||
private fleetStatusCache: { runs: RunLogData[]; today: string; completedToday: number; pending: number } | null = null;
|
||||
/** Heartbeat cron jobs, keyed by agent name. Separate from task scheduler jobs. */
|
||||
private heartbeatJobs = new Map<string, Cron>();
|
||||
/** Nightly reflection cron jobs, keyed by agent name (§8). */
|
||||
|
|
@ -183,17 +198,22 @@ export class FleetRuntime {
|
|||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
const completedToday = this.recentRuns.filter((run) => {
|
||||
const d = new Date(run.started);
|
||||
const runDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return runDate === today;
|
||||
}).length;
|
||||
let cache = this.fleetStatusCache;
|
||||
if (!cache || cache.runs !== this.recentRuns || cache.today !== today) {
|
||||
const completedToday = this.recentRuns.filter((run) => {
|
||||
const d = new Date(run.started);
|
||||
const runDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return runDate === today;
|
||||
}).length;
|
||||
const pending = this.recentRuns.flatMap((run) => run.approvals ?? []).filter((item) => item.status === "pending").length;
|
||||
cache = { runs: this.recentRuns, today, completedToday, pending };
|
||||
this.fleetStatusCache = cache;
|
||||
}
|
||||
const running = Array.from(this.runtimeState.values()).filter((state) => state.status === "running").length;
|
||||
const pending = this.recentRuns.flatMap((run) => run.approvals ?? []).filter((item) => item.status === "pending").length;
|
||||
return {
|
||||
running,
|
||||
pending,
|
||||
completedToday,
|
||||
pending: cache.pending,
|
||||
completedToday: cache.completedToday,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +223,10 @@ export class FleetRuntime {
|
|||
}
|
||||
|
||||
onRunOutput(agentName: string, callback: (chunk: string) => void): () => void {
|
||||
// Flush any batched chunks to the *existing* listeners first: the snapshot
|
||||
// handed to the new listener below already contains them (the buffer is
|
||||
// appended eagerly), so leaving them pending would deliver them twice.
|
||||
this.flushRunOutput(agentName);
|
||||
let listeners = this.runOutputListeners.get(agentName);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
|
|
@ -220,6 +244,56 @@ export class FleetRuntime {
|
|||
return this.runOutputBuffers.get(agentName) ?? "";
|
||||
}
|
||||
|
||||
/** Record a live output chunk: append to the (always-current) buffer, and
|
||||
* batch listener notification so a chatty CLI doesn't trigger a dashboard
|
||||
* render per stdout chunk. Listeners receive the concatenated pending
|
||||
* chunks at most every OUTPUT_FLUSH_INTERVAL_MS. */
|
||||
private emitRunOutput(agentName: string, chunk: string): void {
|
||||
this.runOutputBuffers.set(agentName, (this.runOutputBuffers.get(agentName) ?? "") + chunk);
|
||||
this.runOutputPending.set(agentName, (this.runOutputPending.get(agentName) ?? "") + chunk);
|
||||
if (!this.runOutputFlushTimers.has(agentName)) {
|
||||
this.runOutputFlushTimers.set(
|
||||
agentName,
|
||||
setTimeout(() => this.flushRunOutput(agentName), FleetRuntime.OUTPUT_FLUSH_INTERVAL_MS),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver batched chunks (concatenated, in order) to listeners now. */
|
||||
private flushRunOutput(agentName: string): void {
|
||||
const timer = this.runOutputFlushTimers.get(agentName);
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
this.runOutputFlushTimers.delete(agentName);
|
||||
}
|
||||
const pending = this.runOutputPending.get(agentName);
|
||||
if (!pending) return;
|
||||
this.runOutputPending.delete(agentName);
|
||||
const listeners = this.runOutputListeners.get(agentName);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) listener(pending);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset live-output state at run start (clears any stale batch/timer). */
|
||||
private resetRunOutput(agentName: string): void {
|
||||
const timer = this.runOutputFlushTimers.get(agentName);
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
this.runOutputFlushTimers.delete(agentName);
|
||||
}
|
||||
this.runOutputPending.delete(agentName);
|
||||
this.runOutputBuffers.set(agentName, "");
|
||||
}
|
||||
|
||||
/** Final flush + teardown of live-output state at run end, so listeners see
|
||||
* the tail of the output before the buffer is dropped. */
|
||||
private clearRunOutput(agentName: string): void {
|
||||
this.flushRunOutput(agentName);
|
||||
this.runOutputBuffers.delete(agentName);
|
||||
this.runOutputListeners.delete(agentName);
|
||||
}
|
||||
|
||||
async handleVaultChange(file: TFile): Promise<void> {
|
||||
await this.repository.loadFile(file);
|
||||
this.snapshot = this.repository.getSnapshot();
|
||||
|
|
@ -353,6 +427,11 @@ export class FleetRuntime {
|
|||
}
|
||||
this.reflectionJobs.clear();
|
||||
this.reflectionsInFlight.clear();
|
||||
for (const [, timer] of this.runOutputFlushTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.runOutputFlushTimers.clear();
|
||||
this.runOutputPending.clear();
|
||||
this.scheduler.shutdown();
|
||||
}
|
||||
|
||||
|
|
@ -401,10 +480,17 @@ export class FleetRuntime {
|
|||
// is at least 10 seconds after registration.
|
||||
if (Date.now() - this.heartbeatRegisteredAt < 10_000) return;
|
||||
|
||||
// Prevent duplicate runs if the previous heartbeat is still in-flight
|
||||
// Prevent duplicate runs if the previous heartbeat is still in-flight.
|
||||
// The check-and-add is synchronous (no await in between), so concurrent
|
||||
// ticks can't both pass the guard.
|
||||
if (this.heartbeatsInFlight.has(agentName)) return;
|
||||
this.heartbeatsInFlight.add(agentName);
|
||||
|
||||
// The guard must outlive enqueue(): the scheduler resolves it when the run
|
||||
// is queued/started, not when it completes. runPendingTask clears the flag
|
||||
// once the heartbeat run actually finishes; clear here only when no run was
|
||||
// enqueued (agent gone/disabled, or enqueue threw).
|
||||
let enqueued = false;
|
||||
try {
|
||||
const agent = this.repository.getAgentByName(agentName);
|
||||
if (!agent || !agent.enabled || !agent.heartbeatBody.trim()) return;
|
||||
|
|
@ -427,8 +513,9 @@ export class FleetRuntime {
|
|||
reason: "heartbeat",
|
||||
promptOverride: agent.heartbeatBody.trim(),
|
||||
});
|
||||
enqueued = true;
|
||||
} finally {
|
||||
this.heartbeatsInFlight.delete(agentName);
|
||||
if (!enqueued) this.heartbeatsInFlight.delete(agentName);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -558,7 +645,7 @@ export class FleetRuntime {
|
|||
currentTaskId: reflectionTaskId,
|
||||
runStarted: started,
|
||||
});
|
||||
this.runOutputBuffers.set(agentName, "");
|
||||
this.resetRunOutput(agentName);
|
||||
this.emitStatusChange();
|
||||
try {
|
||||
// Guarantee the legacy→v2 migration (and its raw-archive seeding) has run
|
||||
|
|
@ -595,14 +682,9 @@ export class FleetRuntime {
|
|||
// not emit new captures or carry the capture instruction). Stream output so
|
||||
// the overview shows live progress.
|
||||
const result = await this.withReflectionSlot(() =>
|
||||
this.executor.execute(agent, task, prompt, (chunk) => {
|
||||
const current = this.runOutputBuffers.get(agentName) ?? "";
|
||||
this.runOutputBuffers.set(agentName, current + chunk);
|
||||
const listeners = this.runOutputListeners.get(agentName);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) listener(chunk);
|
||||
}
|
||||
}, { suppressMemoryCapture: true }),
|
||||
this.executor.execute(agent, task, prompt, (chunk) => this.emitRunOutput(agentName, chunk), {
|
||||
suppressMemoryCapture: true,
|
||||
}),
|
||||
);
|
||||
const parsed = parseReflectionOutput(result.outputText);
|
||||
|
||||
|
|
@ -654,6 +736,9 @@ export class FleetRuntime {
|
|||
currentRunId: result.runId,
|
||||
lastRun: { ...run, filePath: runPath },
|
||||
});
|
||||
// Reflection runs bypass runPendingTask, so surface failures here —
|
||||
// otherwise a broken nightly reflection is invisible outside run logs.
|
||||
if (runStatus === "failure") this.notify(run);
|
||||
return { ok: applied, message };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -683,11 +768,11 @@ export class FleetRuntime {
|
|||
console.warn(`Agent Fleet: failed to write reflection run log for "${agentName}"`, writeErr);
|
||||
}
|
||||
this.runtimeState.set(agentName, { status: "error", lastRun });
|
||||
this.notify(run);
|
||||
return { ok: false, message: `Reflection failed: ${msg}` };
|
||||
} finally {
|
||||
this.reflectionsInFlight.delete(agentName);
|
||||
this.runOutputBuffers.delete(agentName);
|
||||
this.runOutputListeners.delete(agentName);
|
||||
this.clearRunOutput(agentName);
|
||||
this.emitStatusChange();
|
||||
}
|
||||
}
|
||||
|
|
@ -732,27 +817,27 @@ export class FleetRuntime {
|
|||
}
|
||||
|
||||
private async runPendingTask({ task, promptOverride }: PendingRun): Promise<void> {
|
||||
// Release the cron dedup guard once the heartbeat run is truly done — see
|
||||
// runHeartbeat. Manual heartbeat-tagged runs delete a flag that isn't set,
|
||||
// which is harmless.
|
||||
const clearHeartbeatGuard = () => {
|
||||
if (task.tags.includes("heartbeat")) this.heartbeatsInFlight.delete(task.agent);
|
||||
};
|
||||
const agent = this.repository.getAgentByName(task.agent);
|
||||
if (!agent || !agent.enabled) {
|
||||
clearHeartbeatGuard();
|
||||
return;
|
||||
}
|
||||
|
||||
const started = new Date().toISOString();
|
||||
this.runtimeState.set(agent.name, { status: "running", currentTaskId: task.taskId, runStarted: started });
|
||||
this.runOutputBuffers.set(agent.name, "");
|
||||
this.resetRunOutput(agent.name);
|
||||
this.emitStatusChange();
|
||||
|
||||
try {
|
||||
const result = await this.executor.execute(agent, task, promptOverride, (chunk) => {
|
||||
const current = this.runOutputBuffers.get(agent.name) ?? "";
|
||||
this.runOutputBuffers.set(agent.name, current + chunk);
|
||||
const listeners = this.runOutputListeners.get(agent.name);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(chunk);
|
||||
}
|
||||
}
|
||||
});
|
||||
const result = await this.executor.execute(agent, task, promptOverride, (chunk) =>
|
||||
this.emitRunOutput(agent.name, chunk),
|
||||
);
|
||||
const wasAborted = this.consumeAborted(agent.name);
|
||||
const approvals = wasAborted ? [] : this.buildApprovals(agent, result.toolsUsed);
|
||||
const runStatus = wasAborted ? "cancelled" : this.resolveRunStatus(result, approvals);
|
||||
|
|
@ -785,12 +870,14 @@ export class FleetRuntime {
|
|||
runCount: task.runCount + 1,
|
||||
});
|
||||
|
||||
let capturedCount = 0;
|
||||
if (agent.memory) {
|
||||
const isHeartbeat = task.tags.includes("heartbeat");
|
||||
const source = isHeartbeat ? "heartbeat" : `task:${task.taskId}`;
|
||||
const entries = extractCaptures(result.outputText);
|
||||
try {
|
||||
await this.memoryWriter.capture(agent, entries, source, new Date().toISOString());
|
||||
capturedCount = entries.length;
|
||||
} catch (err) {
|
||||
console.warn(`Agent Fleet: failed to append memory for "${agent.name}"`, err);
|
||||
}
|
||||
|
|
@ -833,7 +920,7 @@ export class FleetRuntime {
|
|||
lastRun: { ...run, filePath: runPath },
|
||||
});
|
||||
|
||||
if (!wasAborted) this.notify(run);
|
||||
if (!wasAborted) this.notify(run, capturedCount);
|
||||
} catch (error) {
|
||||
const wasAborted = this.consumeAborted(agent.name);
|
||||
const runStatus = wasAborted ? "cancelled" : "failure";
|
||||
|
|
@ -862,6 +949,7 @@ export class FleetRuntime {
|
|||
});
|
||||
if (!wasAborted) this.notify(run);
|
||||
} finally {
|
||||
clearHeartbeatGuard();
|
||||
// Fold any `remember` MCP-tool captures from this run into memory (§7.5).
|
||||
if (agent.memory) {
|
||||
try {
|
||||
|
|
@ -870,8 +958,7 @@ export class FleetRuntime {
|
|||
console.warn(`Agent Fleet: failed to drain pending memory for "${agent.name}"`, err);
|
||||
}
|
||||
}
|
||||
this.runOutputBuffers.delete(agent.name);
|
||||
this.runOutputListeners.delete(agent.name);
|
||||
this.clearRunOutput(agent.name);
|
||||
this.emitStatusChange();
|
||||
}
|
||||
}
|
||||
|
|
@ -901,7 +988,7 @@ export class FleetRuntime {
|
|||
return result.exitCode === 0 ? "success" : "failure";
|
||||
}
|
||||
|
||||
private notify(run: RunLogData): void {
|
||||
private notify(run: RunLogData, capturedCount = 0): void {
|
||||
if (this.settings.notificationLevel === "none") {
|
||||
return;
|
||||
}
|
||||
|
|
@ -915,9 +1002,12 @@ export class FleetRuntime {
|
|||
.find((l) => l && !l.startsWith("{") && !l.startsWith("[")) ?? "";
|
||||
const preview = firstLine.slice(0, 120) || run.status;
|
||||
|
||||
// Surface memory captures on successful runs so they aren't drained silently.
|
||||
const captureSuffix =
|
||||
capturedCount > 0 ? ` · captured ${capturedCount} memory fact${capturedCount === 1 ? "" : "s"}` : "";
|
||||
const message =
|
||||
run.status === "success"
|
||||
? `✅ ${run.agent}: ${preview}`
|
||||
? `✅ ${run.agent}: ${preview}${captureSuffix}`
|
||||
: run.status === "pending_approval"
|
||||
? `🔵 ${run.agent} needs approval: ${(run.approvals ?? [])[0]?.tool ?? "tool action"}`
|
||||
: `❌ ${run.agent}: ${preview}`;
|
||||
|
|
|
|||
|
|
@ -360,44 +360,55 @@ export class McpManager {
|
|||
let onError: ((err: Error) => void) | null = null;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
||||
if (reqUrl.pathname !== "/callback") {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
// A throw here (bad URL, dead socket on writeHead, …) would otherwise be
|
||||
// an uncaught exception AND leave waitForCode hanging until its timeout —
|
||||
// catch it, settle the promise, and best-effort close the response.
|
||||
try {
|
||||
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
||||
if (reqUrl.pathname !== "/callback") {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const error = reqUrl.searchParams.get("error");
|
||||
if (error) {
|
||||
const desc = reqUrl.searchParams.get("error_description") ?? error;
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
|
||||
+ "<h2>Authorization Failed</h2><p>" + desc + "</p>"
|
||||
+ "<p style='color:#888'>You can close this tab.</p>"
|
||||
+ "</body></html>",
|
||||
);
|
||||
onError?.(new Error(`OAuth denied: ${desc}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const code = reqUrl.searchParams.get("code");
|
||||
const state = reqUrl.searchParams.get("state");
|
||||
if (!code || !state) {
|
||||
res.writeHead(400);
|
||||
res.end("Missing code or state");
|
||||
return;
|
||||
}
|
||||
|
||||
const error = reqUrl.searchParams.get("error");
|
||||
if (error) {
|
||||
const desc = reqUrl.searchParams.get("error_description") ?? error;
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
|
||||
+ "<h2>Authorization Failed</h2><p>" + desc + "</p>"
|
||||
+ "<p style='color:#888'>You can close this tab.</p>"
|
||||
+ "<h2 style='color:#22c55e'>Authenticated!</h2>"
|
||||
+ "<p>You can close this tab and return to Obsidian.</p>"
|
||||
+ "<script>window.setTimeout(()=>window.close(),2000)</script>"
|
||||
+ "</body></html>",
|
||||
);
|
||||
onError?.(new Error(`OAuth denied: ${desc}`));
|
||||
return;
|
||||
onResult?.(code, state);
|
||||
} catch (err) {
|
||||
try {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
} catch { /* ignore */ }
|
||||
onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
|
||||
const code = reqUrl.searchParams.get("code");
|
||||
const state = reqUrl.searchParams.get("state");
|
||||
if (!code || !state) {
|
||||
res.writeHead(400);
|
||||
res.end("Missing code or state");
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
|
||||
+ "<h2 style='color:#22c55e'>Authenticated!</h2>"
|
||||
+ "<p>You can close this tab and return to Obsidian.</p>"
|
||||
+ "<script>window.setTimeout(()=>window.close(),2000)</script>"
|
||||
+ "</body></html>",
|
||||
);
|
||||
onResult?.(code, state);
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
|
|
@ -433,7 +444,13 @@ export class McpManager {
|
|||
reject(err);
|
||||
};
|
||||
}),
|
||||
close: () => { try { server.close(); } catch { /* ignore */ } },
|
||||
close: () => {
|
||||
// Drop lingering keep-alive connections first — server.close() alone
|
||||
// waits for them, which would keep the port bound and break the next
|
||||
// auth attempt with "address already in use".
|
||||
try { server.closeAllConnections(); } catch { /* ignore */ }
|
||||
try { server.close(); } catch { /* ignore */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -512,7 +529,12 @@ export class McpManager {
|
|||
});
|
||||
|
||||
req.on("error", reject);
|
||||
const timer = window.setTimeout(() => { req.destroy(); reject(new Error("OAuth request timed out")); }, 15000);
|
||||
const timer = window.setTimeout(() => {
|
||||
// destroy() can throw on an already-broken socket — never let that
|
||||
// skip the reject or leave the socket lingering.
|
||||
try { req.destroy(); } catch { /* ignore */ }
|
||||
reject(new Error("OAuth request timed out"));
|
||||
}, 15000);
|
||||
req.on("close", () => window.clearTimeout(timer));
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
// write failure returns null so the run proceeds with no fleet MCP rather than
|
||||
// aborting, and one bad server is dropped (logged) without poisoning the rest.
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import type { McpServer, McpTransport } from "../types";
|
||||
|
|
@ -56,9 +57,6 @@ export interface McpProjection {
|
|||
tempFiles: string[];
|
||||
}
|
||||
|
||||
/** Monotonic suffix so concurrent installs in the same cwd never collide. */
|
||||
let projectionSeq = 0;
|
||||
|
||||
/**
|
||||
* Descriptor for the per-run `remember` capture tool, fed through the same
|
||||
* projection pipe as any other stdio server. `AF_PENDING_DIR` / `AF_SOURCE` are
|
||||
|
|
@ -254,7 +252,9 @@ export function installMcpProjection(
|
|||
const claudeDir = join(cwd, ".claude");
|
||||
try {
|
||||
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
||||
const token = `${process.pid}-${Date.now()}-${projectionSeq++}`;
|
||||
// Random token so concurrent installs — even across processes — never
|
||||
// collide. (pid+time+counter could repeat across two plugin processes.)
|
||||
const token = randomUUID();
|
||||
|
||||
// Prepare each server (materialize inline scripts). Drop any that fail so a
|
||||
// single broken definition doesn't take down the whole run.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfig, MemorySection, WorkingMemory } from "../types";
|
||||
import { appendEntries, emptyWorkingMemory } from "../utils/memoryFormat";
|
||||
import { MemoryWriter, type MemoryStore } from "./memoryWriter";
|
||||
|
|
@ -198,6 +198,44 @@ describe("MemoryWriter", () => {
|
|||
expect(store.migrated).toEqual(["Agent A"]);
|
||||
});
|
||||
|
||||
it("a hung store write does not wedge subsequent captures (lock slot times out)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const store = new FakeStore();
|
||||
// First write hangs forever; later writes behave normally.
|
||||
let hangNext = true;
|
||||
const realWrite = store.writeWorkingMemory.bind(store);
|
||||
store.writeWorkingMemory = (name, wm) => {
|
||||
if (hangNext) {
|
||||
hangNext = false;
|
||||
return new Promise<void>(() => {}); // never settles
|
||||
}
|
||||
return realWrite(name, wm);
|
||||
};
|
||||
const w = new MemoryWriter(store);
|
||||
|
||||
// Fire-and-forget, mirroring how ChatSession invokes capture. This one
|
||||
// wedges inside writeWorkingMemory and never settles.
|
||||
void w.capture(agent(), [{ text: "stuck fact" }], "chat", "2026-06-13T10:00:00Z");
|
||||
// Let the first task reach the hung write before queuing the second.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const second = w.capture(agent(), [{ text: "later fact" }], "chat", "2026-06-13T10:00:01Z");
|
||||
// Nothing moves until the stuck task's lock slot times out.
|
||||
await vi.advanceTimersByTimeAsync(9_999);
|
||||
expect(store.entryTexts("Agent A")).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await second;
|
||||
expect(store.entryTexts("Agent A")).toContain("later fact");
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("releasing its lock slot"));
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces the hard cap, spilling oldest entries to raw-only", async () => {
|
||||
const store = new FakeStore();
|
||||
const w = new MemoryWriter(store);
|
||||
|
|
|
|||
|
|
@ -190,18 +190,36 @@ export class MemoryWriter {
|
|||
await this.store.writeWorkingMemory(agent.name, next);
|
||||
}
|
||||
|
||||
/** Per-task timeout for the lock chain — one hung vault write must not
|
||||
* wedge every later capture for the agent forever. */
|
||||
private static readonly LOCK_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Run `fn` after any in-flight memory op for `key` completes. FIFO. */
|
||||
private withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = MemoryWriter.locks.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
// Keep the chain alive even if fn rejects, but don't swallow the caller's error.
|
||||
MemoryWriter.locks.set(
|
||||
key,
|
||||
next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
// The chain link resolves when fn settles OR its timeout fires, so the
|
||||
// chain survives both rejections (without swallowing the caller's error)
|
||||
// and hangs. A timed-out task keeps running detached — it can't be
|
||||
// cancelled — but its lock slot is released so later captures proceed.
|
||||
let release!: () => void;
|
||||
const link = new Promise<void>((resolve) => { release = resolve; });
|
||||
const run = (): Promise<T> => {
|
||||
const timer = setTimeout(() => {
|
||||
console.warn(
|
||||
`Agent Fleet: memory write for "${key}" still pending after ` +
|
||||
`${MemoryWriter.LOCK_TIMEOUT_MS}ms — releasing its lock slot`,
|
||||
);
|
||||
release();
|
||||
}, MemoryWriter.LOCK_TIMEOUT_MS);
|
||||
const result = fn();
|
||||
result.then(
|
||||
() => { clearTimeout(timer); release(); },
|
||||
() => { clearTimeout(timer); release(); },
|
||||
);
|
||||
return result;
|
||||
};
|
||||
const next = prev.then(run, run);
|
||||
MemoryWriter.locks.set(key, link);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
src/services/promptAssembly.ts
Normal file
80
src/services/promptAssembly.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Shared agent-prompt assembly for the one-shot run path (ExecutionManager)
|
||||
// and the chat path (ChatSession). Both paths inject the same core context —
|
||||
// agent body, shared skills, agent-specific skills/context, working memory,
|
||||
// and wiki references — and this module is the single source of truth for
|
||||
// that sequence so the two cannot silently diverge.
|
||||
//
|
||||
// Genuinely path-specific sections stay with their callers:
|
||||
// - ExecutionManager appends the `## Task` section (task body or override).
|
||||
// - ChatSession appends the `## Thread Mode` replay (side threads) and frames
|
||||
// the first user message as the `## Task` section; its chat-only
|
||||
// `## Channel Context` section travels through `opts.channelContext`
|
||||
// because it sits BETWEEN memory and wiki access in the sequence.
|
||||
import type { AgentConfig } from "../types";
|
||||
import type { FleetRepository } from "../fleetRepository";
|
||||
import { buildWikiReferencesContext } from "../utils/wikiReferences";
|
||||
import { buildMemorySection } from "../utils/memoryFormat";
|
||||
|
||||
export interface AgentPromptOptions {
|
||||
/** Whether the `## Memory` section is injected. The run path passes
|
||||
* `agent.memory && !suppressMemoryCapture` (reflection runs must not see —
|
||||
* or be told to capture into — the memory they are consolidating); the chat
|
||||
* path passes `agent.memory` unchanged. */
|
||||
memoryActive: boolean;
|
||||
/** Chat-only: channel instructions (e.g. "you are talking via Slack"),
|
||||
* injected after memory and before wiki access. Omitted/blank = no section. */
|
||||
channelContext?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ordered common prompt sections for an agent. Returns the raw
|
||||
* section array (possibly containing an empty agent body) — callers append
|
||||
* their path-specific sections and then `filter(Boolean).join("\n\n")`.
|
||||
*/
|
||||
export async function buildAgentPromptSections(
|
||||
repository: FleetRepository,
|
||||
agent: AgentConfig,
|
||||
opts: AgentPromptOptions,
|
||||
): Promise<string[]> {
|
||||
const sections: string[] = [agent.body.trim()];
|
||||
|
||||
// Shared skills
|
||||
for (const skillName of agent.skills) {
|
||||
const skill = repository.getSkillByName(skillName);
|
||||
if (skill) {
|
||||
const parts = [skill.body.trim()];
|
||||
if (skill.toolsBody.trim()) parts.push(`### Tools\n${skill.toolsBody.trim()}`);
|
||||
if (skill.referencesBody.trim()) parts.push(`### References\n${skill.referencesBody.trim()}`);
|
||||
if (skill.examplesBody.trim()) parts.push(`### Examples\n${skill.examplesBody.trim()}`);
|
||||
sections.push(`## Skill: ${skill.name}\n${parts.join("\n\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Agent-specific skills (from SKILLS.md in folder agents)
|
||||
if (agent.skillsBody.trim()) {
|
||||
sections.push(`## Agent Skills\n${agent.skillsBody.trim()}`);
|
||||
}
|
||||
|
||||
// Agent context (from CONTEXT.md in folder agents)
|
||||
if (agent.contextBody.trim()) {
|
||||
sections.push(`## Agent Context\n${agent.contextBody.trim()}`);
|
||||
}
|
||||
|
||||
if (opts.memoryActive) {
|
||||
const wm = await repository.readWorkingMemory(agent.name);
|
||||
const memorySection = buildMemorySection(agent, wm);
|
||||
if (memorySection) sections.push(memorySection);
|
||||
}
|
||||
|
||||
// Channel context (chat-only) is appended after the agent's own sections so
|
||||
// it takes priority without shadowing the agent's identity.
|
||||
if (opts.channelContext && opts.channelContext.trim()) {
|
||||
sections.push(`## Channel Context\n${opts.channelContext.trim()}`);
|
||||
}
|
||||
|
||||
// Wiki references — consumer-mode access to one or more Wiki Keeper scopes.
|
||||
const wikiContext = buildWikiReferencesContext(agent, repository);
|
||||
if (wikiContext) sections.push(wikiContext);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
|
@ -117,6 +117,7 @@ function handle(req) {
|
|||
}
|
||||
`;
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { normalizeAdapter } from "../adapters";
|
||||
|
|
@ -134,17 +135,15 @@ export interface RememberToolInstall {
|
|||
tempFiles: string[];
|
||||
}
|
||||
|
||||
/** Monotonic suffix so concurrent installs in the same cwd never collide. */
|
||||
let installSeq = 0;
|
||||
|
||||
/**
|
||||
* Write the per-run temp MCP server script + config into `<cwd>/.claude` and
|
||||
* return the config path (for `--mcp-config`) plus the temp files to clean up.
|
||||
* Returns null when there is no absolute pending dir (e.g. mobile vault).
|
||||
*
|
||||
* Filenames are made UNIQUE per install (pid + time + counter) so two agents or
|
||||
* a task+chat running in the same cwd (the default vault root) can't clobber
|
||||
* each other's config or have one run's cleanup delete a peer's live files.
|
||||
* Filenames are made UNIQUE per install (random UUID) so two agents or a
|
||||
* task+chat running in the same cwd (the default vault root) — even across
|
||||
* processes — can't clobber each other's config or have one run's cleanup
|
||||
* delete a peer's live files.
|
||||
*/
|
||||
export function installRememberTool(
|
||||
cwd: string,
|
||||
|
|
@ -158,7 +157,7 @@ export function installRememberTool(
|
|||
try {
|
||||
const claudeDir = join(cwd, ".claude");
|
||||
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
||||
const token = `${process.pid}-${Date.now()}-${installSeq++}`;
|
||||
const token = randomUUID();
|
||||
const scriptPath = join(claudeDir, `af-remember-mcp.${token}.cjs`);
|
||||
const configPath = join(claudeDir, `af-remember-mcp.${token}.json`);
|
||||
writeFileSync(scriptPath, REMEMBER_MCP_SERVER_SOURCE, "utf-8");
|
||||
|
|
|
|||
|
|
@ -89,16 +89,34 @@ export function writeClaudeSettingsFile(
|
|||
}
|
||||
|
||||
/** Best-effort cleanup. Restores the user's pre-existing settings.local.json
|
||||
* if one was backed up, otherwise removes the file we wrote. Errors swallow. */
|
||||
* if one was backed up, otherwise removes the file we wrote. Never throws
|
||||
* (this runs in spawn handlers), but failures are logged: a leftover
|
||||
* agent-written settings file silently contaminates the next Claude run in
|
||||
* that directory. If restoring the backup fails, we fall back to deleting
|
||||
* the file so at least the agent's temporary config doesn't persist. */
|
||||
export function restoreClaudeSettingsFile(state: ClaudeSettingsState | null): void {
|
||||
if (!state) return;
|
||||
try {
|
||||
if (state.backupContent !== null) {
|
||||
if (state.backupContent !== null) {
|
||||
try {
|
||||
writeFileSync(state.path, state.backupContent, "utf-8");
|
||||
} else if (existsSync(state.path)) {
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Agent Fleet: failed to restore the previous ${state.path} ` +
|
||||
`(${err instanceof Error ? err.message : String(err)}); ` +
|
||||
`deleting the temporary agent settings file instead so it doesn't affect later runs.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (existsSync(state.path)) {
|
||||
unlinkSync(state.path);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup — don't crash a spawn handler over this.
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Agent Fleet: failed to remove the temporary agent settings file at ${state.path} ` +
|
||||
`(${err instanceof Error ? err.message : String(err)}). ` +
|
||||
`Stale agent permissions may leak into the next Claude run in this directory — delete it manually.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1894,7 +1894,7 @@ export class AgentChatView extends ItemView {
|
|||
// timeout, etc). Render a red error bubble so the user knows
|
||||
// exactly what happened; state clean-up is handled by the session.
|
||||
const msg = event.errorMessage?.trim() || "The agent's run ended with an error.";
|
||||
this.addBubble("error", `Error: ${msg}`);
|
||||
this.addErrorBubble(msg);
|
||||
} else if (event.type === "compacted") {
|
||||
// /compact completed — not a bubble anymore. The session writes
|
||||
// `stats.lastCompact` and re-emits stats, so the inline notice
|
||||
|
|
@ -1930,11 +1930,19 @@ export class AgentChatView extends ItemView {
|
|||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg !== "Aborted") {
|
||||
this.addBubble("error", `Error: ${msg}`);
|
||||
this.addErrorBubble(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a red error bubble, plus a muted one-line recovery hint below
|
||||
* the message when the error matches a known failure mode. */
|
||||
private addErrorBubble(msg: string): void {
|
||||
const bubble = this.addBubble("error", `Error: ${msg}`);
|
||||
const hint = recoveryHintFor(msg);
|
||||
if (hint) bubble.createDiv({ cls: "af-chat-error-hint", text: hint });
|
||||
}
|
||||
|
||||
/** "+ New chat" creates a brand-new conversation alongside any existing
|
||||
* ones, leaving every other chat's history intact. */
|
||||
private async handleNewChat(): Promise<void> {
|
||||
|
|
@ -1989,12 +1997,34 @@ export class AgentChatView extends ItemView {
|
|||
}).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg !== "Aborted") {
|
||||
this.addBubble("error", `Error: ${msg}`);
|
||||
this.addErrorBubble(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Pattern-match common CLI/API failures and return a one-line recovery
|
||||
* hint, or null when the error isn't recognized (no hint rendered). */
|
||||
export function recoveryHintFor(errorText: string): string | null {
|
||||
const t = errorText.toLowerCase();
|
||||
if (/context\s*(window|length)|prompt is too long|too many tokens|token limit|maximum context|context.{0,20}exceed/.test(t)) {
|
||||
return "Try a smaller model, or start a new session to clear history.";
|
||||
}
|
||||
if (/rate.?limit|\b429\b|overloaded|too many requests/.test(t)) {
|
||||
return "Wait a minute and retry, or check your API quota.";
|
||||
}
|
||||
if (/\b401\b|unauthorized|api.?key|authentication|invalid.{0,10}credential|not logged in/.test(t)) {
|
||||
return "Check your API key / CLI login (run the CLI's login command in a terminal).";
|
||||
}
|
||||
if (/timed out|time.?out|watchdog/.test(t)) {
|
||||
return "A tool may be stuck. Retry, or raise the Chat Watchdog Timeout in Settings.";
|
||||
}
|
||||
if (/enoent|command not found|no such file or directory/.test(t)) {
|
||||
return "CLI binary not found — check the CLI path in Settings.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`;
|
||||
return `${n}`;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1299
src/views/forms/agentForm.ts
Normal file
1299
src/views/forms/agentForm.ts
Normal file
File diff suppressed because it is too large
Load diff
420
src/views/forms/channelForm.ts
Normal file
420
src/views/forms/channelForm.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import { Notice, setIcon } from "obsidian";
|
||||
import { ConfirmModal } from "../../modals/confirmModal";
|
||||
import type AgentFleetPlugin from "../../main";
|
||||
import type { ChannelConfig } from "../../types";
|
||||
import { slugify, stringifyMarkdownWithFrontmatter } from "../../utils/markdown";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
|
||||
/**
|
||||
* Map a channel status to the existing `af-agent-card-avatar` color class so that
|
||||
* channel cards borrow the same visual language as agent cards (green=healthy,
|
||||
* blue=transitioning, red=broken, gray=off).
|
||||
*/
|
||||
export function channelStatusToAvatarClass(status: string): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "idle"; // green
|
||||
case "connecting":
|
||||
case "reconnecting":
|
||||
return "pending"; // blue
|
||||
case "needs-auth":
|
||||
case "error":
|
||||
return "error"; // red
|
||||
case "stopped":
|
||||
case "disabled":
|
||||
default:
|
||||
return "disabled"; // gray
|
||||
}
|
||||
}
|
||||
|
||||
/** View helpers the channel form borrows from the dashboard so the extracted
|
||||
* markup stays byte-identical to what the view used to render inline. */
|
||||
export interface ChannelFormDeps {
|
||||
plugin: AgentFleetPlugin;
|
||||
/** Navigate the dashboard to another page (post-save / cancel / delete). */
|
||||
navigate: (page: "channels" | "edit-channel", context?: string) => void;
|
||||
/** Append a small info icon with a hover tooltip to a label element. */
|
||||
addTooltip: (labelEl: HTMLElement, text: string) => void;
|
||||
/** Standard labelled text-input form row. */
|
||||
createFormField: (
|
||||
container: HTMLElement,
|
||||
label: string,
|
||||
placeholder: string,
|
||||
desc: string,
|
||||
onChange: (v: string) => void,
|
||||
initialValue?: string,
|
||||
) => void;
|
||||
/** Disable a submit button while an async save is in flight; returns a restore fn. */
|
||||
markSubmitBusy: (
|
||||
btn: HTMLButtonElement,
|
||||
busyLabel: string,
|
||||
iconName: string,
|
||||
label: string,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared channel form used by both the Create Channel and Edit Channel pages.
|
||||
* Pass `channel` to render in edit mode (pre-filled fields, read-only name,
|
||||
* delete button, "Save Changes" submit); omit it for create mode.
|
||||
*/
|
||||
export function renderChannelForm(
|
||||
page: HTMLElement,
|
||||
deps: ChannelFormDeps,
|
||||
channel?: ChannelConfig,
|
||||
): void {
|
||||
const { plugin } = deps;
|
||||
const snapshot = plugin.runtime.getSnapshot();
|
||||
const credentials = plugin.channelCredentials.list();
|
||||
|
||||
// Header
|
||||
const header = page.createDiv({ cls: "af-detail-header" });
|
||||
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
|
||||
if (channel) {
|
||||
const status = plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled";
|
||||
const avatarEl = headerLeft.createDiv({ cls: `af-agent-card-avatar ${channelStatusToAvatarClass(status)}` });
|
||||
setIcon(avatarEl, "radio");
|
||||
const headerInfo = headerLeft.createDiv();
|
||||
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Channel: ${channel.name}` });
|
||||
headerInfo.createDiv({ cls: "af-detail-header-desc", text: `Status: ${status}` });
|
||||
} else {
|
||||
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
|
||||
setIcon(avatar, "plus");
|
||||
const headerInfo = headerLeft.createDiv();
|
||||
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Channel" });
|
||||
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Connect an external chat transport to an agent" });
|
||||
}
|
||||
header.createDiv({ cls: "af-detail-header-actions" });
|
||||
|
||||
// Form state (pre-filled in edit mode)
|
||||
const state = {
|
||||
name: "",
|
||||
type: (channel ? channel.type : "slack") as ChannelConfig["type"],
|
||||
defaultAgent: channel ? channel.defaultAgent : (snapshot.agents[0]?.name ?? ""),
|
||||
allowedAgents: channel ? [...channel.allowedAgents] : ([] as string[]),
|
||||
credentialRef: channel ? channel.credentialRef : (credentials[0]?.ref ?? ""),
|
||||
allowedUsers: channel ? channel.allowedUsers.join("\n") : "",
|
||||
perUserSessions: channel ? channel.perUserSessions : true,
|
||||
channelContext: channel ? channel.channelContext : "",
|
||||
enabled: channel ? channel.enabled : true,
|
||||
tags: channel ? channel.tags.join(", ") : "",
|
||||
body: channel ? channel.body : "",
|
||||
transportJson: channel && Object.keys(channel.transport).length > 0
|
||||
? JSON.stringify(channel.transport, null, 2)
|
||||
: "",
|
||||
};
|
||||
|
||||
const form = page.createDiv({ cls: "af-create-form" });
|
||||
|
||||
// ─── Channel Details ───
|
||||
const detailsSection = form.createDiv({ cls: "af-create-section" });
|
||||
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
|
||||
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(detailsIcon, "radio");
|
||||
detailsHeader.createSpan({ text: "Channel Details" });
|
||||
|
||||
if (channel) {
|
||||
// Name (read-only)
|
||||
const nameRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
nameRow.createDiv({ cls: "af-form-label", text: "Name" });
|
||||
const nameInput = nameRow.createEl("input", {
|
||||
cls: "af-form-input",
|
||||
attr: { type: "text", value: channel.name, disabled: "true" },
|
||||
});
|
||||
nameInput.setCssStyles({ opacity: "0.6" });
|
||||
} else {
|
||||
deps.createFormField(detailsSection, "Name", "my-slack", "Unique identifier for this channel", (v) => { state.name = v; });
|
||||
}
|
||||
|
||||
// Type dropdown — only Slack is supported in v1; others shown as disabled
|
||||
const typeRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
typeRow.createDiv({ cls: "af-form-label", text: "Type" });
|
||||
const typeSelect = typeRow.createEl("select", { cls: "af-form-select" });
|
||||
for (const t of ["slack", "telegram", "discord"] as const) {
|
||||
const opt = typeSelect.createEl("option", { text: t, attr: { value: t } });
|
||||
if (channel && t === channel.type) opt.selected = true;
|
||||
}
|
||||
typeSelect.addEventListener("change", () => { state.type = typeSelect.value as ChannelConfig["type"]; });
|
||||
|
||||
// Credential dropdown
|
||||
const credRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
const credLabel = credRow.createDiv({ cls: "af-form-label" });
|
||||
credLabel.setText("Credential");
|
||||
deps.addTooltip(credLabel, "Configured in Settings → Agent Fleet → Channel Credentials");
|
||||
const credSelect = credRow.createEl("select", { cls: "af-form-select" });
|
||||
if (credentials.length === 0) {
|
||||
credSelect.createEl("option", { text: "(no credentials configured)", attr: { value: "" } });
|
||||
}
|
||||
for (const c of credentials) {
|
||||
const opt = credSelect.createEl("option", { text: `${c.ref} (${c.entry.type})`, attr: { value: c.ref } });
|
||||
if (channel && c.ref === channel.credentialRef) opt.selected = true;
|
||||
}
|
||||
credSelect.addEventListener("change", () => { state.credentialRef = credSelect.value; });
|
||||
|
||||
// Enabled toggle
|
||||
const enabledRow = detailsSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
|
||||
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
|
||||
const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${state.enabled ? " on" : ""}` });
|
||||
enabledToggle.onclick = () => {
|
||||
const isOn = enabledToggle.hasClass("on");
|
||||
enabledToggle.toggleClass("on", !isOn);
|
||||
state.enabled = !isOn;
|
||||
};
|
||||
|
||||
// ─── Agent Routing ───
|
||||
const agentSection = form.createDiv({ cls: "af-create-section" });
|
||||
const agentHeader = agentSection.createDiv({ cls: "af-create-section-header" });
|
||||
const agentIcon = agentHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(agentIcon, "bot");
|
||||
agentHeader.createSpan({ text: "Agent Routing" });
|
||||
|
||||
// Default agent dropdown
|
||||
const defaultRow = agentSection.createDiv({ cls: "af-form-row" });
|
||||
const defaultLabel = defaultRow.createDiv({ cls: "af-form-label" });
|
||||
defaultLabel.setText("Default agent");
|
||||
deps.addTooltip(defaultLabel, "Used when no @agent-name prefix is given in a message");
|
||||
const defaultSelect = defaultRow.createEl("select", { cls: "af-form-select" });
|
||||
for (const a of snapshot.agents) {
|
||||
const opt = defaultSelect.createEl("option", { text: a.name, attr: { value: a.name } });
|
||||
if (channel && a.name === channel.defaultAgent) opt.selected = true;
|
||||
}
|
||||
defaultSelect.addEventListener("change", () => { state.defaultAgent = defaultSelect.value; });
|
||||
|
||||
// Allowed agents checkboxes
|
||||
const allowedRow = agentSection.createDiv({ cls: "af-form-row" });
|
||||
const allowedLabel = allowedRow.createDiv({ cls: "af-form-label" });
|
||||
allowedLabel.setText("Allowed agents");
|
||||
deps.addTooltip(allowedLabel, "Agents reachable via @prefix. Leave unchecked to allow all agents.");
|
||||
const checkboxContainer = allowedRow.createDiv({ cls: "af-form-checkboxes" });
|
||||
for (const a of snapshot.agents) {
|
||||
const label = checkboxContainer.createEl("label", { cls: "af-form-checkbox-label" });
|
||||
const cb = label.createEl("input", { attr: { type: "checkbox", value: a.name } });
|
||||
if (channel && channel.allowedAgents.includes(a.name)) cb.checked = true;
|
||||
label.appendText(` ${a.name}`);
|
||||
cb.addEventListener("change", () => {
|
||||
if (cb.checked) {
|
||||
if (!state.allowedAgents.includes(a.name)) state.allowedAgents.push(a.name);
|
||||
} else {
|
||||
state.allowedAgents = state.allowedAgents.filter((n) => n !== a.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Per-user sessions toggle
|
||||
const perUserRow = agentSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
|
||||
const perUserLabel = perUserRow.createDiv({ cls: "af-form-label" });
|
||||
perUserLabel.setText("Per-user sessions");
|
||||
deps.addTooltip(perUserLabel, "Each external user gets their own isolated Claude session");
|
||||
const perUserToggle = perUserRow.createDiv({ cls: `af-agent-card-toggle${state.perUserSessions ? " on" : ""}` });
|
||||
perUserToggle.onclick = () => {
|
||||
const isOn = perUserToggle.hasClass("on");
|
||||
perUserToggle.toggleClass("on", !isOn);
|
||||
state.perUserSessions = !isOn;
|
||||
};
|
||||
|
||||
// ─── Access Control ───
|
||||
const accessSection = form.createDiv({ cls: "af-create-section" });
|
||||
const accessHeader = accessSection.createDiv({ cls: "af-create-section-header" });
|
||||
const accessIcon = accessHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(accessIcon, "shield-check");
|
||||
accessHeader.createSpan({ text: "Access Control" });
|
||||
|
||||
const usersLabel = accessSection.createDiv({ cls: "af-form-label" });
|
||||
usersLabel.setText("Allowed users");
|
||||
deps.addTooltip(
|
||||
usersLabel,
|
||||
channel
|
||||
? "Slack user IDs (U...), one per line. Only listed users can reach the bot."
|
||||
: "User IDs, one per line — Slack (U...), Telegram (numeric), or Discord (numeric snowflakes). Only listed users can reach the bot.",
|
||||
);
|
||||
const usersTextarea = accessSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: "U0AQW6P37N1\nU0BXYZ12345", rows: "4" },
|
||||
});
|
||||
if (channel) usersTextarea.value = channel.allowedUsers.join("\n");
|
||||
usersTextarea.addEventListener("input", () => { state.allowedUsers = usersTextarea.value; });
|
||||
|
||||
// ─── Channel Context ───
|
||||
const contextSection = form.createDiv({ cls: "af-create-section" });
|
||||
const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" });
|
||||
const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(contextIcon, "message-square");
|
||||
const contextHeaderLabel = contextHeader.createSpan({ text: "Channel Context" });
|
||||
deps.addTooltip(contextHeaderLabel, "Extra instructions appended to the agent's system prompt when reached through this channel");
|
||||
const contextTextarea = contextSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: "You are being contacted via Slack. Keep replies concise...", rows: "6" },
|
||||
});
|
||||
if (channel) contextTextarea.value = channel.channelContext;
|
||||
contextTextarea.addEventListener("input", () => { state.channelContext = contextTextarea.value; });
|
||||
|
||||
deps.createFormField(
|
||||
detailsSection, "Tags", "ops, internal", "Comma-separated metadata",
|
||||
(v) => { state.tags = v; },
|
||||
channel ? channel.tags.join(", ") : undefined,
|
||||
);
|
||||
|
||||
// ─── Advanced ───
|
||||
const advSection = form.createDiv({ cls: "af-create-section" });
|
||||
const advHeader = advSection.createDiv({ cls: "af-create-section-header" });
|
||||
const advIcon = advHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(advIcon, "settings");
|
||||
const advHeaderLabel = advHeader.createSpan({ text: "Advanced" });
|
||||
deps.addTooltip(advHeaderLabel, "Markdown body (shown in the channel detail page) and transport-specific overrides");
|
||||
|
||||
const bodyLabel = advSection.createDiv({ cls: "af-form-label" });
|
||||
bodyLabel.setText("Body (markdown)");
|
||||
if (!channel) {
|
||||
deps.addTooltip(bodyLabel, "Free-form notes for this channel. Shown in the channel detail page; not sent to the agent.");
|
||||
}
|
||||
const bodyTextarea = advSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: "Notes, runbook snippets, escalation contacts…", rows: "4" },
|
||||
});
|
||||
if (channel) bodyTextarea.value = channel.body;
|
||||
bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; });
|
||||
|
||||
const transportLabel = advSection.createDiv({ cls: "af-form-label" });
|
||||
transportLabel.setText("Transport config (JSON)");
|
||||
deps.addTooltip(
|
||||
transportLabel,
|
||||
channel
|
||||
? "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode). Leave blank for defaults."
|
||||
: "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode, telegram webhook settings). Leave blank for defaults.",
|
||||
);
|
||||
const transportTextarea = advSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: '{\n "socket_mode": true\n}', rows: "4" },
|
||||
});
|
||||
if (channel) transportTextarea.value = state.transportJson;
|
||||
transportTextarea.addEventListener("input", () => { state.transportJson = transportTextarea.value; });
|
||||
|
||||
// ─── Footer ───
|
||||
const footer = page.createDiv({ cls: "af-create-footer" });
|
||||
|
||||
if (channel) {
|
||||
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
|
||||
createIcon(deleteBtn, "trash-2", "af-btn-icon");
|
||||
deleteBtn.appendText(" Delete");
|
||||
deleteBtn.onclick = () => {
|
||||
new ConfirmModal(plugin.app, {
|
||||
title: `Delete channel "${channel.name}"?`,
|
||||
body: "The channel file will be moved to your system trash and can be recovered.",
|
||||
confirmText: "Delete",
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
await plugin.repository.deleteChannel(channel.name);
|
||||
new Notice(`Channel "${channel.name}" deleted.`);
|
||||
await new Promise((r) => window.setTimeout(r, 200));
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("channels");
|
||||
},
|
||||
}).open();
|
||||
};
|
||||
|
||||
footer.createDiv({ cls: "af-toolbar-spacer" });
|
||||
}
|
||||
|
||||
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
|
||||
cancelBtn.onclick = () => deps.navigate("channels");
|
||||
|
||||
const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
|
||||
createIcon(submitBtn, channel ? "check" : "plus", "af-btn-icon");
|
||||
submitBtn.appendText(channel ? " Save Changes" : " Create Channel");
|
||||
|
||||
const parseUsers = (s: string) => s.split(/[\n,]+/).map((t) => t.trim()).filter(Boolean);
|
||||
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
/** Parse the transport JSON textarea; returns `null` (after a Notice) when invalid. */
|
||||
const parseTransport = (): Record<string, unknown> | undefined | null => {
|
||||
if (!state.transportJson.trim()) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(state.transportJson);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
new Notice("Transport config must be a JSON object.");
|
||||
return null;
|
||||
} catch (err) {
|
||||
new Notice(`Transport JSON is invalid: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (channel) {
|
||||
// Edit mode: update the existing channel file in place.
|
||||
submitBtn.onclick = async () => {
|
||||
let transport = parseTransport();
|
||||
if (transport === null) return;
|
||||
if (transport === undefined) transport = {};
|
||||
|
||||
const restoreSubmit = deps.markSubmitBusy(submitBtn, "Saving...", "check", "Save Changes");
|
||||
try {
|
||||
await plugin.repository.updateChannel(channel.name, {
|
||||
type: state.type,
|
||||
default_agent: state.defaultAgent,
|
||||
allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : [],
|
||||
enabled: state.enabled,
|
||||
credential_ref: state.credentialRef,
|
||||
allowed_users: parseUsers(state.allowedUsers),
|
||||
per_user_sessions: state.perUserSessions,
|
||||
channel_context: state.channelContext.trim(),
|
||||
tags: parseTags(state.tags),
|
||||
body: state.body.trim(),
|
||||
transport,
|
||||
});
|
||||
new Notice(`Channel "${channel.name}" updated.`);
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("channels");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to update channel: ${msg}`);
|
||||
restoreSubmit();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Create mode: write a new channel file.
|
||||
submitBtn.onclick = async () => {
|
||||
const name = state.name.trim();
|
||||
if (!name) { new Notice("Channel name is required."); return; }
|
||||
if (!state.credentialRef) { new Notice("Select a credential."); return; }
|
||||
|
||||
const transport = parseTransport();
|
||||
if (transport === null) return;
|
||||
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
name: slugify(name),
|
||||
type: state.type,
|
||||
default_agent: state.defaultAgent,
|
||||
allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : undefined,
|
||||
enabled: state.enabled,
|
||||
credential_ref: state.credentialRef,
|
||||
allowed_users: parseUsers(state.allowedUsers),
|
||||
per_user_sessions: state.perUserSessions,
|
||||
channel_context: state.channelContext.trim() || undefined,
|
||||
tags: parseTags(state.tags).length > 0 ? parseTags(state.tags) : undefined,
|
||||
transport,
|
||||
};
|
||||
|
||||
const restoreSubmit = deps.markSubmitBusy(submitBtn, "Creating...", "plus", "Create Channel");
|
||||
try {
|
||||
const channelSlug = slugify(name);
|
||||
const path = await plugin.repository.getAvailablePath(
|
||||
plugin.repository.getSubfolder("channels"),
|
||||
channelSlug,
|
||||
);
|
||||
await plugin.app.vault.create(
|
||||
path,
|
||||
stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim()),
|
||||
);
|
||||
new Notice(`Channel "${channelSlug}" created.`);
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("edit-channel", channelSlug);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to create channel: ${msg}`);
|
||||
restoreSubmit();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
251
src/views/forms/mcpForm.ts
Normal file
251
src/views/forms/mcpForm.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { Notice, setIcon } from "obsidian";
|
||||
import type { McpServer } from "../../types";
|
||||
import { splitLines } from "../../utils/platform";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import type { DashboardFormDeps } from "./shared";
|
||||
|
||||
/** View helpers the MCP server form borrows from the dashboard. */
|
||||
export interface McpFormDeps extends DashboardFormDeps {
|
||||
/** Navigate the dashboard to another page (post-save / cancel). */
|
||||
navigate: (page: "mcp") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Add MCP Server" registration form: stdio (local process) or http/sse
|
||||
* (remote) transports, with optional bearer-token/OAuth auth. Secrets go to
|
||||
* the OS keychain via `plugin.mcpAuth`, never into the vault file.
|
||||
*/
|
||||
export function renderAddMcpServerForm(page: HTMLElement, deps: McpFormDeps): void {
|
||||
const { plugin } = deps;
|
||||
|
||||
// Header
|
||||
const header = page.createDiv({ cls: "af-detail-header" });
|
||||
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
|
||||
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
|
||||
setIcon(avatar, "plus");
|
||||
const headerInfo = headerLeft.createDiv();
|
||||
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Add MCP Server" });
|
||||
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Register a new MCP server for agents to use" });
|
||||
header.createDiv({ cls: "af-detail-header-actions" });
|
||||
|
||||
// Form state
|
||||
const state: {
|
||||
name: string;
|
||||
transport: "stdio" | "http" | "sse";
|
||||
description: string;
|
||||
command: string;
|
||||
args: string;
|
||||
envVars: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
auth: "none" | "bearer" | "oauth";
|
||||
bearerToken: string;
|
||||
} = {
|
||||
name: "",
|
||||
transport: "stdio",
|
||||
description: "",
|
||||
command: "",
|
||||
args: "",
|
||||
envVars: "",
|
||||
url: "",
|
||||
headers: "",
|
||||
auth: "none",
|
||||
bearerToken: "",
|
||||
};
|
||||
|
||||
const form = page.createDiv({ cls: "af-create-form" });
|
||||
|
||||
// ─── Server Details ───
|
||||
const detailsSection = form.createDiv({ cls: "af-create-section" });
|
||||
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
|
||||
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(detailsIcon, "plug");
|
||||
detailsHeader.createSpan({ text: "Server Details" });
|
||||
|
||||
deps.createFormField(detailsSection, "Name", "my-server", "Unique name for this MCP server", (v) => { state.name = v; });
|
||||
|
||||
// Transport dropdown
|
||||
const transportRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
const transportLabel = transportRow.createDiv({ cls: "af-form-label" });
|
||||
transportLabel.setText("Transport");
|
||||
deps.addTooltip(transportLabel, "stdio: local process, http/sse: remote server");
|
||||
const transportSelect = transportRow.createEl("select", { cls: "af-form-select" });
|
||||
transportSelect.createEl("option", { text: "stdio", attr: { value: "stdio" } });
|
||||
transportSelect.createEl("option", { text: "http", attr: { value: "http" } });
|
||||
transportSelect.createEl("option", { text: "sse", attr: { value: "sse" } });
|
||||
|
||||
deps.createFormField(detailsSection, "Description", "What this server does (optional)", "Shown on the server card", (v) => { state.description = v; });
|
||||
|
||||
// ─── stdio fields ───
|
||||
const stdioSection = form.createDiv({ cls: "af-create-section" });
|
||||
const stdioHeader = stdioSection.createDiv({ cls: "af-create-section-header" });
|
||||
const stdioIcon = stdioHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(stdioIcon, "terminal");
|
||||
stdioHeader.createSpan({ text: "Process Configuration" });
|
||||
|
||||
deps.createFormField(stdioSection, "Command", "npx @anthropic-ai/mcp-server-memory", "The command to run", (v) => { state.command = v; });
|
||||
deps.createFormField(stdioSection, "Arguments", "--port 3000", "Space-separated arguments (optional)", (v) => { state.args = v; });
|
||||
|
||||
const envLabel = stdioSection.createDiv({ cls: "af-form-label" });
|
||||
envLabel.setText("Environment variables");
|
||||
deps.addTooltip(envLabel, "One KEY=VALUE per line");
|
||||
const envTextarea = stdioSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: "API_KEY=sk-...\nDEBUG=true", rows: "3" },
|
||||
});
|
||||
envTextarea.addEventListener("input", () => { state.envVars = envTextarea.value; });
|
||||
|
||||
// ─── http/sse fields ───
|
||||
const httpSection = form.createDiv({ cls: "af-create-section" });
|
||||
const httpHeader = httpSection.createDiv({ cls: "af-create-section-header" });
|
||||
const httpIcon = httpHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(httpIcon, "globe");
|
||||
httpHeader.createSpan({ text: "Remote Server Configuration" });
|
||||
|
||||
deps.createFormField(httpSection, "URL", "https://mcp.example.com/sse", "Server endpoint URL", (v) => { state.url = v; });
|
||||
|
||||
const headersLabel = httpSection.createDiv({ cls: "af-form-label" });
|
||||
headersLabel.setText("Custom headers");
|
||||
deps.addTooltip(headersLabel, "One Header: Value per line (optional, non-secret)");
|
||||
const headersTextarea = httpSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: "X-Custom-Header: value", rows: "2" },
|
||||
});
|
||||
headersTextarea.addEventListener("input", () => { state.headers = headersTextarea.value; });
|
||||
|
||||
// Auth dropdown
|
||||
const authRow = httpSection.createDiv({ cls: "af-form-row" });
|
||||
const authLabel = authRow.createDiv({ cls: "af-form-label" });
|
||||
authLabel.setText("Authentication");
|
||||
deps.addTooltip(authLabel, "none, a static bearer token, or OAuth (authenticate after saving)");
|
||||
const authSelect = authRow.createEl("select", { cls: "af-form-select" });
|
||||
authSelect.createEl("option", { text: "None", attr: { value: "none" } });
|
||||
authSelect.createEl("option", { text: "Bearer token", attr: { value: "bearer" } });
|
||||
authSelect.createEl("option", { text: "OAuth", attr: { value: "oauth" } });
|
||||
|
||||
// Bearer token field (shown only for auth=bearer). Stored in the keychain,
|
||||
// never in the vault.
|
||||
const bearerRow = httpSection.createDiv({ cls: "af-form-row" });
|
||||
const bearerLabel = bearerRow.createDiv({ cls: "af-form-label" });
|
||||
bearerLabel.setText("Bearer token");
|
||||
deps.addTooltip(bearerLabel, "Stored securely in the OS keychain, never written to the vault");
|
||||
const bearerInput = bearerRow.createEl("input", { cls: "af-form-input", attr: { type: "password", placeholder: "sk-…" } });
|
||||
bearerInput.addEventListener("input", () => { state.bearerToken = bearerInput.value; });
|
||||
|
||||
const updateAuthVisibility = () => {
|
||||
bearerRow.setCssStyles({ display: state.auth === "bearer" ? "" : "none" });
|
||||
};
|
||||
authSelect.addEventListener("change", () => {
|
||||
state.auth = authSelect.value as "none" | "bearer" | "oauth";
|
||||
updateAuthVisibility();
|
||||
});
|
||||
updateAuthVisibility();
|
||||
|
||||
// Toggle section visibility based on transport
|
||||
const updateTransportVisibility = () => {
|
||||
stdioSection.setCssStyles({ display: state.transport === "stdio" ? "" : "none" });
|
||||
httpSection.setCssStyles({ display: state.transport !== "stdio" ? "" : "none" });
|
||||
};
|
||||
transportSelect.addEventListener("change", () => {
|
||||
state.transport = transportSelect.value as "stdio" | "http" | "sse";
|
||||
updateTransportVisibility();
|
||||
});
|
||||
updateTransportVisibility();
|
||||
|
||||
// ─── Footer ───
|
||||
const footer = page.createDiv({ cls: "af-create-footer" });
|
||||
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
|
||||
cancelBtn.onclick = () => deps.navigate("mcp");
|
||||
|
||||
const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
|
||||
createIcon(submitBtn, "plus", "af-btn-icon");
|
||||
submitBtn.appendText(" Add Server");
|
||||
submitBtn.onclick = async () => {
|
||||
const name = state.name.trim();
|
||||
if (!name) { new Notice("Server name is required."); return; }
|
||||
|
||||
if (state.transport === "stdio") {
|
||||
if (!state.command.trim()) { new Notice("Command is required for stdio servers."); return; }
|
||||
} else {
|
||||
if (!state.url.trim()) { new Notice("URL is required for HTTP/SSE servers."); return; }
|
||||
}
|
||||
|
||||
// Parse env vars
|
||||
const envVars: Record<string, string> = {};
|
||||
if (state.envVars.trim()) {
|
||||
for (const line of splitLines(state.envVars)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx <= 0) { new Notice(`Invalid env var: ${trimmed}`); return; }
|
||||
envVars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
const headers: Record<string, string> = {};
|
||||
if (state.headers.trim()) {
|
||||
for (const line of splitLines(state.headers)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
if (colonIdx <= 0) { new Notice(`Invalid header: ${trimmed}`); return; }
|
||||
headers[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse args
|
||||
const args = state.args.trim() ? state.args.trim().split(/\s+/) : undefined;
|
||||
|
||||
if (plugin.repository.getMcpServerByName(name)) {
|
||||
new Notice(`An MCP server named "${name}" already exists.`);
|
||||
return;
|
||||
}
|
||||
if (state.transport !== "stdio" && state.auth === "bearer" && !state.bearerToken.trim()) {
|
||||
new Notice("Enter a bearer token, or choose a different auth method.");
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.setText("Adding...");
|
||||
|
||||
// Build the registry definition (non-secret fields only). The bearer
|
||||
// token, if any, goes to the keychain — never into the vault file.
|
||||
const server: McpServer = {
|
||||
name,
|
||||
type: state.transport,
|
||||
enabled: true,
|
||||
source: "manual",
|
||||
status: "disconnected",
|
||||
scope: "user",
|
||||
tools: [],
|
||||
toolDetails: [],
|
||||
};
|
||||
if (state.transport === "stdio") {
|
||||
server.command = state.command.trim();
|
||||
if (args) server.args = args;
|
||||
if (Object.keys(envVars).length > 0) server.env = envVars;
|
||||
} else {
|
||||
server.url = state.url.trim();
|
||||
if (Object.keys(headers).length > 0) server.headers = headers;
|
||||
server.auth = state.auth;
|
||||
}
|
||||
|
||||
try {
|
||||
await plugin.repository.saveMcpServer(server, state.description.trim());
|
||||
if (state.transport !== "stdio" && state.auth === "bearer" && state.bearerToken.trim()) {
|
||||
plugin.mcpAuth.storeStaticToken(name, state.bearerToken.trim());
|
||||
}
|
||||
new Notice(`Server "${name}" added.`);
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("mcp");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to add server: ${msg}`);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.setText("");
|
||||
createIcon(submitBtn, "plus", "af-btn-icon");
|
||||
submitBtn.appendText(" Add Server");
|
||||
}
|
||||
};
|
||||
}
|
||||
68
src/views/forms/shared.ts
Normal file
68
src/views/forms/shared.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type AgentFleetPlugin from "../../main";
|
||||
|
||||
/**
|
||||
* View helpers every extracted form page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. Each form module extends this with its own (narrowly typed)
|
||||
* `navigate` delegate and any page-specific extras.
|
||||
*/
|
||||
export interface DashboardFormDeps {
|
||||
plugin: AgentFleetPlugin;
|
||||
/** Append a small info icon with a hover tooltip to a label element. */
|
||||
addTooltip: (labelEl: HTMLElement, text: string) => void;
|
||||
/** Standard labelled text-input form row. */
|
||||
createFormField: (
|
||||
container: HTMLElement,
|
||||
label: string,
|
||||
placeholder: string,
|
||||
desc: string,
|
||||
onChange: (v: string) => void,
|
||||
initialValue?: string,
|
||||
) => void;
|
||||
/** Disable a submit button while an async save is in flight; returns a restore fn. */
|
||||
markSubmitBusy: (
|
||||
btn: HTMLButtonElement,
|
||||
busyLabel: string,
|
||||
iconName: string,
|
||||
label: string,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
/** Decompose a cron expression into the schedule-picker widget's components. */
|
||||
export function parseCronComponents(cron: string): {
|
||||
freq: string;
|
||||
hour: number;
|
||||
minute: number;
|
||||
days: number[];
|
||||
dayOfMonth: number;
|
||||
} {
|
||||
const defaults = { freq: "daily", hour: 9, minute: 0, days: [1], dayOfMonth: 1 };
|
||||
if (!cron?.trim()) return defaults;
|
||||
|
||||
const shortcutMap: Record<string, string> = {
|
||||
"*/5 * * * *": "every_5m",
|
||||
"*/15 * * * *": "every_15m",
|
||||
"*/30 * * * *": "every_30m",
|
||||
"0 * * * *": "every_hour",
|
||||
"0 */2 * * *": "every_2h",
|
||||
};
|
||||
if (shortcutMap[cron]) return { ...defaults, freq: shortcutMap[cron] };
|
||||
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return defaults;
|
||||
const [min, hr, dom, , dow] = parts;
|
||||
const h = parseInt(hr ?? "9", 10);
|
||||
const m = parseInt(min ?? "0", 10);
|
||||
|
||||
if (dom === "*" && dow === "*") return { ...defaults, freq: "daily", hour: h, minute: m };
|
||||
if (dom === "*" && dow === "1-5") return { ...defaults, freq: "weekdays", hour: h, minute: m };
|
||||
if (dom === "*" && dow !== "*") {
|
||||
const days = (dow ?? "1").split(",").map((d) => parseInt(d, 10));
|
||||
return { ...defaults, freq: "weekly", hour: h, minute: m, days };
|
||||
}
|
||||
if (dow === "*" && dom !== "*") {
|
||||
return { ...defaults, freq: "monthly", hour: h, minute: m, dayOfMonth: parseInt(dom ?? "1", 10) };
|
||||
}
|
||||
|
||||
return { ...defaults, hour: h, minute: m };
|
||||
}
|
||||
271
src/views/forms/skillForm.ts
Normal file
271
src/views/forms/skillForm.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { Notice, setIcon } from "obsidian";
|
||||
import { ConfirmModal } from "../../modals/confirmModal";
|
||||
import type { SkillConfig } from "../../types";
|
||||
import { slugify } from "../../utils/markdown";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import type { DashboardFormDeps } from "./shared";
|
||||
|
||||
/** View helpers the skill form borrows from the dashboard. */
|
||||
export interface SkillFormDeps extends DashboardFormDeps {
|
||||
/** Navigate the dashboard to another page (post-save / cancel / delete). */
|
||||
navigate: (page: "skills") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared skill form used by both the Create Skill and Edit Skill pages.
|
||||
* Pass `skill` to render in edit mode (pre-filled fields, read-only name,
|
||||
* delete button, "Save Changes" submit); omit it for create mode (which
|
||||
* additionally offers a template picker).
|
||||
*/
|
||||
export function renderSkillForm(
|
||||
page: HTMLElement,
|
||||
deps: SkillFormDeps,
|
||||
skill?: SkillConfig,
|
||||
): void {
|
||||
const { plugin } = deps;
|
||||
|
||||
// Header
|
||||
const header = page.createDiv({ cls: "af-detail-header" });
|
||||
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
|
||||
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
|
||||
setIcon(avatar, skill ? "edit" : "plus");
|
||||
const headerInfo = headerLeft.createDiv();
|
||||
if (skill) {
|
||||
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Skill: ${skill.name}` });
|
||||
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify skill definition" });
|
||||
} else {
|
||||
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Skill" });
|
||||
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Define a reusable skill for your agents" });
|
||||
}
|
||||
|
||||
header.createDiv({ cls: "af-detail-header-actions" });
|
||||
// Form state (pre-filled in edit mode)
|
||||
const state = {
|
||||
name: "",
|
||||
description: skill ? skill.description ?? "" : "",
|
||||
tags: skill ? skill.tags.join(", ") : "",
|
||||
body: skill ? skill.body : "",
|
||||
toolsBody: skill ? skill.toolsBody : "",
|
||||
referencesBody: skill ? skill.referencesBody : "",
|
||||
examplesBody: skill ? skill.examplesBody : "",
|
||||
};
|
||||
|
||||
const SKILL_TEMPLATES: Record<string, { label: string; prompt: string }> = {
|
||||
none: { label: "None", prompt: "" },
|
||||
cli: { label: "CLI Tool Wrapper", prompt: "You are using the {{tool}} CLI. All operations go through the wrapper script.\n\nRequirements:\n- Ensure required environment variables are set\n- Parse JSON responses for human-readable output\n- Confirm destructive operations before executing\n\nKey behaviors:\n- List existing items before making changes\n- Use --dry-run flags when available\n- Report errors clearly with suggested fixes" },
|
||||
api: { label: "API Integration", prompt: "You are integrating with the {{service}} API.\n\nBase URL: https://api.example.com/v1\nAuth: Bearer token via environment variable\n\nKey behaviors:\n- Always check rate limits before bulk operations\n- Handle pagination for list endpoints\n- Validate inputs before making requests\n- Parse and format JSON responses for readability" },
|
||||
review: { label: "Code Review", prompt: "You are a code review skill. Analyze code changes and provide structured feedback.\n\nReview checklist:\n- Correctness: Does the code do what it claims?\n- Security: Any injection, auth, or data exposure risks?\n- Performance: Unnecessary allocations, N+1 queries, missing indexes?\n- Maintainability: Clear naming, reasonable complexity, adequate tests?\n\nOutput format:\n- Start with a 1-line summary\n- Group findings by severity (critical, warning, suggestion)\n- Reference specific file paths and line numbers" },
|
||||
data: { label: "Data Analysis", prompt: "You are a data analysis skill. Query, transform, and report on data.\n\nKey behaviors:\n- Summarize datasets before diving into details\n- Use tables and charts where appropriate\n- Always state the time range and filters applied\n- Flag anomalies and outliers explicitly\n- End with actionable insights, not just observations" },
|
||||
};
|
||||
|
||||
const form = page.createDiv({ cls: "af-create-form" });
|
||||
|
||||
// ─── Identity Section ───
|
||||
const identitySection = form.createDiv({ cls: "af-create-section" });
|
||||
const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" });
|
||||
const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(identityIcon, "puzzle");
|
||||
identityHeader.createSpan({ text: "Identity" });
|
||||
|
||||
if (skill) {
|
||||
// Name (read-only)
|
||||
const nameRow = identitySection.createDiv({ cls: "af-form-row" });
|
||||
nameRow.createDiv({ cls: "af-form-label", text: "Name" });
|
||||
const nameInput = nameRow.createEl("input", {
|
||||
cls: "af-form-input",
|
||||
attr: { type: "text", value: skill.name, disabled: "true" },
|
||||
});
|
||||
nameInput.setCssStyles({ opacity: "0.6" });
|
||||
} else {
|
||||
deps.createFormField(identitySection, "Name", "todoist", "Unique identifier (will be slugified)", (v) => { state.name = v; });
|
||||
}
|
||||
|
||||
deps.createFormField(identitySection, "Description", "Manage tasks and projects via CLI", "", (v) => { state.description = v; }, skill ? skill.description ?? "" : undefined);
|
||||
deps.createFormField(identitySection, "Tags", "productivity, tasks", "Comma-separated", (v) => { state.tags = v; }, skill ? skill.tags.join(", ") : undefined);
|
||||
|
||||
// ─── Core Instructions Section ───
|
||||
const instructionsSection = form.createDiv({ cls: "af-create-section" });
|
||||
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
|
||||
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(instructionsIcon, "file-text");
|
||||
instructionsHeader.createSpan({ text: "Core Instructions" });
|
||||
|
||||
// Template picker (create mode only)
|
||||
let templateSelect: HTMLSelectElement | undefined;
|
||||
if (!skill) {
|
||||
const templateRow = instructionsSection.createDiv({ cls: "af-form-row" });
|
||||
templateRow.createDiv({ cls: "af-form-label", text: "Template" });
|
||||
templateSelect = templateRow.createEl("select", { cls: "af-form-select" });
|
||||
for (const [key, { label }] of Object.entries(SKILL_TEMPLATES)) {
|
||||
templateSelect.createEl("option", { text: label, attr: { value: key } });
|
||||
}
|
||||
}
|
||||
|
||||
const bodyTextarea = instructionsSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: {
|
||||
placeholder: skill
|
||||
? "Skill instructions..."
|
||||
: "Skill instructions — what does this skill do and how should agents use it?",
|
||||
rows: "10",
|
||||
},
|
||||
});
|
||||
if (skill) bodyTextarea.value = skill.body;
|
||||
bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; });
|
||||
|
||||
if (templateSelect) {
|
||||
const select = templateSelect;
|
||||
select.addEventListener("change", () => {
|
||||
const preset = SKILL_TEMPLATES[select.value];
|
||||
if (preset && select.value !== "none") {
|
||||
state.body = preset.prompt;
|
||||
bodyTextarea.value = preset.prompt;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tools Section ───
|
||||
const toolsSection = form.createDiv({ cls: "af-create-section" });
|
||||
const toolsHeader = toolsSection.createDiv({ cls: "af-create-section-header" });
|
||||
const toolsIcon = toolsHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(toolsIcon, "wrench");
|
||||
const toolsHeaderLabel = toolsHeader.createSpan({ text: "Tools" });
|
||||
deps.addTooltip(toolsHeaderLabel, "CLI commands, API endpoints, and tool definitions available to agents using this skill");
|
||||
const toolsTextarea = toolsSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: {
|
||||
placeholder: skill
|
||||
? "## Commands\n\n### list\n..."
|
||||
: "## Commands\n\n### list\nUsage: tool list [--filter <query>]\n...",
|
||||
rows: "8",
|
||||
},
|
||||
});
|
||||
if (skill) toolsTextarea.value = skill.toolsBody;
|
||||
toolsTextarea.addEventListener("input", () => { state.toolsBody = toolsTextarea.value; });
|
||||
|
||||
// ─── References Section ───
|
||||
const refsSection = form.createDiv({ cls: "af-create-section" });
|
||||
const refsHeader = refsSection.createDiv({ cls: "af-create-section-header" });
|
||||
const refsIcon = refsHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(refsIcon, "book-open");
|
||||
const refsHeaderLabel = refsHeader.createSpan({ text: "References" });
|
||||
deps.addTooltip(refsHeaderLabel, "Background docs, conventions, cheat sheets");
|
||||
const refsTextarea = refsSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: "API docs, filter syntax, conventions...", rows: "6" },
|
||||
});
|
||||
if (skill) refsTextarea.value = skill.referencesBody;
|
||||
refsTextarea.addEventListener("input", () => { state.referencesBody = refsTextarea.value; });
|
||||
|
||||
// ─── Examples Section ───
|
||||
const examplesSection = form.createDiv({ cls: "af-create-section" });
|
||||
const examplesHeader = examplesSection.createDiv({ cls: "af-create-section-header" });
|
||||
const examplesIcon = examplesHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(examplesIcon, "message-circle");
|
||||
const examplesHeaderLabel = examplesHeader.createSpan({ text: "Examples" });
|
||||
deps.addTooltip(examplesHeaderLabel, "Example prompts and ideal outputs showing how to use this skill");
|
||||
const examplesTextarea = examplesSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: {
|
||||
placeholder: skill
|
||||
? "## Example: List all tasks\n..."
|
||||
: "## Example: List all tasks\n\nUser: Show me my tasks for today\n\nAgent: ...",
|
||||
rows: "6",
|
||||
},
|
||||
});
|
||||
if (skill) examplesTextarea.value = skill.examplesBody;
|
||||
examplesTextarea.addEventListener("input", () => { state.examplesBody = examplesTextarea.value; });
|
||||
|
||||
// ─── Footer ───
|
||||
const footer = page.createDiv({ cls: "af-create-footer" });
|
||||
|
||||
if (skill) {
|
||||
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
|
||||
createIcon(deleteBtn, "trash-2", "af-btn-icon");
|
||||
deleteBtn.appendText(" Delete");
|
||||
deleteBtn.onclick = () => {
|
||||
new ConfirmModal(plugin.app, {
|
||||
title: `Delete skill "${skill.name}"?`,
|
||||
body: "The skill file will be moved to your system trash and can be recovered.",
|
||||
confirmText: "Delete",
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
await plugin.repository.deleteSkill(skill.name);
|
||||
new Notice(`Skill "${skill.name}" deleted.`);
|
||||
// Small delay for Obsidian vault cache to process the trash
|
||||
await new Promise((r) => window.setTimeout(r, 200));
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("skills");
|
||||
},
|
||||
}).open();
|
||||
};
|
||||
|
||||
footer.createDiv({ cls: "af-toolbar-spacer" });
|
||||
}
|
||||
|
||||
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
|
||||
cancelBtn.onclick = () => deps.navigate("skills");
|
||||
|
||||
const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
|
||||
createIcon(submitBtn, skill ? "check" : "plus", "af-btn-icon");
|
||||
submitBtn.appendText(skill ? " Save Changes" : " Create Skill");
|
||||
|
||||
if (skill) {
|
||||
// Edit mode: update the existing skill file(s) in place.
|
||||
submitBtn.onclick = async () => {
|
||||
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
const restoreSubmit = deps.markSubmitBusy(submitBtn, "Saving...", "check", "Save Changes");
|
||||
try {
|
||||
await plugin.repository.updateSkill(skill.name, {
|
||||
description: state.description.trim(),
|
||||
tags: parseTags(state.tags),
|
||||
body: state.body.trim(),
|
||||
toolsBody: state.toolsBody.trim(),
|
||||
referencesBody: state.referencesBody.trim(),
|
||||
examplesBody: state.examplesBody.trim(),
|
||||
});
|
||||
new Notice(`Skill "${skill.name}" updated.`);
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("skills");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to update skill: ${msg}`);
|
||||
restoreSubmit();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Create mode: write a new skill folder.
|
||||
submitBtn.onclick = async () => {
|
||||
const name = state.name.trim();
|
||||
if (!name) {
|
||||
new Notice("Skill name is required.");
|
||||
return;
|
||||
}
|
||||
const slug = slugify(name);
|
||||
if (plugin.repository.getSkillByName(slug)) {
|
||||
new Notice(`Skill "${slug}" already exists.`);
|
||||
return;
|
||||
}
|
||||
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
const restoreSubmit = deps.markSubmitBusy(submitBtn, "Creating...", "plus", "Create Skill");
|
||||
try {
|
||||
await plugin.repository.createSkillFolder({
|
||||
name: slug,
|
||||
description: state.description.trim(),
|
||||
tags: parseTags(state.tags),
|
||||
body: state.body.trim(),
|
||||
toolsBody: state.toolsBody.trim(),
|
||||
referencesBody: state.referencesBody.trim(),
|
||||
examplesBody: state.examplesBody.trim(),
|
||||
});
|
||||
new Notice(`Skill "${slug}" created.`);
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("skills");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to create skill: ${msg}`);
|
||||
restoreSubmit();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
786
src/views/forms/taskForm.ts
Normal file
786
src/views/forms/taskForm.ts
Normal file
|
|
@ -0,0 +1,786 @@
|
|||
import { Notice, setIcon } from "obsidian";
|
||||
import { ConfirmModal } from "../../modals/confirmModal";
|
||||
import type { ChannelConfig, TaskConfig } from "../../types";
|
||||
import { slugify, stringifyMarkdownWithFrontmatter } from "../../utils/markdown";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import { renderModelPicker } from "../../components/modelPicker";
|
||||
import type { DashboardFormDeps } from "./shared";
|
||||
import { parseCronComponents } from "./shared";
|
||||
|
||||
/** View helpers the task forms borrow from the dashboard. */
|
||||
export interface TaskFormDeps extends DashboardFormDeps {
|
||||
/** Navigate the dashboard to another page (post-save / cancel / delete). */
|
||||
navigate: (page: "kanban" | "task-detail", context?: string) => void;
|
||||
}
|
||||
|
||||
/** Inline cron schedule picker shared by the create and edit task forms.
|
||||
* Writes the built cron expression into `state.schedule`. */
|
||||
function renderInlineSchedule(
|
||||
container: HTMLElement,
|
||||
state: { schedule: string; type: string },
|
||||
): void {
|
||||
// Parse current cron into components
|
||||
const parsed = parseCronComponents(state.schedule);
|
||||
|
||||
// Frequency dropdown
|
||||
const freqRow = container.createDiv({ cls: "af-form-row" });
|
||||
freqRow.createDiv({ cls: "af-form-label", text: "Frequency" });
|
||||
const freqSelect = freqRow.createEl("select", { cls: "af-form-select" });
|
||||
const freqOptions: Array<[string, string]> = [
|
||||
["every_5m", "Every 5 minutes"],
|
||||
["every_15m", "Every 15 minutes"],
|
||||
["every_30m", "Every 30 minutes"],
|
||||
["every_hour", "Every hour"],
|
||||
["every_2h", "Every 2 hours"],
|
||||
["daily", "Daily"],
|
||||
["weekdays", "Weekdays"],
|
||||
["weekly", "Weekly"],
|
||||
["monthly", "Monthly"],
|
||||
];
|
||||
for (const [val, lbl] of freqOptions) {
|
||||
const opt = freqSelect.createEl("option", { text: lbl, attr: { value: val } });
|
||||
if (val === parsed.freq) opt.selected = true;
|
||||
}
|
||||
|
||||
// Time row (shown for daily/weekdays/weekly/monthly)
|
||||
const timeRow = container.createDiv({ cls: "af-form-row af-schedule-time-row" });
|
||||
timeRow.createDiv({ cls: "af-form-label", text: "Time" });
|
||||
const timeWrap = timeRow.createDiv({ cls: "af-schedule-time-selects" });
|
||||
|
||||
const hourSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" });
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
const opt = hourSelect.createEl("option", { text: `${h12} ${ampm}`, attr: { value: String(h) } });
|
||||
if (h === parsed.hour) opt.selected = true;
|
||||
}
|
||||
|
||||
timeWrap.createSpan({ cls: "af-schedule-colon", text: ":" });
|
||||
|
||||
const minSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" });
|
||||
for (let m = 0; m < 60; m += 5) {
|
||||
const opt = minSelect.createEl("option", {
|
||||
text: String(m).padStart(2, "0"),
|
||||
attr: { value: String(m) },
|
||||
});
|
||||
if (m === parsed.minute) opt.selected = true;
|
||||
}
|
||||
|
||||
// Day row (shown for weekly)
|
||||
const dayRow = container.createDiv({ cls: "af-form-row af-schedule-day-row" });
|
||||
dayRow.createDiv({ cls: "af-form-label", text: "Day" });
|
||||
const dayWrap = dayRow.createDiv({ cls: "af-schedule-day-buttons" });
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const selectedDays = new Set(parsed.days);
|
||||
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const btn = dayWrap.createEl("button", {
|
||||
cls: `af-schedule-day-btn${selectedDays.has(d) ? " active" : ""}`,
|
||||
text: dayNames[d]!,
|
||||
});
|
||||
btn.onclick = () => {
|
||||
if (selectedDays.has(d)) selectedDays.delete(d);
|
||||
else selectedDays.add(d);
|
||||
btn.toggleClass("active", selectedDays.has(d));
|
||||
buildCron();
|
||||
};
|
||||
}
|
||||
|
||||
// Day-of-month row (shown for monthly)
|
||||
const domRow = container.createDiv({ cls: "af-form-row af-schedule-dom-row" });
|
||||
domRow.createDiv({ cls: "af-form-label", text: "Day of month" });
|
||||
const domSelect = domRow.createEl("select", { cls: "af-form-select af-form-select-sm" });
|
||||
for (let d = 1; d <= 28; d++) {
|
||||
const opt = domSelect.createEl("option", { text: String(d), attr: { value: String(d) } });
|
||||
if (d === parsed.dayOfMonth) opt.selected = true;
|
||||
}
|
||||
|
||||
// Visibility logic
|
||||
const showHideFields = () => {
|
||||
const freq = freqSelect.value;
|
||||
const needsTime = ["daily", "weekdays", "weekly", "monthly"].includes(freq);
|
||||
const needsDay = freq === "weekly";
|
||||
const needsDom = freq === "monthly";
|
||||
timeRow.setCssStyles({ display: needsTime ? "" : "none" });
|
||||
dayRow.setCssStyles({ display: needsDay ? "" : "none" });
|
||||
domRow.setCssStyles({ display: needsDom ? "" : "none" });
|
||||
};
|
||||
|
||||
// Build cron from selections
|
||||
const buildCron = () => {
|
||||
const freq = freqSelect.value;
|
||||
const h = hourSelect.value;
|
||||
const m = minSelect.value;
|
||||
let cron = "";
|
||||
switch (freq) {
|
||||
case "every_5m": cron = "*/5 * * * *"; break;
|
||||
case "every_15m": cron = "*/15 * * * *"; break;
|
||||
case "every_30m": cron = "*/30 * * * *"; break;
|
||||
case "every_hour": cron = "0 * * * *"; break;
|
||||
case "every_2h": cron = "0 */2 * * *"; break;
|
||||
case "daily": cron = `${m} ${h} * * *`; break;
|
||||
case "weekdays": cron = `${m} ${h} * * 1-5`; break;
|
||||
case "weekly": {
|
||||
const days = Array.from(selectedDays).sort().join(",") || "1";
|
||||
cron = `${m} ${h} * * ${days}`;
|
||||
break;
|
||||
}
|
||||
case "monthly": cron = `${m} ${h} ${domSelect.value} * *`; break;
|
||||
}
|
||||
state.schedule = cron;
|
||||
state.type = "recurring";
|
||||
};
|
||||
|
||||
freqSelect.addEventListener("change", () => { showHideFields(); buildCron(); });
|
||||
hourSelect.addEventListener("change", buildCron);
|
||||
minSelect.addEventListener("change", buildCron);
|
||||
domSelect.addEventListener("change", buildCron);
|
||||
|
||||
showHideFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the optional "post results to a channel" controls shared by the
|
||||
* create and edit task forms: a channel picker plus a transport-native target
|
||||
* id (Discord/Slack channel id, Telegram chat id). Empty target = broadcast/DM.
|
||||
*/
|
||||
function renderTaskChannelDelivery(
|
||||
section: HTMLElement,
|
||||
deps: TaskFormDeps,
|
||||
channels: ChannelConfig[],
|
||||
state: { channel: string; channelTarget: string },
|
||||
): void {
|
||||
const channelRow = section.createDiv({ cls: "af-form-row" });
|
||||
const channelLabel = channelRow.createDiv({ cls: "af-form-label", text: "Channel" });
|
||||
const channelSelect = channelRow.createEl("select", { cls: "af-form-select" });
|
||||
channelSelect.createEl("option", { text: "— none (run log only) —", attr: { value: "" } });
|
||||
for (const ch of channels) {
|
||||
const opt = channelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } });
|
||||
if (ch.name === state.channel) opt.selected = true;
|
||||
}
|
||||
deps.addTooltip(
|
||||
channelLabel,
|
||||
"Post this task’s full output to a channel when it finishes. Leave as none to only write the run log (the default batched behavior).",
|
||||
);
|
||||
|
||||
const targetRow = section.createDiv({ cls: "af-form-row" });
|
||||
const targetLabel = targetRow.createDiv({ cls: "af-form-label", text: "Target ID" });
|
||||
const targetInput = targetRow.createEl("input", {
|
||||
cls: "af-form-input",
|
||||
attr: { type: "text", placeholder: "Discord/Slack channel ID or Telegram chat ID — empty = DM you" },
|
||||
});
|
||||
targetInput.value = state.channelTarget;
|
||||
targetInput.addEventListener("input", () => { state.channelTarget = targetInput.value.trim(); });
|
||||
deps.addTooltip(
|
||||
targetLabel,
|
||||
"Where in the channel to post. For Discord, enable Developer Mode and right-click the channel → Copy Channel ID. Leave empty to DM the first allowed user instead.",
|
||||
);
|
||||
|
||||
const syncVisibility = () => { targetRow.setCssStyles({ display: state.channel ? "" : "none" }); };
|
||||
syncVisibility();
|
||||
channelSelect.addEventListener("change", () => {
|
||||
state.channel = channelSelect.value;
|
||||
syncVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
function toLocalISO(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** Format a Date as the value string accepted by `<input type="datetime-local">`:
|
||||
* `YYYY-MM-DDTHH:mm`, local timezone, no seconds. */
|
||||
function toDatetimeLocal(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
type ScheduleChoice = "immediate" | "recurring" | "once";
|
||||
|
||||
/**
|
||||
* Segmented [ Immediate | Recurring | One-time ] control that drives the
|
||||
* schedule sections. Maps 1:1 onto the previous enable-toggle + mode-dropdown
|
||||
* state (scheduleEnabled + scheduleMode), so the saved frontmatter is
|
||||
* unchanged — only the input UI differs.
|
||||
*/
|
||||
function renderScheduleTypeSegments(
|
||||
container: HTMLElement,
|
||||
initial: ScheduleChoice,
|
||||
onSelect: (choice: ScheduleChoice) => void,
|
||||
): void {
|
||||
const row = container.createDiv({ cls: "af-form-row" });
|
||||
row.createDiv({ cls: "af-form-label", text: "Run" });
|
||||
const seg = row.createDiv({ cls: "af-segmented" });
|
||||
const options: Array<[ScheduleChoice, string]> = [
|
||||
["immediate", "Immediate"],
|
||||
["recurring", "Recurring"],
|
||||
["once", "One-time"],
|
||||
];
|
||||
const buttons: Array<[ScheduleChoice, HTMLButtonElement]> = [];
|
||||
for (const [val, lbl] of options) {
|
||||
const btn = seg.createEl("button", {
|
||||
cls: `af-segmented-btn${val === initial ? " active" : ""}`,
|
||||
text: lbl,
|
||||
});
|
||||
buttons.push([val, btn]);
|
||||
btn.onclick = () => {
|
||||
for (const [v, b] of buttons) b.toggleClass("active", v === val);
|
||||
onSelect(val);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Create Task Page
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export function renderCreateTaskForm(page: HTMLElement, deps: TaskFormDeps): void {
|
||||
const { plugin } = deps;
|
||||
const snapshot = plugin.runtime.getSnapshot();
|
||||
|
||||
// Header
|
||||
const header = page.createDiv({ cls: "af-detail-header" });
|
||||
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
|
||||
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
|
||||
setIcon(avatar, "plus");
|
||||
const headerInfo = headerLeft.createDiv();
|
||||
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Task" });
|
||||
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Configure a new task for your fleet" });
|
||||
|
||||
header.createDiv({ cls: "af-detail-header-actions" });
|
||||
|
||||
// Form state
|
||||
const state = {
|
||||
title: "",
|
||||
agent: snapshot.agents[0]?.name ?? "",
|
||||
priority: "medium",
|
||||
tags: "",
|
||||
body: "",
|
||||
scheduleEnabled: false,
|
||||
/** "recurring" (cron) or "once" (run_at). Only consulted when
|
||||
* scheduleEnabled is true; otherwise the task is "immediate". */
|
||||
scheduleMode: "recurring" as "recurring" | "once",
|
||||
schedule: "0 9 * * *",
|
||||
runAt: "",
|
||||
type: "immediate" as string,
|
||||
enabled: true,
|
||||
catchUp: true,
|
||||
effort: "",
|
||||
model: "",
|
||||
channel: "",
|
||||
channelTarget: "",
|
||||
};
|
||||
|
||||
const form = page.createDiv({ cls: "af-create-form" });
|
||||
|
||||
// ─── Task Details Section ───
|
||||
const detailsSection = form.createDiv({ cls: "af-create-section" });
|
||||
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
|
||||
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(detailsIcon, "file-text");
|
||||
detailsHeader.createSpan({ text: "Task Details" });
|
||||
|
||||
deps.createFormField(detailsSection, "Title", "Daily status report", "Used as the task identifier", (v) => { state.title = v; });
|
||||
|
||||
// Agent dropdown
|
||||
const agentRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
agentRow.createDiv({ cls: "af-form-label", text: "Agent" });
|
||||
const agentSelect = agentRow.createEl("select", { cls: "af-form-select" });
|
||||
for (const a of snapshot.agents) {
|
||||
agentSelect.createEl("option", { text: a.name, attr: { value: a.name } });
|
||||
}
|
||||
agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; });
|
||||
|
||||
// Priority dropdown
|
||||
const priorityRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
priorityRow.createDiv({ cls: "af-form-label", text: "Priority" });
|
||||
const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" });
|
||||
const priorityOptions: Array<[string, string]> = [
|
||||
["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"],
|
||||
];
|
||||
for (const [val, lbl] of priorityOptions) {
|
||||
const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } });
|
||||
if (val === "medium") opt.selected = true;
|
||||
}
|
||||
prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value; });
|
||||
|
||||
// Tags
|
||||
deps.createFormField(detailsSection, "Tags", "monitoring, devops", "Comma-separated", (v) => { state.tags = v; });
|
||||
|
||||
// ─── Instructions Section ───
|
||||
const instructionsSection = form.createDiv({ cls: "af-create-section" });
|
||||
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
|
||||
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(instructionsIcon, "message-square");
|
||||
instructionsHeader.createSpan({ text: "Instructions" });
|
||||
|
||||
const instructionsTextarea = instructionsSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: "Describe what the agent should do...", rows: "10" },
|
||||
});
|
||||
instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; });
|
||||
|
||||
// ─── Schedule Section ───
|
||||
const scheduleSection = form.createDiv({ cls: "af-create-section" });
|
||||
const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" });
|
||||
const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(scheduleIcon, "clock");
|
||||
scheduleHeader.createSpan({ text: "Schedule" });
|
||||
|
||||
// Schedule type — one segmented control replaces the old enable-toggle +
|
||||
// mode dropdown. Drives the same state fields (scheduleEnabled/scheduleMode)
|
||||
// so the saved frontmatter is identical.
|
||||
renderScheduleTypeSegments(scheduleSection, "immediate", (choice) => {
|
||||
state.scheduleEnabled = choice !== "immediate";
|
||||
if (choice !== "immediate") state.scheduleMode = choice;
|
||||
state.type = choice;
|
||||
scheduleBody.setCssStyles({ display: choice === "immediate" ? "none" : "" });
|
||||
cronHost.setCssStyles({ display: choice === "recurring" ? "" : "none" });
|
||||
onceHost.setCssStyles({ display: choice === "once" ? "" : "none" });
|
||||
});
|
||||
|
||||
const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" });
|
||||
scheduleBody.setCssStyles({ display: "none" });
|
||||
const cronHost = scheduleBody.createDiv();
|
||||
const onceHost = scheduleBody.createDiv();
|
||||
onceHost.setCssStyles({ display: "none" });
|
||||
renderInlineSchedule(cronHost, state);
|
||||
|
||||
const onceRow = onceHost.createDiv({ cls: "af-form-row" });
|
||||
onceRow.createDiv({ cls: "af-form-label", text: "Run at" });
|
||||
const onceInput = onceRow.createEl("input", {
|
||||
cls: "af-form-input",
|
||||
attr: { type: "datetime-local", value: toDatetimeLocal(new Date(Date.now() + 3600_000)) },
|
||||
});
|
||||
// Seed the initial value — the task won't persist blank runAt
|
||||
state.runAt = new Date(onceInput.value).toISOString();
|
||||
onceInput.addEventListener("input", () => {
|
||||
state.runAt = onceInput.value ? new Date(onceInput.value).toISOString() : "";
|
||||
});
|
||||
|
||||
// Enabled toggle (only when schedule is on)
|
||||
const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
|
||||
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
|
||||
const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" });
|
||||
enabledToggle.onclick = () => {
|
||||
const isOn = enabledToggle.hasClass("on");
|
||||
enabledToggle.toggleClass("on", !isOn);
|
||||
state.enabled = !isOn;
|
||||
};
|
||||
|
||||
// Catch up missed runs toggle
|
||||
const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
|
||||
const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" });
|
||||
catchUpLabel.setText("Catch up if missed");
|
||||
|
||||
const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` });
|
||||
catchUpToggle.onclick = () => {
|
||||
const isOn = catchUpToggle.hasClass("on");
|
||||
catchUpToggle.toggleClass("on", !isOn);
|
||||
state.catchUp = !isOn;
|
||||
};
|
||||
|
||||
// ─── Execution Section ───
|
||||
// Model & effort are per-run controls, not scheduling knobs — they apply
|
||||
// whether or not the task has a schedule, so they live in their own section.
|
||||
const execSection = form.createDiv({ cls: "af-create-section" });
|
||||
const execHeader = execSection.createDiv({ cls: "af-create-section-header" });
|
||||
const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(execIcon, "gauge");
|
||||
execHeader.createSpan({ text: "Execution" });
|
||||
|
||||
// Model override (per-task)
|
||||
const taskModelRow = execSection.createDiv({ cls: "af-form-row" });
|
||||
const taskModelLabel = taskModelRow.createDiv({ cls: "af-form-label", text: "Model" });
|
||||
const taskModelFieldWrap = taskModelRow.createDiv({ cls: "af-form-field-wrap" });
|
||||
const renderCreateTaskModelPicker = (agentName: string) => {
|
||||
taskModelFieldWrap.empty();
|
||||
const selAgent = snapshot.agents.find((a) => a.name === agentName);
|
||||
renderModelPicker(taskModelFieldWrap, {
|
||||
value: state.model,
|
||||
adapter: selAgent?.adapter,
|
||||
onChange: (value) => { state.model = value; },
|
||||
allowInherit: true,
|
||||
inheritPlaceholder: selAgent
|
||||
? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}`
|
||||
: "Inherit from agent",
|
||||
});
|
||||
};
|
||||
renderCreateTaskModelPicker(state.agent);
|
||||
agentSelect.addEventListener("change", () => renderCreateTaskModelPicker(agentSelect.value));
|
||||
deps.addTooltip(
|
||||
taskModelLabel,
|
||||
"Override the agent’s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.",
|
||||
);
|
||||
|
||||
// Effort level
|
||||
const taskEffortRow = execSection.createDiv({ cls: "af-form-row" });
|
||||
const taskEffortLabel = taskEffortRow.createDiv({ cls: "af-form-label", text: "Effort" });
|
||||
const taskEffortSelect = taskEffortRow.createEl("select", { cls: "af-form-select" });
|
||||
for (const [val, lbl] of [["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) {
|
||||
const opt = taskEffortSelect.createEl("option", { text: lbl, attr: { value: val } });
|
||||
if (val === state.effort) opt.selected = true;
|
||||
}
|
||||
taskEffortSelect.addEventListener("change", () => { state.effort = taskEffortSelect.value; });
|
||||
// Channel delivery (optional) — post this task's output to a channel on completion.
|
||||
renderTaskChannelDelivery(execSection, deps, snapshot.channels, state);
|
||||
deps.addTooltip(
|
||||
taskEffortLabel,
|
||||
"Overrides the agent’s effort level for this task. Higher effort = more thinking tokens spent.",
|
||||
);
|
||||
|
||||
// ─── Footer ───
|
||||
const footer = page.createDiv({ cls: "af-create-footer" });
|
||||
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
|
||||
cancelBtn.onclick = () => deps.navigate("kanban");
|
||||
|
||||
const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
|
||||
createIcon(createBtn, "plus", "af-btn-icon");
|
||||
createBtn.appendText(" Create Task");
|
||||
createBtn.onclick = async () => {
|
||||
const title = state.title.trim();
|
||||
if (!title) {
|
||||
new Notice("Task title is required.");
|
||||
return;
|
||||
}
|
||||
const taskId = slugify(title);
|
||||
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
const effectiveType = state.scheduleEnabled
|
||||
? state.scheduleMode === "once"
|
||||
? "once"
|
||||
: "recurring"
|
||||
: "immediate";
|
||||
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
task_id: taskId,
|
||||
agent: state.agent,
|
||||
type: effectiveType,
|
||||
priority: state.priority,
|
||||
enabled: state.enabled,
|
||||
created: toLocalISO(new Date()),
|
||||
run_count: 0,
|
||||
catch_up: state.catchUp,
|
||||
effort: state.effort || undefined,
|
||||
model: state.model || undefined,
|
||||
channel: state.channel || undefined,
|
||||
channel_target: state.channel && state.channelTarget ? state.channelTarget : undefined,
|
||||
tags: parseTags(state.tags),
|
||||
};
|
||||
|
||||
if (effectiveType === "recurring") {
|
||||
frontmatter.schedule = state.schedule.trim() || "0 9 * * *";
|
||||
} else if (effectiveType === "once") {
|
||||
if (!state.runAt) {
|
||||
new Notice("Pick a date/time for the one-time run.");
|
||||
return;
|
||||
}
|
||||
frontmatter.run_at = state.runAt;
|
||||
}
|
||||
|
||||
const restoreSubmit = deps.markSubmitBusy(createBtn, "Creating...", "plus", "Create Task");
|
||||
try {
|
||||
const path = await plugin.repository.getAvailablePath(
|
||||
plugin.repository.getSubfolder("tasks"),
|
||||
taskId,
|
||||
);
|
||||
await plugin.app.vault.create(
|
||||
path,
|
||||
stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim() || "Describe the task here."),
|
||||
);
|
||||
new Notice(`Task "${taskId}" created.`);
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("task-detail", taskId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to create task: ${msg}`);
|
||||
restoreSubmit();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Edit Task Page
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export function renderEditTaskForm(page: HTMLElement, deps: TaskFormDeps, task: TaskConfig): void {
|
||||
const { plugin } = deps;
|
||||
const snapshot = plugin.runtime.getSnapshot();
|
||||
|
||||
// Header
|
||||
const header = page.createDiv({ cls: "af-detail-header" });
|
||||
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
|
||||
const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
|
||||
setIcon(taskIcon, "edit");
|
||||
const headerInfo = headerLeft.createDiv();
|
||||
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Task: ${task.taskId}` });
|
||||
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify task configuration" });
|
||||
|
||||
header.createDiv({ cls: "af-detail-header-actions" });
|
||||
|
||||
const hasSchedule = !!(task.schedule || task.runAt);
|
||||
|
||||
// Form state pre-filled
|
||||
const state = {
|
||||
agent: task.agent,
|
||||
type: task.type as string,
|
||||
priority: task.priority,
|
||||
schedule: task.schedule ?? "0 9 * * *",
|
||||
runAt: task.runAt ?? "",
|
||||
scheduleEnabled: hasSchedule,
|
||||
scheduleMode: (task.type === "once" ? "once" : "recurring"),
|
||||
enabled: task.enabled,
|
||||
catchUp: task.catchUp,
|
||||
effort: task.effort ?? "",
|
||||
model: task.model ?? "",
|
||||
channel: task.channel ?? "",
|
||||
channelTarget: task.channelTarget ?? "",
|
||||
tags: task.tags.join(", "),
|
||||
body: task.body,
|
||||
};
|
||||
|
||||
const form = page.createDiv({ cls: "af-create-form" });
|
||||
|
||||
// ─── Task Details Section ───
|
||||
const detailsSection = form.createDiv({ cls: "af-create-section" });
|
||||
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
|
||||
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(detailsIcon, "file-text");
|
||||
detailsHeader.createSpan({ text: "Task Details" });
|
||||
|
||||
// Title (read-only)
|
||||
const nameRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
nameRow.createDiv({ cls: "af-form-label", text: "Title" });
|
||||
const nameInput = nameRow.createEl("input", {
|
||||
cls: "af-form-input",
|
||||
attr: { type: "text", value: task.taskId, disabled: "true" },
|
||||
});
|
||||
nameInput.setCssStyles({ opacity: "0.6" });
|
||||
|
||||
// Agent dropdown
|
||||
const agentRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
agentRow.createDiv({ cls: "af-form-label", text: "Agent" });
|
||||
const agentSelect = agentRow.createEl("select", { cls: "af-form-select" });
|
||||
for (const a of snapshot.agents) {
|
||||
const opt = agentSelect.createEl("option", { text: a.name, attr: { value: a.name } });
|
||||
if (a.name === task.agent) opt.selected = true;
|
||||
}
|
||||
agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; });
|
||||
|
||||
// Priority dropdown
|
||||
const priorityRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
priorityRow.createDiv({ cls: "af-form-label", text: "Priority" });
|
||||
const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" });
|
||||
const priorityOptions: Array<[string, string]> = [
|
||||
["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"],
|
||||
];
|
||||
for (const [val, lbl] of priorityOptions) {
|
||||
const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } });
|
||||
if (val === task.priority) opt.selected = true;
|
||||
}
|
||||
prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value as typeof state.priority; });
|
||||
|
||||
// Tags
|
||||
deps.createFormField(detailsSection, "Tags", "monitoring, critical", "Comma-separated", (v) => { state.tags = v; }, task.tags.join(", "));
|
||||
|
||||
// ─── Instructions Section ───
|
||||
const instructionsSection = form.createDiv({ cls: "af-create-section" });
|
||||
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
|
||||
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(instructionsIcon, "message-square");
|
||||
instructionsHeader.createSpan({ text: "Instructions" });
|
||||
|
||||
const instructionsTextarea = instructionsSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: "Describe what the agent should do...", rows: "10" },
|
||||
});
|
||||
instructionsTextarea.value = task.body;
|
||||
instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; });
|
||||
|
||||
// ─── Schedule Section ───
|
||||
const scheduleSection = form.createDiv({ cls: "af-create-section" });
|
||||
const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" });
|
||||
const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(scheduleIcon, "clock");
|
||||
scheduleHeader.createSpan({ text: "Schedule" });
|
||||
|
||||
// Schedule type — one segmented control replaces the old enable-toggle +
|
||||
// mode dropdown. Drives the same state fields (scheduleEnabled/scheduleMode)
|
||||
// so the saved frontmatter is identical.
|
||||
const initialChoice: ScheduleChoice = !hasSchedule
|
||||
? "immediate"
|
||||
: state.scheduleMode === "once"
|
||||
? "once"
|
||||
: "recurring";
|
||||
renderScheduleTypeSegments(scheduleSection, initialChoice, (choice) => {
|
||||
state.scheduleEnabled = choice !== "immediate";
|
||||
if (choice !== "immediate") state.scheduleMode = choice;
|
||||
state.type = choice;
|
||||
scheduleBody.setCssStyles({ display: choice === "immediate" ? "none" : "" });
|
||||
editCronHost.setCssStyles({ display: choice === "recurring" ? "" : "none" });
|
||||
editOnceHost.setCssStyles({ display: choice === "once" ? "" : "none" });
|
||||
});
|
||||
|
||||
const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" });
|
||||
scheduleBody.setCssStyles({ display: hasSchedule ? "" : "none" });
|
||||
const editCronHost = scheduleBody.createDiv();
|
||||
const editOnceHost = scheduleBody.createDiv();
|
||||
editCronHost.setCssStyles({ display: initialChoice === "recurring" ? "" : "none" });
|
||||
editOnceHost.setCssStyles({ display: initialChoice === "once" ? "" : "none" });
|
||||
renderInlineSchedule(editCronHost, state);
|
||||
|
||||
const editOnceRow = editOnceHost.createDiv({ cls: "af-form-row" });
|
||||
editOnceRow.createDiv({ cls: "af-form-label", text: "Run at" });
|
||||
const prefillOnce = state.runAt
|
||||
? toDatetimeLocal(new Date(state.runAt))
|
||||
: toDatetimeLocal(new Date(Date.now() + 3600_000));
|
||||
const editOnceInput = editOnceRow.createEl("input", {
|
||||
cls: "af-form-input",
|
||||
attr: { type: "datetime-local", value: prefillOnce },
|
||||
});
|
||||
if (!state.runAt) state.runAt = new Date(editOnceInput.value).toISOString();
|
||||
editOnceInput.addEventListener("input", () => {
|
||||
state.runAt = editOnceInput.value ? new Date(editOnceInput.value).toISOString() : "";
|
||||
});
|
||||
|
||||
// Enabled toggle (whether the schedule is active)
|
||||
const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
|
||||
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
|
||||
const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${task.enabled ? " on" : ""}` });
|
||||
enabledToggle.onclick = () => {
|
||||
const isOn = enabledToggle.hasClass("on");
|
||||
enabledToggle.toggleClass("on", !isOn);
|
||||
state.enabled = !isOn;
|
||||
};
|
||||
|
||||
// Catch up missed runs toggle
|
||||
const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
|
||||
const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" });
|
||||
catchUpLabel.setText("Catch up if missed");
|
||||
|
||||
const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` });
|
||||
catchUpToggle.onclick = () => {
|
||||
const isOn = catchUpToggle.hasClass("on");
|
||||
catchUpToggle.toggleClass("on", !isOn);
|
||||
state.catchUp = !isOn;
|
||||
};
|
||||
|
||||
// ─── Execution Section ───
|
||||
const execSection = form.createDiv({ cls: "af-create-section" });
|
||||
const execHeader = execSection.createDiv({ cls: "af-create-section-header" });
|
||||
const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" });
|
||||
setIcon(execIcon, "gauge");
|
||||
execHeader.createSpan({ text: "Execution" });
|
||||
|
||||
// Effort level
|
||||
const effortRow = execSection.createDiv({ cls: "af-form-row" });
|
||||
const effortLabel = effortRow.createDiv({ cls: "af-form-label", text: "Effort" });
|
||||
const effortSelect = effortRow.createEl("select", { cls: "af-form-select" });
|
||||
const effortOptions: Array<[string, string]> = [
|
||||
["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"],
|
||||
];
|
||||
for (const [val, lbl] of effortOptions) {
|
||||
const opt = effortSelect.createEl("option", { text: lbl, attr: { value: val } });
|
||||
if (val === state.effort) opt.selected = true;
|
||||
}
|
||||
effortSelect.addEventListener("change", () => { state.effort = effortSelect.value; });
|
||||
deps.addTooltip(
|
||||
effortLabel,
|
||||
"Overrides the agent’s effort level for this task. Higher effort = more thinking tokens spent.",
|
||||
);
|
||||
|
||||
// Model override
|
||||
const modelRowEdit = execSection.createDiv({ cls: "af-form-row" });
|
||||
const modelLabelEdit = modelRowEdit.createDiv({ cls: "af-form-label", text: "Model" });
|
||||
const modelFieldWrapEdit = modelRowEdit.createDiv({ cls: "af-form-field-wrap" });
|
||||
const renderTaskModelPicker = (agentName: string) => {
|
||||
modelFieldWrapEdit.empty();
|
||||
const selAgent = snapshot.agents.find((a) => a.name === agentName);
|
||||
renderModelPicker(modelFieldWrapEdit, {
|
||||
value: state.model,
|
||||
adapter: selAgent?.adapter,
|
||||
onChange: (value) => { state.model = value; },
|
||||
allowInherit: true,
|
||||
inheritPlaceholder: selAgent
|
||||
? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}`
|
||||
: "Inherit from agent",
|
||||
});
|
||||
};
|
||||
renderTaskModelPicker(state.agent);
|
||||
agentSelect.addEventListener("change", () => renderTaskModelPicker(agentSelect.value));
|
||||
// Channel delivery (optional) — post this task's output to a channel on completion.
|
||||
renderTaskChannelDelivery(execSection, deps, snapshot.channels, state);
|
||||
deps.addTooltip(
|
||||
modelLabelEdit,
|
||||
"Override the agent’s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.",
|
||||
);
|
||||
|
||||
// ─── Footer ───
|
||||
const footer = page.createDiv({ cls: "af-create-footer" });
|
||||
|
||||
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
|
||||
createIcon(deleteBtn, "trash-2", "af-btn-icon");
|
||||
deleteBtn.appendText(" Delete");
|
||||
deleteBtn.onclick = () => {
|
||||
new ConfirmModal(plugin.app, {
|
||||
title: `Delete task "${task.taskId}"?`,
|
||||
body: "The task file will be moved to your system trash and can be recovered.",
|
||||
confirmText: "Delete",
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
await plugin.repository.deleteTask(task.taskId);
|
||||
new Notice(`Task "${task.taskId}" deleted.`);
|
||||
await new Promise((r) => window.setTimeout(r, 200));
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("kanban");
|
||||
},
|
||||
}).open();
|
||||
};
|
||||
|
||||
footer.createDiv({ cls: "af-toolbar-spacer" });
|
||||
|
||||
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
|
||||
cancelBtn.onclick = () => deps.navigate("task-detail", task.taskId);
|
||||
|
||||
const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
|
||||
createIcon(saveBtn, "check", "af-btn-icon");
|
||||
saveBtn.appendText(" Save Changes");
|
||||
saveBtn.onclick = async () => {
|
||||
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
const effectiveType = state.scheduleEnabled
|
||||
? state.scheduleMode === "once"
|
||||
? "once"
|
||||
: "recurring"
|
||||
: "immediate";
|
||||
if (effectiveType === "once" && !state.runAt) {
|
||||
new Notice("Pick a date/time for the one-time run.");
|
||||
return;
|
||||
}
|
||||
const restoreSubmit = deps.markSubmitBusy(saveBtn, "Saving...", "check", "Save Changes");
|
||||
try {
|
||||
await plugin.repository.updateTask(task.taskId, {
|
||||
agent: state.agent,
|
||||
type: effectiveType,
|
||||
priority: state.priority,
|
||||
schedule: effectiveType === "recurring" ? state.schedule.trim() : "",
|
||||
runAt: effectiveType === "once" ? state.runAt : "",
|
||||
enabled: state.enabled,
|
||||
catch_up: state.catchUp,
|
||||
effort: state.effort || undefined,
|
||||
model: state.model || "",
|
||||
channel: state.channel || "",
|
||||
channelTarget: state.channel && state.channelTarget ? state.channelTarget : "",
|
||||
tags: parseTags(state.tags),
|
||||
body: state.body.trim(),
|
||||
});
|
||||
new Notice(`Task "${task.taskId}" updated.`);
|
||||
await plugin.refreshFromVault();
|
||||
deps.navigate("task-detail", task.taskId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to update task: ${msg}`);
|
||||
restoreSubmit();
|
||||
}
|
||||
};
|
||||
}
|
||||
396
src/views/pages/agentDetailPage.ts
Normal file
396
src/views/pages/agentDetailPage.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { Notice, setIcon } from "obsidian";
|
||||
import type { AgentConfig, AgentHealth, RunLogData, UsageRecord } from "../../types";
|
||||
import { truncate } from "../../utils/markdown";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import { renderSections } from "../../utils/memoryFormat";
|
||||
import type { DashboardPageDeps } from "./shared";
|
||||
|
||||
/** View helpers the agent detail page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. The active tab is view state — the page reads and writes it
|
||||
* through the get/set delegates so it survives re-renders. */
|
||||
export interface AgentDetailPageDeps extends DashboardPageDeps {
|
||||
/** Navigate the dashboard to another page. */
|
||||
navigate: (page: "edit-agent", context?: string) => void;
|
||||
healthToClass: (status: AgentHealth) => string;
|
||||
/** Lucide icon / emoji / initials avatar renderer (owned by the view). */
|
||||
renderAgentAvatar: (el: HTMLElement, agent: AgentConfig) => void;
|
||||
/** Dashboard stat card (owned by the view, shared with the overview page). */
|
||||
renderStatCard: (
|
||||
container: HTMLElement,
|
||||
label: string,
|
||||
value: string,
|
||||
valueSuffix: string,
|
||||
iconName: string,
|
||||
sub: string,
|
||||
) => void;
|
||||
/** Comprehensive token + cost totals across run logs and usage records. */
|
||||
combinedTotals: (runs: RunLogData[], usage: UsageRecord[]) => { tokens: number; cost: number };
|
||||
/** Run timeline entry shared with the overview page (owned by the view). */
|
||||
renderTimelineItem: (container: HTMLElement, run: RunLogData) => void;
|
||||
/** Label + monospace value config row (owned by the view). */
|
||||
renderConfigRow: (container: HTMLElement, label: string, value: string) => void;
|
||||
statusToTimelineClass: (status: string) => string;
|
||||
statusToIconName: (status: string) => string;
|
||||
statusToBadgeClass: (status: string) => string;
|
||||
statusToBadgeText: (status: string) => string;
|
||||
formatStarted: (iso: string) => string;
|
||||
formatDuration: (seconds: number) => string;
|
||||
formatTokenCount: (tokens: number) => string;
|
||||
cronToHuman: (cron: string) => string;
|
||||
timeUntil: (date: Date) => string;
|
||||
/** Open the run-details slideover (owned by the view). */
|
||||
openSlideover: (run: RunLogData) => void;
|
||||
/** Open the chat panel for this agent (owned by the view). */
|
||||
openChatSlideover: (agent: AgentConfig) => void;
|
||||
/** Active detail tab — view state so it survives re-renders. */
|
||||
getDetailTab: () => string;
|
||||
setDetailTab: (tab: string) => void;
|
||||
/** Re-render the whole dashboard view (tab switches). */
|
||||
rerender: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function renderAgentDetailPage(container: HTMLElement, deps: AgentDetailPageDeps, agentName: string | undefined): void {
|
||||
const page = container.createDiv({ cls: "af-agent-detail-page" });
|
||||
if (!agentName) {
|
||||
deps.renderEmptyState(page, "bot", "No agent selected", "Select an agent from the list");
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = deps.plugin.runtime.getSnapshot().agents.find((a) => a.name === agentName);
|
||||
if (!agent) {
|
||||
deps.renderEmptyState(page, "bot", "Agent not found", `Agent "${agentName}" was not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = deps.plugin.runtime.getAgentState(agent.name);
|
||||
const agentRuns = deps.plugin.runtime.getRecentRuns().filter((r) => r.agent === agent.name);
|
||||
|
||||
// Header
|
||||
const header = page.createDiv({ cls: "af-detail-header" });
|
||||
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
|
||||
const avatar = headerLeft.createDiv({
|
||||
cls: `af-agent-card-avatar ${deps.healthToClass(state.status)}`,
|
||||
});
|
||||
deps.renderAgentAvatar(avatar, agent);
|
||||
const headerInfo = headerLeft.createDiv();
|
||||
headerInfo.createDiv({ cls: "af-detail-header-name", text: agent.name });
|
||||
headerInfo.createDiv({ cls: "af-detail-header-desc", text: agent.description ?? "No description" });
|
||||
|
||||
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
|
||||
|
||||
// Chat — primary action
|
||||
const chatBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" });
|
||||
createIcon(chatBtn, "message-circle", "af-btn-icon");
|
||||
chatBtn.appendText(" Chat");
|
||||
chatBtn.onclick = () => deps.openChatSlideover(agent);
|
||||
|
||||
// Run Now / Enable — secondary
|
||||
if (agent.enabled) {
|
||||
const runBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
|
||||
createIcon(runBtn, "play", "af-btn-icon");
|
||||
runBtn.appendText(" Run Now");
|
||||
runBtn.onclick = () => void deps.plugin.runAgentPrompt(agent.name);
|
||||
|
||||
const pauseBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
|
||||
createIcon(pauseBtn, "pause", "af-btn-icon");
|
||||
pauseBtn.appendText(" Disable");
|
||||
pauseBtn.onclick = () => void deps.plugin.toggleAgent(agent.name, false);
|
||||
} else {
|
||||
const enableBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
|
||||
createIcon(enableBtn, "play", "af-btn-icon");
|
||||
enableBtn.appendText(" Enable");
|
||||
enableBtn.onclick = () => void deps.plugin.toggleAgent(agent.name, true);
|
||||
}
|
||||
|
||||
const detailEditBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
|
||||
createIcon(detailEditBtn, "edit", "af-btn-icon");
|
||||
detailEditBtn.appendText(" Edit");
|
||||
detailEditBtn.onclick = () => deps.navigate("edit-agent", agent.name);
|
||||
|
||||
const deleteBtn = headerActions.createEl("button", { cls: "af-btn-sm danger" });
|
||||
createIcon(deleteBtn, "trash-2", "af-btn-icon");
|
||||
deleteBtn.appendText(" Delete");
|
||||
deleteBtn.onclick = () => void deps.plugin.deleteAgent(agent.name);
|
||||
|
||||
// Tabs
|
||||
const tabs = page.createDiv({ cls: "af-detail-tabs" });
|
||||
const tabDefs = [
|
||||
{ id: "overview", label: "Overview", icon: "layout-dashboard" },
|
||||
{ id: "config", label: "Config", icon: "settings" },
|
||||
{ id: "runs", label: "Runs", icon: "scroll-text" },
|
||||
{ id: "memory", label: "Memory", icon: "file-text" },
|
||||
];
|
||||
for (const t of tabDefs) {
|
||||
const tabBtn = tabs.createEl("button", {
|
||||
cls: `af-detail-tab${deps.getDetailTab() === t.id ? " active" : ""}`,
|
||||
});
|
||||
createIcon(tabBtn, t.icon, "af-tab-icon");
|
||||
tabBtn.appendText(` ${t.label}`);
|
||||
tabBtn.onclick = () => {
|
||||
deps.setDetailTab(t.id);
|
||||
void deps.rerender();
|
||||
};
|
||||
}
|
||||
|
||||
const tabContent = page.createDiv({ cls: "af-detail-tab-content" });
|
||||
switch (deps.getDetailTab()) {
|
||||
case "overview":
|
||||
renderAgentOverviewTab(tabContent, deps, agent, agentRuns);
|
||||
break;
|
||||
case "config":
|
||||
renderAgentConfigTab(tabContent, deps, agent);
|
||||
break;
|
||||
case "runs":
|
||||
renderAgentRunsTab(tabContent, deps, agentRuns);
|
||||
break;
|
||||
case "memory":
|
||||
void renderAgentMemoryTab(tabContent, deps, agent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAgentOverviewTab(container: HTMLElement, deps: AgentDetailPageDeps, agent: AgentConfig, runs: RunLogData[]): void {
|
||||
// Stats row
|
||||
const statsRow = container.createDiv({ cls: "af-dash-grid" });
|
||||
const totalRuns = runs.length;
|
||||
const successRuns = runs.filter((r) => r.status === "success").length;
|
||||
const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0;
|
||||
const avgTime = totalRuns > 0 ? Math.round(runs.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns) : 0;
|
||||
// Comprehensive: this agent's run logs + its chat/channel usage over the window.
|
||||
const agentUsage = deps.plugin.runtime.getUsageRecords().filter((u) => u.agent === agent.name);
|
||||
const { tokens: totalTokens, cost: totalCostAgent } = deps.combinedTotals(runs, agentUsage);
|
||||
const costSuffixAgent = totalCostAgent > 0 ? ` \u00B7 $${totalCostAgent.toFixed(2)}` : "";
|
||||
|
||||
deps.renderStatCard(statsRow, "Total Runs", String(totalRuns), "", "activity", "all time");
|
||||
deps.renderStatCard(statsRow, "Success Rate", `${successRate}%`, "", "check-circle-2", `${successRuns}/${totalRuns}`);
|
||||
deps.renderStatCard(statsRow, "Avg Time", `${avgTime}s`, "", "clock", "per run");
|
||||
deps.renderStatCard(statsRow, "Total Tokens", deps.formatTokenCount(totalTokens), "", "zap", `all time${costSuffixAgent}`);
|
||||
|
||||
// Heartbeat — compact status only (instruction shown in Config tab)
|
||||
if (agent.isFolder && (agent.heartbeatBody.trim() || agent.heartbeatEnabled)) {
|
||||
const hbSection = container.createDiv({ cls: "af-section-card" });
|
||||
const hbHeader = hbSection.createDiv({ cls: "af-section-header" });
|
||||
const hbTitle = hbHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(hbTitle, "heart-pulse");
|
||||
hbTitle.appendText(" Heartbeat");
|
||||
|
||||
const hbActions = hbHeader.createDiv({ cls: "af-detail-header-actions" });
|
||||
const hbToggle = hbActions.createDiv({ cls: `af-agent-card-toggle${agent.heartbeatEnabled ? " on" : ""}` });
|
||||
hbToggle.onclick = async () => {
|
||||
const isOn = hbToggle.hasClass("on");
|
||||
await deps.plugin.repository.updateHeartbeat(agent.name, { enabled: !isOn });
|
||||
await deps.plugin.refreshFromVault();
|
||||
new Notice(`Heartbeat ${!isOn ? "enabled" : "paused"} for ${agent.name}`);
|
||||
};
|
||||
|
||||
const hbBody = hbSection.createDiv({ cls: "af-config-form" });
|
||||
deps.renderConfigRow(hbBody, "Schedule", deps.cronToHuman(agent.heartbeatSchedule));
|
||||
const nextRun = deps.plugin.runtime.getNextHeartbeat(agent.name);
|
||||
if (nextRun && agent.heartbeatEnabled) {
|
||||
deps.renderConfigRow(hbBody, "Next run", deps.timeUntil(nextRun));
|
||||
}
|
||||
if (agent.heartbeatChannel) {
|
||||
deps.renderConfigRow(hbBody, "Channel", agent.heartbeatChannel);
|
||||
}
|
||||
}
|
||||
|
||||
// Skills
|
||||
if (agent.skills.length > 0) {
|
||||
const skillsSection = container.createDiv({ cls: "af-section-card" });
|
||||
const skillsHeader = skillsSection.createDiv({ cls: "af-section-header" });
|
||||
const skillsTitle = skillsHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(skillsTitle, "puzzle");
|
||||
skillsTitle.appendText(" Skills");
|
||||
const skillsBody = skillsSection.createDiv({ cls: "af-detail-skills-list" });
|
||||
for (const skill of agent.skills) {
|
||||
skillsBody.createSpan({ cls: "af-skill-tag", text: skill });
|
||||
}
|
||||
}
|
||||
|
||||
// MCP Servers
|
||||
const agentMcpServers = agent.mcpServers ?? [];
|
||||
if (agentMcpServers.length > 0) {
|
||||
const mcpSection = container.createDiv({ cls: "af-section-card" });
|
||||
const mcpHeader = mcpSection.createDiv({ cls: "af-section-header" });
|
||||
const mcpTitle = mcpHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(mcpTitle, "plug");
|
||||
mcpTitle.appendText(" MCP Servers");
|
||||
const mcpBody = mcpSection.createDiv({ cls: "af-mcp-overview-list" });
|
||||
|
||||
const cachedServers = deps.plugin.repository.getMcpServers();
|
||||
for (const serverName of agentMcpServers) {
|
||||
const serverInfo = cachedServers.find((s) => s.name === serverName);
|
||||
const row = mcpBody.createDiv({ cls: "af-mcp-overview-row" });
|
||||
const dot = row.createSpan({
|
||||
cls: `af-mcp-status-dot ${serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "disconnected"}`,
|
||||
});
|
||||
dot.title = serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "unknown";
|
||||
row.createSpan({ cls: "af-mcp-overview-name", text: serverName });
|
||||
const toolCount = serverInfo?.toolDetails.length ?? serverInfo?.tools.length ?? 0;
|
||||
if (toolCount > 0) {
|
||||
row.createSpan({ cls: "af-mcp-overview-tools", text: `${toolCount} tools` });
|
||||
} else if (serverInfo && !serverInfo.enabled) {
|
||||
row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "disabled" });
|
||||
} else if (serverInfo?.status === "needs-auth") {
|
||||
row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "needs auth" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Permissions
|
||||
const hasPermRules = agent.permissionRules.allow.length > 0 || agent.permissionRules.deny.length > 0;
|
||||
if (hasPermRules || (agent.permissionMode && agent.permissionMode !== "default")) {
|
||||
const permSection = container.createDiv({ cls: "af-section-card" });
|
||||
const permHeader = permSection.createDiv({ cls: "af-section-header" });
|
||||
const permTitle = permHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(permTitle, "shield-check");
|
||||
permTitle.appendText(" Permissions");
|
||||
const permBody = permSection.createDiv({ cls: "af-config-form" });
|
||||
deps.renderConfigRow(permBody, "Mode", agent.permissionMode || "default");
|
||||
if (agent.permissionRules.allow.length > 0) {
|
||||
deps.renderConfigRow(permBody, "Allowed", agent.permissionRules.allow.join(", "));
|
||||
}
|
||||
if (agent.permissionRules.deny.length > 0) {
|
||||
deps.renderConfigRow(permBody, "Denied", agent.permissionRules.deny.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
// Recent runs
|
||||
const runsSection = container.createDiv({ cls: "af-section-card" });
|
||||
const runsHeader = runsSection.createDiv({ cls: "af-section-header" });
|
||||
const runsTitle = runsHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(runsTitle, "scroll-text");
|
||||
runsTitle.appendText(" Recent Runs");
|
||||
const runsBody = runsSection.createDiv({ cls: "af-timeline" });
|
||||
|
||||
if (runs.length === 0) {
|
||||
deps.renderEmptyState(runsBody, "scroll-text", "No runs yet", "");
|
||||
} else {
|
||||
for (const run of runs.slice(0, 5)) {
|
||||
deps.renderTimelineItem(runsBody, run);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderAgentConfigTab(container: HTMLElement, deps: AgentDetailPageDeps, agent: AgentConfig): void {
|
||||
const form = container.createDiv({ cls: "af-config-form" });
|
||||
|
||||
deps.renderConfigRow(form, "Name", agent.name);
|
||||
deps.renderConfigRow(form, "Description", agent.description ?? "");
|
||||
deps.renderConfigRow(form, "Model", agent.model);
|
||||
deps.renderConfigRow(form, "Timeout", `${agent.timeout}s`);
|
||||
deps.renderConfigRow(form, "Working Directory", agent.cwd ?? "(vault root)");
|
||||
deps.renderConfigRow(form, "Permission Mode", agent.permissionMode || "default");
|
||||
deps.renderConfigRow(form, "Approval Required", agent.approvalRequired.join(", ") || "none");
|
||||
if (agent.permissionRules.allow.length > 0) {
|
||||
deps.renderConfigRow(form, "Allowed Commands", agent.permissionRules.allow.join(", "));
|
||||
}
|
||||
if (agent.permissionRules.deny.length > 0) {
|
||||
deps.renderConfigRow(form, "Blocked Commands", agent.permissionRules.deny.join(", "));
|
||||
}
|
||||
deps.renderConfigRow(form, "Memory", agent.memory ? "Enabled" : "Disabled");
|
||||
deps.renderConfigRow(
|
||||
form,
|
||||
"Auto-compact",
|
||||
agent.autoCompactThreshold && agent.autoCompactThreshold > 0
|
||||
? `at ${agent.autoCompactThreshold}% context`
|
||||
: "disabled",
|
||||
);
|
||||
if (agent.wikiReferences && agent.wikiReferences.length > 0) {
|
||||
deps.renderConfigRow(
|
||||
form,
|
||||
"Wiki access",
|
||||
agent.wikiReferences.map((r) => r.agent).join(", "),
|
||||
);
|
||||
}
|
||||
deps.renderConfigRow(form, "Tags", agent.tags.join(", ") || "none");
|
||||
|
||||
const promptSection = form.createDiv({ cls: "af-config-prompt-section" });
|
||||
promptSection.createDiv({ cls: "af-slideover-section-title", text: "SYSTEM PROMPT" });
|
||||
promptSection.createDiv({ cls: "af-output-block", text: agent.body || "(empty)" });
|
||||
|
||||
if (agent.heartbeatBody.trim()) {
|
||||
const hbPromptSection = form.createDiv({ cls: "af-config-prompt-section" });
|
||||
hbPromptSection.createDiv({ cls: "af-slideover-section-title", text: "HEARTBEAT INSTRUCTION" });
|
||||
hbPromptSection.createDiv({ cls: "af-output-block", text: agent.heartbeatBody });
|
||||
}
|
||||
|
||||
const actions = form.createDiv({ cls: "af-slideover-actions" });
|
||||
const editBtn = actions.createEl("button", { cls: "af-btn-sm primary" });
|
||||
createIcon(editBtn, "edit", "af-btn-icon");
|
||||
editBtn.appendText(" Edit Agent");
|
||||
editBtn.onclick = () => deps.navigate("edit-agent", agent.name);
|
||||
}
|
||||
|
||||
function renderAgentRunsTab(container: HTMLElement, deps: AgentDetailPageDeps, runs: RunLogData[]): void {
|
||||
if (runs.length === 0) {
|
||||
deps.renderEmptyState(container, "scroll-text", "No runs yet", "Run this agent to see history");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
const item = container.createDiv({ cls: "af-run-list-item" });
|
||||
|
||||
const statusIcon = item.createDiv({ cls: `af-tl-icon ${deps.statusToTimelineClass(run.status)}` });
|
||||
setIcon(statusIcon, deps.statusToIconName(run.status));
|
||||
|
||||
const body = item.createDiv({ cls: "af-tl-body" });
|
||||
const titleRow = body.createDiv({ cls: "af-tl-title" });
|
||||
titleRow.createSpan({ text: run.task });
|
||||
titleRow.createSpan({ cls: `af-status-badge ${deps.statusToBadgeClass(run.status)}`, text: deps.statusToBadgeText(run.status) });
|
||||
|
||||
const meta = body.createDiv({ cls: "af-tl-meta" });
|
||||
meta.createSpan({ text: `${deps.formatStarted(run.started)} \u00B7 ${deps.formatDuration(run.durationSeconds)}` });
|
||||
if (run.tokensUsed) {
|
||||
meta.createSpan({ text: `${run.tokensUsed.toLocaleString()} tokens` });
|
||||
}
|
||||
|
||||
body.createDiv({ cls: "af-tl-desc", text: truncate(run.output, 120) });
|
||||
|
||||
item.onclick = () => deps.openSlideover(run);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderAgentMemoryTab(container: HTMLElement, deps: AgentDetailPageDeps, agent: AgentConfig): Promise<void> {
|
||||
if (!agent.memory) {
|
||||
deps.renderEmptyState(container, "file-text", "Memory disabled", "Enable memory in agent config");
|
||||
return;
|
||||
}
|
||||
|
||||
const wm = await deps.plugin.repository.readWorkingMemory(agent.name);
|
||||
|
||||
// Header: token usage vs budget + reflection status.
|
||||
const meta = container.createDiv({ cls: "af-form-help" });
|
||||
const used = wm?.tokenEstimate ?? 0;
|
||||
const reflectBits = agent.reflection.enabled
|
||||
? `reflection on (${agent.reflection.schedule})`
|
||||
: "reflection off";
|
||||
meta.setText(`~${used} / ${agent.memoryTokenBudget} tokens · ${reflectBits}`);
|
||||
|
||||
if (!wm || wm.sections.length === 0) {
|
||||
deps.renderEmptyState(container, "file-text", "No memories yet", "Agent will learn from runs");
|
||||
} else {
|
||||
const block = container.createDiv({ cls: "af-output-block" });
|
||||
block.setText(renderSections(wm.sections));
|
||||
}
|
||||
|
||||
const actions = container.createDiv({ cls: "af-slideover-actions" });
|
||||
|
||||
const reflectBtn = actions.createEl("button", { cls: "af-btn-sm" });
|
||||
createIcon(reflectBtn, "moon", "af-btn-icon");
|
||||
reflectBtn.appendText(" Reflect now");
|
||||
reflectBtn.onclick = async () => {
|
||||
reflectBtn.disabled = true;
|
||||
reflectBtn.setText(" Reflecting…");
|
||||
const res = await deps.plugin.runtime.runReflectionNow(agent.name);
|
||||
new Notice(`Agent Fleet: ${res.message}`);
|
||||
await renderAgentMemoryTab((container.empty(), container), deps, agent);
|
||||
};
|
||||
|
||||
const editBtn = actions.createEl("button", { cls: "af-btn-sm" });
|
||||
createIcon(editBtn, "external-link", "af-btn-icon");
|
||||
editBtn.appendText(" Open in Editor");
|
||||
editBtn.onclick = () =>
|
||||
void deps.plugin.openPath(deps.plugin.repository.getWorkingMemoryPath(agent.name));
|
||||
}
|
||||
191
src/views/pages/agentsPage.ts
Normal file
191
src/views/pages/agentsPage.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { Notice, setIcon } from "obsidian";
|
||||
import type { AgentConfig, AgentHealth, RunLogData, SkillConfig, TaskConfig } from "../../types";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import type { DashboardPageDeps } from "./shared";
|
||||
|
||||
/** View helpers the agents page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. */
|
||||
export interface AgentsPageDeps extends DashboardPageDeps {
|
||||
/** Navigate the dashboard to another page. */
|
||||
navigate: (page: "agent-detail" | "edit-agent", context?: string) => void;
|
||||
healthToClass: (status: AgentHealth) => string;
|
||||
/** Lucide icon / emoji / initials avatar renderer (owned by the view). */
|
||||
renderAgentAvatar: (el: HTMLElement, agent: AgentConfig) => void;
|
||||
/** Value + label stat cell shared with the channel cards (owned by the view). */
|
||||
renderAgentStat: (container: HTMLElement, value: string, label: string) => void;
|
||||
formatTokenCount: (tokens: number) => string;
|
||||
cronToHuman: (cron: string) => string;
|
||||
timeUntil: (date: Date) => string;
|
||||
}
|
||||
|
||||
export function renderAgentsPage(container: HTMLElement, deps: AgentsPageDeps): void {
|
||||
const page = container.createDiv({ cls: "af-agents-page" });
|
||||
const snapshot = deps.plugin.runtime.getSnapshot();
|
||||
|
||||
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
|
||||
toolbar.createDiv({ cls: "af-page-title", text: "Agents" });
|
||||
toolbar.createDiv({ cls: "af-toolbar-spacer" });
|
||||
|
||||
const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
|
||||
createIcon(newBtn, "plus", "af-btn-icon");
|
||||
newBtn.appendText(" New Agent");
|
||||
newBtn.onclick = () => void deps.plugin.createAgentTemplate();
|
||||
|
||||
const grid = page.createDiv({ cls: "af-agents-grid" });
|
||||
|
||||
if (snapshot.agents.length === 0) {
|
||||
deps.renderEmptyState(grid, "bot", "No agents configured", "Create your first agent to get started", {
|
||||
label: "New Agent",
|
||||
onClick: () => void deps.plugin.createAgentTemplate(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch runs once and group by agent — avoids re-filtering the full run
|
||||
// list for every card (O(agents × runs)).
|
||||
const runsByAgent = new Map<string, RunLogData[]>();
|
||||
for (const run of deps.plugin.runtime.getRecentRuns()) {
|
||||
const list = runsByAgent.get(run.agent);
|
||||
if (list) {
|
||||
list.push(run);
|
||||
} else {
|
||||
runsByAgent.set(run.agent, [run]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of snapshot.agents) {
|
||||
renderAgentCard(grid, deps, agent, snapshot, runsByAgent.get(agent.name) ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAgentCard(
|
||||
container: HTMLElement,
|
||||
deps: AgentsPageDeps,
|
||||
agent: AgentConfig,
|
||||
snapshot: { tasks: TaskConfig[]; skills: SkillConfig[] },
|
||||
agentRuns: RunLogData[],
|
||||
): void {
|
||||
const state = deps.plugin.runtime.getAgentState(agent.name);
|
||||
|
||||
const card = container.createDiv({ cls: `af-agent-card${agent.enabled ? "" : " disabled"}` });
|
||||
|
||||
// Header
|
||||
const header = card.createDiv({ cls: "af-agent-card-header" });
|
||||
const avatarCls = agent.enabled ? deps.healthToClass(state.status) : "disabled";
|
||||
const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` });
|
||||
deps.renderAgentAvatar(avatar, agent);
|
||||
|
||||
const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" });
|
||||
const nameRow = titleBlock.createDiv({ cls: "af-agent-card-name" });
|
||||
nameRow.appendText(agent.name);
|
||||
if (agent.heartbeatEnabled && agent.heartbeatSchedule) {
|
||||
const hbIcon = nameRow.createSpan({ cls: "af-heartbeat-indicator" });
|
||||
setIcon(hbIcon, "heart-pulse");
|
||||
hbIcon.title = `Heartbeat: ${agent.heartbeatSchedule}`;
|
||||
}
|
||||
titleBlock.createDiv({ cls: "af-agent-card-desc", text: agent.description ?? "No description" });
|
||||
|
||||
const toggle = header.createDiv({ cls: `af-agent-card-toggle${agent.enabled ? " on" : ""}` });
|
||||
toggle.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
void deps.plugin.toggleAgent(agent.name, !agent.enabled);
|
||||
};
|
||||
|
||||
// Stats
|
||||
const stats = card.createDiv({ cls: "af-agent-card-stats" });
|
||||
const totalRuns = agentRuns.length;
|
||||
const successRuns = agentRuns.filter((r) => r.status === "success").length;
|
||||
const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0;
|
||||
const avgTime =
|
||||
totalRuns > 0
|
||||
? Math.round(agentRuns.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns)
|
||||
: 0;
|
||||
const totalTokens = agentRuns.reduce((s, r) => s + (r.tokensUsed ?? 0), 0);
|
||||
|
||||
deps.renderAgentStat(stats, String(totalRuns), "Runs");
|
||||
deps.renderAgentStat(stats, `${successRate}%`, "Success");
|
||||
deps.renderAgentStat(stats, `${avgTime}s`, "Avg Time");
|
||||
deps.renderAgentStat(stats, deps.formatTokenCount(totalTokens), "Tokens");
|
||||
|
||||
// Skills
|
||||
if (agent.skills.length > 0) {
|
||||
const skillsRow = card.createDiv({ cls: "af-agent-card-skills" });
|
||||
for (const skill of agent.skills) {
|
||||
skillsRow.createSpan({ cls: "af-skill-tag", text: skill });
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat status — same gate + next-run source as the agent detail
|
||||
// Overview tab, with the same quick toggle (repository.updateHeartbeat).
|
||||
if (agent.isFolder && (agent.heartbeatBody.trim() || agent.heartbeatEnabled)) {
|
||||
const hbRow = card.createDiv({ cls: "af-agent-card-heartbeat" });
|
||||
const hbRowIcon = hbRow.createSpan({ cls: "af-agent-card-hb-icon" });
|
||||
setIcon(hbRowIcon, "heart-pulse");
|
||||
|
||||
const parts: string[] = [deps.cronToHuman(agent.heartbeatSchedule)];
|
||||
if (agent.heartbeatEnabled) {
|
||||
const nextHb = deps.plugin.runtime.getNextHeartbeat(agent.name);
|
||||
if (nextHb) parts.push(`next ${deps.timeUntil(nextHb)}`);
|
||||
} else {
|
||||
parts.push("paused");
|
||||
}
|
||||
hbRow.createSpan({
|
||||
cls: "af-agent-card-hb-text",
|
||||
text: `Heartbeat · ${parts.join(" · ")}`,
|
||||
});
|
||||
|
||||
const hbToggle = hbRow.createDiv({
|
||||
cls: `af-agent-card-toggle af-agent-card-toggle-sm${agent.heartbeatEnabled ? " on" : ""}`,
|
||||
});
|
||||
hbToggle.title = agent.heartbeatEnabled ? "Pause heartbeat" : "Enable heartbeat";
|
||||
hbToggle.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
void (async () => {
|
||||
await deps.plugin.repository.updateHeartbeat(agent.name, { enabled: !agent.heartbeatEnabled });
|
||||
await deps.plugin.refreshFromVault();
|
||||
new Notice(`Heartbeat ${!agent.heartbeatEnabled ? "enabled" : "paused"} for ${agent.name}`);
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footer = card.createDiv({ cls: "af-agent-card-footer" });
|
||||
const metaParts: string[] = [`Model: ${agent.model}`];
|
||||
if (agent.approvalRequired.length > 0) {
|
||||
metaParts.push(`Approval: ${agent.approvalRequired.join(", ")}`);
|
||||
}
|
||||
if (agent.memory) metaParts.push("Memory: on");
|
||||
if (!agent.enabled) metaParts.unshift("Disabled");
|
||||
footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") });
|
||||
|
||||
const actions = footer.createDiv({ cls: "af-agent-card-actions" });
|
||||
|
||||
if (!agent.enabled) {
|
||||
const enableBtn = actions.createEl("button", { cls: "af-btn-sm", text: "Enable" });
|
||||
enableBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
void deps.plugin.toggleAgent(agent.name, true);
|
||||
};
|
||||
}
|
||||
|
||||
const editBtn = actions.createEl("button", { cls: "af-btn-sm" });
|
||||
createIcon(editBtn, "edit", "af-btn-icon");
|
||||
editBtn.appendText(" Edit");
|
||||
editBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deps.navigate("edit-agent", agent.name);
|
||||
};
|
||||
|
||||
if (agent.enabled) {
|
||||
const runBtn = actions.createEl("button", { cls: "af-btn-sm primary" });
|
||||
createIcon(runBtn, "play", "af-btn-icon");
|
||||
runBtn.appendText(" Run");
|
||||
runBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
void deps.plugin.runAgentPrompt(agent.name);
|
||||
};
|
||||
}
|
||||
|
||||
card.onclick = () => deps.navigate("agent-detail", agent.name);
|
||||
}
|
||||
114
src/views/pages/approvalsPage.ts
Normal file
114
src/views/pages/approvalsPage.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { setIcon } from "obsidian";
|
||||
import type { RunLogData } from "../../types";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import type { DashboardPageDeps } from "./shared";
|
||||
|
||||
/** View helpers the approvals page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. */
|
||||
export interface ApprovalsPageDeps extends DashboardPageDeps {
|
||||
formatStarted: (iso: string) => string;
|
||||
/** Re-render the whole dashboard view (after an approval is resolved). */
|
||||
rerender: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function renderApprovalsPage(container: HTMLElement, deps: ApprovalsPageDeps): void {
|
||||
const page = container.createDiv({ cls: "af-approvals-page" });
|
||||
const runs = deps.plugin.runtime.getRecentRuns();
|
||||
|
||||
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
|
||||
toolbar.createDiv({ cls: "af-page-title", text: "Approvals" });
|
||||
toolbar.createDiv({ cls: "af-toolbar-spacer" });
|
||||
|
||||
// Pending approvals
|
||||
const pendingRuns = runs.filter((r) =>
|
||||
(r.approvals ?? []).some((a) => a.status === "pending"),
|
||||
);
|
||||
|
||||
if (pendingRuns.length > 0) {
|
||||
const pendingSection = page.createDiv({ cls: "af-section-card" });
|
||||
const pendingHeader = pendingSection.createDiv({ cls: "af-section-header" });
|
||||
const pendingTitle = pendingHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(pendingTitle, "alert-triangle");
|
||||
pendingTitle.appendText(` Pending (${pendingRuns.length})`);
|
||||
|
||||
const pendingBody = pendingSection.createDiv({ cls: "af-approvals-list" });
|
||||
for (const run of pendingRuns) {
|
||||
renderApprovalItem(pendingBody, deps, run, true);
|
||||
}
|
||||
} else {
|
||||
const emptySection = page.createDiv({ cls: "af-section-card" });
|
||||
deps.renderEmptyState(emptySection, "shield-check", "No pending approvals", "All clear!");
|
||||
}
|
||||
|
||||
// Resolved approvals history
|
||||
const resolvedRuns = runs.filter((r) =>
|
||||
(r.approvals ?? []).some((a) => a.status !== "pending"),
|
||||
);
|
||||
|
||||
if (resolvedRuns.length > 0) {
|
||||
const resolvedSection = page.createDiv({ cls: "af-section-card" });
|
||||
const resolvedHeader = resolvedSection.createDiv({ cls: "af-section-header" });
|
||||
const resolvedTitle = resolvedHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(resolvedTitle, "check-circle-2");
|
||||
resolvedTitle.appendText(" History");
|
||||
|
||||
const resolvedBody = resolvedSection.createDiv({ cls: "af-approvals-list" });
|
||||
for (const run of resolvedRuns.slice(0, 20)) {
|
||||
renderApprovalItem(resolvedBody, deps, run, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderApprovalItem(container: HTMLElement, deps: ApprovalsPageDeps, run: RunLogData, showActions: boolean): void {
|
||||
for (const approval of run.approvals ?? []) {
|
||||
if (showActions && approval.status !== "pending") continue;
|
||||
if (!showActions && approval.status === "pending") continue;
|
||||
|
||||
const item = container.createDiv({ cls: "af-approval-item" });
|
||||
|
||||
const iconEl = item.createDiv({ cls: "af-approval-item-icon" });
|
||||
if (approval.status === "pending") {
|
||||
setIcon(iconEl, "shield-check");
|
||||
iconEl.addClass("pending");
|
||||
} else if (approval.status === "approved") {
|
||||
setIcon(iconEl, "check-circle-2");
|
||||
iconEl.addClass("approved");
|
||||
} else {
|
||||
setIcon(iconEl, "x-circle");
|
||||
iconEl.addClass("rejected");
|
||||
}
|
||||
|
||||
const body = item.createDiv({ cls: "af-approval-item-body" });
|
||||
body.createDiv({
|
||||
cls: "af-approval-item-title",
|
||||
text: `${run.agent} \u2192 ${approval.tool}`,
|
||||
});
|
||||
body.createDiv({
|
||||
cls: "af-approval-item-meta",
|
||||
text: `Task: ${run.task} \u00B7 ${approval.command ?? "no command"} \u00B7 ${deps.formatStarted(run.started)}`,
|
||||
});
|
||||
if (approval.reason) {
|
||||
body.createDiv({ cls: "af-approval-item-reason", text: `Reason: ${approval.reason}` });
|
||||
}
|
||||
|
||||
if (showActions && approval.status === "pending") {
|
||||
const actions = item.createDiv({ cls: "af-approval-item-actions" });
|
||||
const approveBtn = actions.createEl("button", { cls: "af-btn-approve" });
|
||||
createIcon(approveBtn, "check-circle-2", "af-btn-icon");
|
||||
approveBtn.appendText(" Approve");
|
||||
approveBtn.onclick = () =>
|
||||
void deps.plugin.runtime
|
||||
.resolveApproval(run, approval.tool, "approved")
|
||||
.then(() => deps.rerender());
|
||||
|
||||
const rejectBtn = actions.createEl("button", { cls: "af-btn-reject" });
|
||||
createIcon(rejectBtn, "x-circle", "af-btn-icon");
|
||||
rejectBtn.appendText(" Reject");
|
||||
rejectBtn.onclick = () =>
|
||||
void deps.plugin.runtime
|
||||
.resolveApproval(run, approval.tool, "rejected")
|
||||
.then(() => deps.rerender());
|
||||
}
|
||||
}
|
||||
}
|
||||
146
src/views/pages/channelsPage.ts
Normal file
146
src/views/pages/channelsPage.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { setIcon } from "obsidian";
|
||||
import type { ChannelConfig } from "../../types";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import { channelStatusToAvatarClass } from "../forms/channelForm";
|
||||
import type { DashboardPageDeps } from "./shared";
|
||||
|
||||
/** View helpers the channels page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. */
|
||||
export interface ChannelsPageDeps extends DashboardPageDeps {
|
||||
/** Navigate the dashboard to another page. */
|
||||
navigate: (page: "create-channel" | "edit-channel", context?: string) => void;
|
||||
/** Value + label stat cell shared with the agent cards (owned by the view). */
|
||||
renderAgentStat: (container: HTMLElement, value: string, label: string) => void;
|
||||
}
|
||||
|
||||
export function renderChannelsPage(container: HTMLElement, deps: ChannelsPageDeps): void {
|
||||
const page = container.createDiv({ cls: "af-agents-page" });
|
||||
const snapshot = deps.plugin.runtime.getSnapshot();
|
||||
|
||||
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
|
||||
toolbar.createDiv({ cls: "af-page-title", text: "Channels" });
|
||||
toolbar.createDiv({ cls: "af-toolbar-spacer" });
|
||||
|
||||
const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
|
||||
createIcon(newBtn, "plus", "af-btn-icon");
|
||||
newBtn.appendText(" New Channel");
|
||||
newBtn.onclick = () => deps.navigate("create-channel");
|
||||
|
||||
const grid = page.createDiv({ cls: "af-agents-grid" });
|
||||
|
||||
if (snapshot.channels.length === 0) {
|
||||
deps.renderEmptyState(
|
||||
grid,
|
||||
"radio",
|
||||
"No channels configured",
|
||||
"Connect an agent to Slack or another chat platform",
|
||||
{ label: "New Channel", onClick: () => deps.navigate("create-channel") },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const channel of snapshot.channels) {
|
||||
renderChannelCard(grid, deps, channel, snapshot.validationIssues);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChannelCard(
|
||||
container: HTMLElement,
|
||||
deps: ChannelsPageDeps,
|
||||
channel: ChannelConfig,
|
||||
validationIssues: Array<{ path: string; message: string }>,
|
||||
): void {
|
||||
const status = deps.plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled";
|
||||
const avatarCls = channelStatusToAvatarClass(status);
|
||||
const cardCls = channel.enabled && status !== "disabled" ? "af-agent-card" : "af-agent-card disabled";
|
||||
|
||||
const card = container.createDiv({ cls: cardCls });
|
||||
card.setCssStyles({ cursor: "default" }); // No card-level click — channels have no detail page
|
||||
|
||||
// Header — avatar (icon + status color) + name/agent + type pill
|
||||
const header = card.createDiv({ cls: "af-agent-card-header" });
|
||||
const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` });
|
||||
setIcon(avatar, "radio");
|
||||
|
||||
const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" });
|
||||
titleBlock.createDiv({ cls: "af-agent-card-name", text: channel.name });
|
||||
titleBlock.createDiv({
|
||||
cls: "af-agent-card-desc",
|
||||
text: `Default: ${channel.defaultAgent}`,
|
||||
});
|
||||
|
||||
const statusPill = header.createSpan({ cls: `af-pill ${channelStatusPillColor(status)}` });
|
||||
statusPill.createSpan({ cls: "af-dot" });
|
||||
statusPill.appendText(` ${status}`);
|
||||
|
||||
// Agents — show all allowed agents as tags (same style as skill tags on agent cards)
|
||||
if (channel.allowedAgents.length > 0) {
|
||||
const agentsRow = card.createDiv({ cls: "af-agent-card-skills" });
|
||||
for (const name of channel.allowedAgents) {
|
||||
const tag = agentsRow.createSpan({ cls: "af-skill-tag", text: name });
|
||||
if (name === channel.defaultAgent) {
|
||||
tag.setCssStyles({ fontWeight: "700" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats — sessions, messages in/out, allowlist
|
||||
const stats = card.createDiv({ cls: "af-agent-card-stats" });
|
||||
const sessionCount = deps.plugin.channelManager?.getSessionCount(channel.name) ?? 0;
|
||||
const metrics = deps.plugin.channelManager?.getMetrics(channel.name);
|
||||
const agentCount = channel.allowedAgents.length > 0
|
||||
? String(channel.allowedAgents.length)
|
||||
: "all";
|
||||
|
||||
deps.renderAgentStat(stats, agentCount, "Agents");
|
||||
deps.renderAgentStat(stats, String(sessionCount), "Sessions");
|
||||
deps.renderAgentStat(stats, String(metrics?.messagesReceived ?? 0), "In");
|
||||
deps.renderAgentStat(stats, String(metrics?.messagesSent ?? 0), "Out");
|
||||
|
||||
// Footer — meta text + edit action
|
||||
const footer = card.createDiv({ cls: "af-agent-card-footer" });
|
||||
const metaParts: string[] = [channel.type];
|
||||
if (!channel.enabled) metaParts.push("disabled");
|
||||
if (channel.allowedUsers.length > 0) metaParts.push(`${channel.allowedUsers.length} user(s)`);
|
||||
else metaParts.push("allowlist empty");
|
||||
footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") });
|
||||
|
||||
const actions = footer.createDiv({ cls: "af-agent-card-actions" });
|
||||
const editBtn = actions.createEl("button", { cls: "af-btn-sm" });
|
||||
createIcon(editBtn, "edit", "af-btn-icon");
|
||||
editBtn.appendText(" Edit");
|
||||
editBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deps.navigate("edit-channel", channel.name);
|
||||
};
|
||||
|
||||
// Validation issues — red-tinted block below the footer
|
||||
const issues = validationIssues.filter((i) => i.path === channel.filePath);
|
||||
if (issues.length > 0) {
|
||||
const issuesBox = card.createDiv({ cls: "af-channel-issues" });
|
||||
for (const issue of issues) {
|
||||
issuesBox.createDiv({ cls: "af-channel-issue-row", text: issue.message });
|
||||
}
|
||||
}
|
||||
|
||||
// No card-level click — channels have no detail page. Editing is via the edit button.
|
||||
}
|
||||
|
||||
/** Map a channel status to an `af-pill` color variant for the header badge. */
|
||||
function channelStatusPillColor(status: string): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "green";
|
||||
case "connecting":
|
||||
case "reconnecting":
|
||||
return "blue";
|
||||
case "needs-auth":
|
||||
case "error":
|
||||
return "red";
|
||||
case "stopped":
|
||||
case "disabled":
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
358
src/views/pages/mcpPage.ts
Normal file
358
src/views/pages/mcpPage.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import { Notice, setIcon } from "obsidian";
|
||||
import type { McpServer, McpTool } from "../../types";
|
||||
import { truncate } from "../../utils/markdown";
|
||||
import { splitLines } from "../../utils/platform";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import type { DashboardPageDeps } from "./shared";
|
||||
|
||||
/** View helpers the MCP servers page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. The probe cache and in-flight auth set are view state — the page
|
||||
* receives the live references so results survive re-renders. */
|
||||
export interface McpPageDeps extends DashboardPageDeps {
|
||||
/** Navigate the dashboard to another page. */
|
||||
navigate: (page: "add-mcp-server") => void;
|
||||
/** Label + monospace value row used by the detail slideover (owned by the view). */
|
||||
renderDetailRow: (container: HTMLElement, label: string, value: string) => void;
|
||||
/** Re-render the whole dashboard view. */
|
||||
rerender: () => Promise<void>;
|
||||
/** View content element the detail slideover overlay attaches to. */
|
||||
contentEl: HTMLElement;
|
||||
/** Transient per-server tool probe results (name → tools), owned by the view. */
|
||||
mcpProbeCache: Map<string, McpTool[]>;
|
||||
/** Names of servers with an OAuth flow in flight, owned by the view. */
|
||||
authenticatingServers: Set<string>;
|
||||
}
|
||||
|
||||
export function renderMcpPage(container: HTMLElement, deps: McpPageDeps): void {
|
||||
const page = container.createDiv({ cls: "af-agents-page" });
|
||||
|
||||
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
|
||||
toolbar.createDiv({ cls: "af-page-title", text: "MCP Servers" });
|
||||
toolbar.createDiv({ cls: "af-toolbar-spacer" });
|
||||
|
||||
const addBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
|
||||
createIcon(addBtn, "plus", "af-btn-icon");
|
||||
addBtn.appendText(" Add Server");
|
||||
addBtn.onclick = () => deps.navigate("add-mcp-server");
|
||||
|
||||
const servers = deps.plugin.repository.getMcpServers();
|
||||
|
||||
if (servers.length === 0) {
|
||||
deps.renderEmptyState(page, "plug", "No MCP servers registered", "Click 'Add Server' above to register one.");
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = page.createDiv({ cls: "af-agents-grid" });
|
||||
for (const server of servers) {
|
||||
renderMcpCard(grid, deps, server);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the server has a stored auth token (OAuth/static bearer). */
|
||||
function mcpHasToken(deps: McpPageDeps, server: McpServer): boolean {
|
||||
return deps.plugin.mcpAuth.hasToken(server.name);
|
||||
}
|
||||
|
||||
/** Whether an http/sse server still needs the user to authenticate (auth is
|
||||
* oauth/bearer but no token is stored yet). */
|
||||
function mcpNeedsAuth(deps: McpPageDeps, server: McpServer): boolean {
|
||||
return (
|
||||
server.type !== "stdio" &&
|
||||
(server.auth === "oauth" || server.auth === "bearer") &&
|
||||
!mcpHasToken(deps, server)
|
||||
);
|
||||
}
|
||||
|
||||
function renderMcpCard(container: HTMLElement, deps: McpPageDeps, server: McpServer): void {
|
||||
const card = container.createDiv({ cls: `af-mcp-card${server.enabled ? "" : " af-mcp-card-disabled"}` });
|
||||
const needsAuth = mcpNeedsAuth(deps, server);
|
||||
|
||||
const header = card.createDiv({ cls: "af-agent-card-header" });
|
||||
const statusClass = !server.enabled ? "disabled" : needsAuth ? "pending" : "idle";
|
||||
const avatarEl = header.createDiv({ cls: `af-agent-card-avatar ${statusClass}` });
|
||||
setIcon(avatarEl, "plug");
|
||||
|
||||
const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" });
|
||||
titleBlock.createDiv({ cls: "af-agent-card-name", text: server.name });
|
||||
|
||||
const metaRow = titleBlock.createDiv({ cls: "af-agent-card-desc af-mcp-meta" });
|
||||
metaRow.createSpan({ cls: "af-mcp-type-badge", text: server.type });
|
||||
if (server.source === "imported") {
|
||||
metaRow.createSpan({ cls: "af-badge", text: "imported" });
|
||||
}
|
||||
|
||||
// Enable/disable toggle — writes the registry file frontmatter.
|
||||
const toggle = header.createDiv({ cls: `af-agent-card-toggle${server.enabled ? " on" : ""}` });
|
||||
toggle.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
void deps.plugin.repository.setMcpServerEnabled(server.name, !server.enabled).then(async () => {
|
||||
await deps.plugin.refreshFromVault();
|
||||
void deps.rerender();
|
||||
});
|
||||
};
|
||||
|
||||
// Status badge
|
||||
const statusBadge = card.createDiv({ cls: `af-mcp-status-badge ${!server.enabled ? "disabled" : needsAuth ? "needs-auth" : "connected"}` });
|
||||
const statusIcon = statusBadge.createSpan();
|
||||
if (!server.enabled) {
|
||||
setIcon(statusIcon, "pause");
|
||||
statusBadge.createSpan({ text: " Disabled" });
|
||||
} else if (needsAuth) {
|
||||
setIcon(statusIcon, "alert-circle");
|
||||
statusBadge.createSpan({ text: " Needs auth" });
|
||||
} else {
|
||||
setIcon(statusIcon, "check-circle");
|
||||
statusBadge.createSpan({ text: server.type === "stdio" ? " Enabled" : " Authenticated" });
|
||||
}
|
||||
|
||||
if (server.description) {
|
||||
const desc = truncateDescription(server.description, 120);
|
||||
card.createDiv({ cls: "af-mcp-description", text: desc });
|
||||
}
|
||||
|
||||
const urlOrCmd = server.url ?? server.command ?? "";
|
||||
if (urlOrCmd) {
|
||||
card.createDiv({ cls: "af-mcp-command", text: truncate(urlOrCmd, 60) });
|
||||
}
|
||||
|
||||
// Tool count from the on-demand probe cache (if probed this session).
|
||||
const probed = deps.mcpProbeCache.get(server.name);
|
||||
if (probed && probed.length > 0) {
|
||||
const toolFooter = card.createDiv({ cls: "af-mcp-tool-footer" });
|
||||
const toolCount = toolFooter.createDiv({ cls: "af-mcp-tool-count" });
|
||||
const toolIcon = toolCount.createSpan();
|
||||
setIcon(toolIcon, "wrench");
|
||||
toolCount.createSpan({ text: ` ${probed.length} tools` });
|
||||
const chips = toolFooter.createDiv({ cls: "af-mcp-tool-chips" });
|
||||
for (const t of probed.slice(0, 4)) {
|
||||
chips.createSpan({ cls: "af-mcp-tool-chip", text: t.name });
|
||||
}
|
||||
if (probed.length > 4) {
|
||||
chips.createSpan({ cls: "af-mcp-tool-chip af-mcp-tool-chip-more", text: `+${probed.length - 4}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Auth / authenticating indicator for http/sse servers.
|
||||
if (deps.authenticatingServers.has(server.name)) {
|
||||
const authRow = card.createDiv({ cls: "af-mcp-auth-row" });
|
||||
const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary", attr: { disabled: "true" } });
|
||||
const spinIcon = authBtn.createSpan({ cls: "af-spin" });
|
||||
setIcon(spinIcon, "loader-2");
|
||||
authBtn.appendText(" Authenticating…");
|
||||
} else if (server.enabled && needsAuth && server.auth === "oauth") {
|
||||
const authRow = card.createDiv({ cls: "af-mcp-auth-row" });
|
||||
const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary" });
|
||||
const authIcon = authBtn.createSpan();
|
||||
setIcon(authIcon, "key");
|
||||
authBtn.appendText(" Authenticate");
|
||||
authBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
void authenticateMcpServer(deps, server);
|
||||
};
|
||||
}
|
||||
|
||||
card.onclick = () => openMcpDetailSlideover(deps, server);
|
||||
}
|
||||
|
||||
async function authenticateMcpServer(deps: McpPageDeps, server: McpServer): Promise<void> {
|
||||
if (!server.url) {
|
||||
new Notice("No URL found for this server — can't authenticate.");
|
||||
return;
|
||||
}
|
||||
|
||||
deps.authenticatingServers.add(server.name);
|
||||
void deps.rerender();
|
||||
|
||||
new Notice(`Authenticating ${server.name}… Complete authorization in your browser.`, 10000);
|
||||
|
||||
try {
|
||||
const transport = server.type === "sse" ? "sse" : "http";
|
||||
await deps.plugin.mcpManager.authenticateServer(server.name, server.url, transport);
|
||||
new Notice(`${server.name} authenticated successfully!`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Authentication failed: ${msg}`, 8000);
|
||||
} finally {
|
||||
deps.authenticatingServers.delete(server.name);
|
||||
void deps.rerender();
|
||||
}
|
||||
}
|
||||
|
||||
/** On-demand tool probe for the MCP detail view. Stores results in the
|
||||
* transient cache and re-renders. */
|
||||
async function probeMcpServer(deps: McpPageDeps, server: McpServer): Promise<void> {
|
||||
try {
|
||||
const tools = await deps.plugin.mcpManager.probeServer(server);
|
||||
deps.mcpProbeCache.set(server.name, tools);
|
||||
if (tools.length === 0) new Notice(`No tools discovered for ${server.name}.`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Probe failed: ${truncate(msg, 150)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateDescription(text: string, maxLen: number): string {
|
||||
// Take first sentence or first line, whichever is shorter
|
||||
const firstLine = splitLines(text)[0] ?? text;
|
||||
const firstSentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
|
||||
const candidate = firstSentence.length < firstLine.length ? firstSentence : firstLine;
|
||||
if (candidate.length <= maxLen) return candidate;
|
||||
return candidate.slice(0, maxLen - 1) + "…";
|
||||
}
|
||||
|
||||
function openMcpDetailSlideover(deps: McpPageDeps, server: McpServer): void {
|
||||
deps.contentEl.querySelector(".af-slideover-overlay")?.remove();
|
||||
|
||||
const overlay = deps.contentEl.createDiv({ cls: "af-slideover-overlay" });
|
||||
const panel = overlay.createDiv({ cls: "af-slideover" });
|
||||
|
||||
const header = panel.createDiv({ cls: "af-slideover-header" });
|
||||
header.createDiv({ cls: "af-slideover-title", text: server.name });
|
||||
const closeBtn = header.createEl("button", { cls: "clickable-icon" });
|
||||
setIcon(closeBtn, "cross");
|
||||
closeBtn.onclick = () => overlay.remove();
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||
|
||||
const body = panel.createDiv({ cls: "af-slideover-body" });
|
||||
|
||||
// Description
|
||||
if (server.description) {
|
||||
const descSection = body.createDiv({ cls: "af-slideover-section" });
|
||||
descSection.createDiv({ cls: "af-slideover-section-title", text: "DESCRIPTION" });
|
||||
descSection.createDiv({ cls: "af-mcp-detail-description", text: server.description });
|
||||
}
|
||||
|
||||
// Server info
|
||||
const infoSection = body.createDiv({ cls: "af-slideover-section" });
|
||||
infoSection.createDiv({ cls: "af-slideover-section-title", text: "SERVER INFO" });
|
||||
deps.renderDetailRow(infoSection, "Name", server.name);
|
||||
deps.renderDetailRow(infoSection, "Transport", server.type);
|
||||
deps.renderDetailRow(infoSection, "Enabled", server.enabled ? "yes" : "no");
|
||||
if (server.type !== "stdio") {
|
||||
deps.renderDetailRow(infoSection, "Auth", server.auth ?? "none");
|
||||
deps.renderDetailRow(infoSection, "Authenticated", mcpHasToken(deps, server) ? "yes" : "no");
|
||||
}
|
||||
if (server.source) deps.renderDetailRow(infoSection, "Source", server.source);
|
||||
if (server.url) deps.renderDetailRow(infoSection, "URL", server.url);
|
||||
if (server.command) deps.renderDetailRow(infoSection, "Command", server.command);
|
||||
if (server.args && server.args.length > 0) deps.renderDetailRow(infoSection, "Args", server.args.join(" "));
|
||||
|
||||
// Tools — populated by the on-demand probe (registry definitions carry no
|
||||
// probed tools until the user clicks "Probe tools").
|
||||
const probedTools = deps.mcpProbeCache.get(server.name) ?? [];
|
||||
const toolsSection = body.createDiv({ cls: "af-slideover-section" });
|
||||
const toolsTitleRow = toolsSection.createDiv({ cls: "af-slideover-section-title" });
|
||||
toolsTitleRow.setText(`TOOLS (${probedTools.length})`);
|
||||
const probeBtn = toolsSection.createEl("button", { cls: "af-btn-sm" });
|
||||
const probeIcon = probeBtn.createSpan();
|
||||
setIcon(probeIcon, "wrench");
|
||||
probeBtn.appendText(" Probe tools");
|
||||
probeBtn.onclick = async () => {
|
||||
probeBtn.disabled = true;
|
||||
probeBtn.setText(" Probing…");
|
||||
await probeMcpServer(deps, server);
|
||||
overlay.remove();
|
||||
openMcpDetailSlideover(deps, server);
|
||||
};
|
||||
|
||||
if (probedTools.length > 0) {
|
||||
for (const tool of probedTools) {
|
||||
const toolItem = toolsSection.createDiv({ cls: "af-mcp-tool-detail" });
|
||||
const toolHeader = toolItem.createDiv({ cls: "af-mcp-tool-detail-header" });
|
||||
const toolNameEl = toolHeader.createSpan({ cls: "af-mcp-tool-detail-name" });
|
||||
const toolNameIcon = toolNameEl.createSpan();
|
||||
setIcon(toolNameIcon, "wrench");
|
||||
toolNameEl.createSpan({ text: ` ${tool.name}` });
|
||||
|
||||
if (tool.inputSchema) {
|
||||
const params = (tool.inputSchema as { required?: string[] }).required ?? [];
|
||||
if (params.length > 0) {
|
||||
toolHeader.createSpan({
|
||||
cls: "af-mcp-tool-param-count",
|
||||
text: `${params.length} param${params.length !== 1 ? "s" : ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tool.description) {
|
||||
// Show first 2 lines of description, rest in collapsible
|
||||
const descLines = splitLines(tool.description).filter((l) => l.trim());
|
||||
const shortDesc = descLines.slice(0, 2).join(" ").trim();
|
||||
const hasMore = descLines.length > 2;
|
||||
|
||||
if (hasMore) {
|
||||
const details = toolItem.createEl("details", { cls: "af-mcp-tool-detail-desc" });
|
||||
details.createEl("summary", { text: truncateDescription(shortDesc, 200) });
|
||||
details.createDiv({ cls: "af-mcp-tool-detail-full", text: tool.description });
|
||||
} else {
|
||||
toolItem.createDiv({ cls: "af-mcp-tool-detail-desc", text: shortDesc });
|
||||
}
|
||||
}
|
||||
|
||||
// Input schema params
|
||||
if (tool.inputSchema) {
|
||||
const props = (tool.inputSchema as { properties?: Record<string, { type?: string; description?: string }> }).properties;
|
||||
const required = new Set((tool.inputSchema as { required?: string[] }).required ?? []);
|
||||
if (props && Object.keys(props).length > 0) {
|
||||
const paramsEl = toolItem.createDiv({ cls: "af-mcp-tool-params" });
|
||||
for (const [paramName, paramDef] of Object.entries(props)) {
|
||||
const paramEl = paramsEl.createDiv({ cls: "af-mcp-tool-param" });
|
||||
paramEl.createSpan({ cls: "af-mcp-tool-param-name", text: paramName });
|
||||
if (paramDef.type) {
|
||||
paramEl.createSpan({ cls: "af-mcp-tool-param-type", text: paramDef.type });
|
||||
}
|
||||
if (required.has(paramName)) {
|
||||
paramEl.createSpan({ cls: "af-mcp-tool-param-required", text: "required" });
|
||||
}
|
||||
if (paramDef.description) {
|
||||
paramEl.createSpan({
|
||||
cls: "af-mcp-tool-param-desc",
|
||||
text: truncate(paramDef.description, 80),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toolsSection.createDiv({
|
||||
cls: "af-form-hint",
|
||||
text: "Click \"Probe tools\" to discover the tools this server exposes.",
|
||||
});
|
||||
}
|
||||
|
||||
// Actions section
|
||||
const actionsSection = body.createDiv({ cls: "af-slideover-section" });
|
||||
actionsSection.createDiv({ cls: "af-slideover-section-title", text: "ACTIONS" });
|
||||
|
||||
if (server.enabled && server.url && server.auth === "oauth" && !mcpHasToken(deps, server)) {
|
||||
const authBtn = actionsSection.createEl("button", { cls: "af-btn-sm primary" });
|
||||
const authIcon = authBtn.createSpan();
|
||||
setIcon(authIcon, "key");
|
||||
authBtn.appendText(" Authenticate");
|
||||
authBtn.onclick = () => {
|
||||
overlay.remove();
|
||||
void authenticateMcpServer(deps, server);
|
||||
};
|
||||
}
|
||||
|
||||
const removeBtn = actionsSection.createEl("button", { cls: "af-btn-sm danger" });
|
||||
const removeIcon = removeBtn.createSpan();
|
||||
setIcon(removeIcon, "trash-2");
|
||||
removeBtn.appendText(" Remove Server");
|
||||
removeBtn.onclick = async () => {
|
||||
try {
|
||||
await deps.plugin.repository.deleteMcpServer(server.name);
|
||||
deps.plugin.mcpAuth.removeToken(server.name);
|
||||
deps.mcpProbeCache.delete(server.name);
|
||||
new Notice(`Server "${server.name}" removed.`);
|
||||
overlay.remove();
|
||||
await deps.plugin.refreshFromVault();
|
||||
void deps.rerender();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Failed to remove server: ${msg}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
77
src/views/pages/runsPage.ts
Normal file
77
src/views/pages/runsPage.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { setIcon } from "obsidian";
|
||||
import type { RunLogData } from "../../types";
|
||||
import type { DashboardPageDeps } from "./shared";
|
||||
|
||||
/** View helpers the run-history page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. */
|
||||
export interface RunsPageDeps extends DashboardPageDeps {
|
||||
/** Navigate the dashboard to another page. */
|
||||
navigate: (page: "agent-detail", context?: string) => void;
|
||||
/** Open the run-details slideover (owned by the view). */
|
||||
openSlideover: (run: RunLogData) => void;
|
||||
formatStarted: (iso: string) => string;
|
||||
formatDuration: (seconds: number) => string;
|
||||
statusToBadgeClass: (status: string) => string;
|
||||
statusToIconName: (status: string) => string;
|
||||
statusToBadgeText: (status: string) => string;
|
||||
}
|
||||
|
||||
export function renderRunsPage(container: HTMLElement, deps: RunsPageDeps): void {
|
||||
const page = container.createDiv({ cls: "af-runs-page" });
|
||||
const runs = deps.plugin.runtime.getRecentRuns();
|
||||
|
||||
const toolbar = page.createDiv({ cls: "af-runs-toolbar" });
|
||||
toolbar.createDiv({ cls: "af-page-title", text: "Run History" });
|
||||
toolbar.createDiv({ cls: "af-toolbar-spacer" });
|
||||
|
||||
const tableWrap = page.createDiv({ cls: "af-runs-table" });
|
||||
|
||||
if (runs.length === 0) {
|
||||
deps.renderEmptyState(tableWrap, "scroll-text", "No runs yet", "Run an agent to see history here");
|
||||
return;
|
||||
}
|
||||
|
||||
const table = tableWrap.createEl("table");
|
||||
const thead = table.createEl("thead");
|
||||
const headerRow = thead.createEl("tr");
|
||||
for (const col of ["Status", "Agent", "Task", "Started", "Duration", "Tokens", "Model"]) {
|
||||
headerRow.createEl("th", { text: col });
|
||||
}
|
||||
|
||||
const tbody = table.createEl("tbody");
|
||||
for (const run of runs.slice(0, 50)) {
|
||||
renderRunRow(tbody, deps, run);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRunRow(tbody: HTMLElement, deps: RunsPageDeps, run: RunLogData): void {
|
||||
const row = tbody.createEl("tr");
|
||||
|
||||
const statusTd = row.createEl("td");
|
||||
const badge = statusTd.createSpan({
|
||||
cls: `af-status-badge ${deps.statusToBadgeClass(run.status)}`,
|
||||
});
|
||||
const badgeIcon = badge.createSpan();
|
||||
setIcon(badgeIcon, deps.statusToIconName(run.status));
|
||||
badge.appendText(` ${deps.statusToBadgeText(run.status)}`);
|
||||
|
||||
const agentTd = row.createEl("td", { cls: "af-agent-link" });
|
||||
agentTd.setText(run.agent);
|
||||
agentTd.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deps.navigate("agent-detail", run.agent);
|
||||
};
|
||||
|
||||
row.createEl("td", { text: run.task });
|
||||
row.createEl("td", { cls: "af-mono", text: deps.formatStarted(run.started) });
|
||||
row.createEl("td", { cls: "af-mono", text: deps.formatDuration(run.durationSeconds) });
|
||||
row.createEl("td", {
|
||||
cls: "af-mono",
|
||||
text: run.tokensUsed ? run.tokensUsed.toLocaleString() : "\u2014",
|
||||
});
|
||||
row.createEl("td", { cls: "af-mono", text: run.model });
|
||||
|
||||
row.setCssStyles({ cursor: "pointer" });
|
||||
row.onclick = () => deps.openSlideover(run);
|
||||
}
|
||||
21
src/views/pages/shared.ts
Normal file
21
src/views/pages/shared.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type AgentFleetPlugin from "../../main";
|
||||
|
||||
/**
|
||||
* View helpers every extracted page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. Each page module extends this with its own (narrowly typed)
|
||||
* `navigate` delegate and any page-specific extras. Shared state (ticker
|
||||
* registries, detail context, probe caches) stays on the view and is handed
|
||||
* to pages through these deps or as parameters.
|
||||
*/
|
||||
export interface DashboardPageDeps {
|
||||
plugin: AgentFleetPlugin;
|
||||
/** Standard icon + label + optional CTA empty-state block (owned by the view). */
|
||||
renderEmptyState: (
|
||||
container: HTMLElement,
|
||||
iconName: string,
|
||||
label: string,
|
||||
sublabel: string,
|
||||
action?: { label: string; onClick: () => void },
|
||||
) => void;
|
||||
}
|
||||
76
src/views/pages/skillsPage.ts
Normal file
76
src/views/pages/skillsPage.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { setIcon } from "obsidian";
|
||||
import type { AgentConfig, SkillConfig } from "../../types";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import type { DashboardPageDeps } from "./shared";
|
||||
|
||||
/** View helpers the skills-library page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. */
|
||||
export interface SkillsPageDeps extends DashboardPageDeps {
|
||||
/** Navigate the dashboard to another page. */
|
||||
navigate: (page: "edit-skill", context?: string) => void;
|
||||
}
|
||||
|
||||
export function renderSkillsPage(container: HTMLElement, deps: SkillsPageDeps): void {
|
||||
const page = container.createDiv({ cls: "af-skills-page" });
|
||||
const snapshot = deps.plugin.runtime.getSnapshot();
|
||||
|
||||
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
|
||||
toolbar.createDiv({ cls: "af-page-title", text: "Skills Library" });
|
||||
toolbar.createDiv({ cls: "af-toolbar-spacer" });
|
||||
|
||||
const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
|
||||
createIcon(newBtn, "plus", "af-btn-icon");
|
||||
newBtn.appendText(" New Skill");
|
||||
newBtn.onclick = () => void deps.plugin.createSkillTemplate();
|
||||
|
||||
const grid = page.createDiv({ cls: "af-skills-grid" });
|
||||
|
||||
if (snapshot.skills.length === 0) {
|
||||
deps.renderEmptyState(grid, "puzzle", "No skills yet", "Create skills to give agents specialized abilities", {
|
||||
label: "New Skill",
|
||||
onClick: () => void deps.plugin.createSkillTemplate(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const skill of snapshot.skills) {
|
||||
renderSkillCard(grid, deps, skill, snapshot.agents);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillCard(container: HTMLElement, deps: SkillsPageDeps, skill: SkillConfig, agents: AgentConfig[]): void {
|
||||
const card = container.createDiv({ cls: "af-skill-card" });
|
||||
|
||||
const cardHeader = card.createDiv({ cls: "af-skill-card-header" });
|
||||
const iconEl = cardHeader.createDiv({ cls: "af-skill-card-icon" });
|
||||
setIcon(iconEl, getSkillIcon(skill.name));
|
||||
|
||||
const skillEditBtn = cardHeader.createEl("button", { cls: "af-btn-sm af-btn-xs" });
|
||||
createIcon(skillEditBtn, "edit", "af-btn-icon");
|
||||
skillEditBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deps.navigate("edit-skill", skill.name);
|
||||
};
|
||||
|
||||
card.createDiv({ cls: "af-skill-card-name", text: skill.name });
|
||||
card.createDiv({ cls: "af-skill-card-desc", text: skill.description ?? "No description" });
|
||||
|
||||
const usedBy = agents.filter((a) => a.skills.includes(skill.name));
|
||||
if (usedBy.length > 0) {
|
||||
const agentsRow = card.createDiv({ cls: "af-skill-card-agents" });
|
||||
for (const agent of usedBy) {
|
||||
agentsRow.createSpan({ cls: "af-skill-card-agent-tag", text: agent.name });
|
||||
}
|
||||
}
|
||||
|
||||
// No card-level click — skills have no detail page. Editing is via the edit button.
|
||||
}
|
||||
|
||||
function getSkillIcon(name: string): string {
|
||||
if (name.includes("git")) return "settings";
|
||||
if (name.includes("summarize") || name.includes("log")) return "activity";
|
||||
if (name.includes("review") || name.includes("check")) return "check-circle-2";
|
||||
if (name.includes("vault") || name.includes("note")) return "file-text";
|
||||
return "puzzle";
|
||||
}
|
||||
111
src/views/pages/taskDetailPage.ts
Normal file
111
src/views/pages/taskDetailPage.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { setIcon } from "obsidian";
|
||||
import type { RunLogData } from "../../types";
|
||||
import { createIcon } from "../../utils/icons";
|
||||
import type { DashboardPageDeps } from "./shared";
|
||||
|
||||
/** View helpers the task detail page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. */
|
||||
export interface TaskDetailPageDeps extends DashboardPageDeps {
|
||||
/** Navigate the dashboard to another page. */
|
||||
navigate: (page: "edit-task", context?: string) => void;
|
||||
/** Label + monospace value config row (owned by the view). */
|
||||
renderConfigRow: (container: HTMLElement, label: string, value: string) => void;
|
||||
/** Run timeline entry shared with the overview page (owned by the view). */
|
||||
renderTimelineItem: (container: HTMLElement, run: RunLogData) => void;
|
||||
humanizeCron: (cron: string) => string;
|
||||
formatStarted: (iso: string) => string;
|
||||
}
|
||||
|
||||
export function renderTaskDetailPage(container: HTMLElement, deps: TaskDetailPageDeps, taskId: string | undefined): void {
|
||||
const page = container.createDiv({ cls: "af-task-detail-page" });
|
||||
if (!taskId) {
|
||||
deps.renderEmptyState(page, "circle-dot", "No task selected", "");
|
||||
return;
|
||||
}
|
||||
|
||||
const task = deps.plugin.runtime.getSnapshot().tasks.find((t) => t.taskId === taskId);
|
||||
if (!task) {
|
||||
deps.renderEmptyState(page, "circle-dot", "Task not found", `Task "${taskId}" was not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = deps.plugin.runtime.getSnapshot();
|
||||
const runs = deps.plugin.runtime.getRecentRuns().filter((r) => r.task === taskId);
|
||||
const agent = snapshot.agents.find((a) => a.name === task.agent);
|
||||
|
||||
// Header
|
||||
const header = page.createDiv({ cls: "af-detail-header" });
|
||||
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
|
||||
const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
|
||||
setIcon(taskIcon, "circle-dot");
|
||||
const headerInfo = headerLeft.createDiv();
|
||||
headerInfo.createDiv({ cls: "af-detail-header-name", text: task.taskId });
|
||||
headerInfo.createDiv({
|
||||
cls: "af-detail-header-desc",
|
||||
text: `Agent: ${task.agent}`,
|
||||
});
|
||||
|
||||
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
|
||||
|
||||
const editBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
|
||||
createIcon(editBtn, "edit", "af-btn-icon");
|
||||
editBtn.appendText(" Edit");
|
||||
editBtn.onclick = () => deps.navigate("edit-task", task.taskId);
|
||||
|
||||
const runBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" });
|
||||
createIcon(runBtn, "play", "af-btn-icon");
|
||||
runBtn.appendText(" Run Now");
|
||||
runBtn.onclick = () => void deps.plugin.runtime.runTaskNow(task);
|
||||
|
||||
// Details section
|
||||
const details = page.createDiv({ cls: "af-section-card" });
|
||||
const detailsHeader = details.createDiv({ cls: "af-section-header" });
|
||||
const detailsTitle = detailsHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(detailsTitle, "file-text");
|
||||
detailsTitle.appendText(" Details");
|
||||
|
||||
const detailsBody = details.createDiv({ cls: "af-config-form" });
|
||||
deps.renderConfigRow(detailsBody, "Agent", task.agent);
|
||||
deps.renderConfigRow(detailsBody, "Priority", task.priority.charAt(0).toUpperCase() + task.priority.slice(1));
|
||||
deps.renderConfigRow(detailsBody, "Status", task.enabled ? "Enabled" : "Disabled");
|
||||
|
||||
// Schedule — human-readable, no raw cron
|
||||
const scheduleText = task.schedule
|
||||
? deps.humanizeCron(task.schedule)
|
||||
: task.runAt ?? "Manual (run on demand)";
|
||||
deps.renderConfigRow(detailsBody, "Schedule", scheduleText);
|
||||
if (task.schedule) {
|
||||
deps.renderConfigRow(detailsBody, "Catch up if missed", task.catchUp ? "Yes" : "No");
|
||||
}
|
||||
|
||||
deps.renderConfigRow(detailsBody, "Created", task.created);
|
||||
deps.renderConfigRow(detailsBody, "Runs", String(task.runCount));
|
||||
if (task.lastRun) {
|
||||
deps.renderConfigRow(detailsBody, "Last Run", deps.formatStarted(task.lastRun));
|
||||
}
|
||||
|
||||
// Instructions section
|
||||
const promptSection = page.createDiv({ cls: "af-section-card" });
|
||||
const promptHeader = promptSection.createDiv({ cls: "af-section-header" });
|
||||
const promptTitle = promptHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(promptTitle, "message-square");
|
||||
promptTitle.appendText(" Instructions");
|
||||
promptSection.createDiv({ cls: "af-output-block", text: task.body || "(empty)" });
|
||||
|
||||
// Recent runs
|
||||
const runsSection = page.createDiv({ cls: "af-section-card" });
|
||||
const runsHeader = runsSection.createDiv({ cls: "af-section-header" });
|
||||
const runsTitle = runsHeader.createDiv({ cls: "af-section-title" });
|
||||
createIcon(runsTitle, "scroll-text");
|
||||
runsTitle.appendText(" Recent Runs");
|
||||
|
||||
const runsBody = runsSection.createDiv({ cls: "af-timeline" });
|
||||
if (runs.length === 0) {
|
||||
deps.renderEmptyState(runsBody, "scroll-text", "No runs yet", "");
|
||||
} else {
|
||||
for (const run of runs.slice(0, 10)) {
|
||||
deps.renderTimelineItem(runsBody, run);
|
||||
}
|
||||
}
|
||||
}
|
||||
193
src/views/pages/wikiKeepersPage.ts
Normal file
193
src/views/pages/wikiKeepersPage.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { Notice, TFile } from "obsidian";
|
||||
import type { AgentConfig } from "../../types";
|
||||
import { parseLatestLintReport } from "../../utils/wikiLintReport";
|
||||
import type { DashboardPageDeps } from "./shared";
|
||||
|
||||
/** View helpers the Wiki Keepers page borrows from the dashboard so the
|
||||
* extracted markup stays byte-identical to what the view used to render
|
||||
* inline. */
|
||||
export type WikiKeepersPageDeps = DashboardPageDeps;
|
||||
|
||||
/**
|
||||
* Wiki Keepers page: lists every Wiki Keeper instance with the latest
|
||||
* lint report parsed from its scope's `log.md`. "Needs review" items
|
||||
* render as cards with a Dismiss button (in-memory only — re-parse on
|
||||
* navigation gives the user a fresh view of the most recent lint pass).
|
||||
*/
|
||||
export async function renderWikiKeepersPage(container: HTMLElement, deps: WikiKeepersPageDeps): Promise<void> {
|
||||
const page = container.createDiv({ cls: "af-agents-page" });
|
||||
const snapshot = deps.plugin.runtime.getSnapshot();
|
||||
|
||||
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
|
||||
toolbar.createDiv({ cls: "af-page-title", text: "Wiki Keepers" });
|
||||
toolbar.createDiv({ cls: "af-toolbar-spacer" });
|
||||
|
||||
const keepers = snapshot.agents.filter(
|
||||
(a): a is AgentConfig & { wikiKeeper: NonNullable<AgentConfig["wikiKeeper"]> } =>
|
||||
a.wikiKeeper !== undefined,
|
||||
);
|
||||
|
||||
if (keepers.length === 0) {
|
||||
deps.renderEmptyState(
|
||||
page,
|
||||
"library",
|
||||
"No Wiki Keepers yet",
|
||||
"Open Settings → Agent Fleet → Wiki Keepers → + Add to create one.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = page.createDiv({ cls: "af-wk-list" });
|
||||
list.setCssStyles({ display: "flex" });
|
||||
list.setCssStyles({ flexDirection: "column" });
|
||||
list.setCssStyles({ gap: "16px" });
|
||||
|
||||
for (const keeper of keepers) {
|
||||
await renderWikiKeeperCard(list, deps, keeper);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderWikiKeeperCard(
|
||||
container: HTMLElement,
|
||||
deps: WikiKeepersPageDeps,
|
||||
agent: AgentConfig & { wikiKeeper: NonNullable<AgentConfig["wikiKeeper"]> },
|
||||
): Promise<void> {
|
||||
const wk = agent.wikiKeeper;
|
||||
const card = container.createDiv({ cls: "af-card" });
|
||||
card.setCssStyles({ padding: "16px" });
|
||||
card.setCssStyles({ border: "1px solid var(--background-modifier-border)" });
|
||||
card.setCssStyles({ borderRadius: "8px" });
|
||||
|
||||
const header = card.createDiv();
|
||||
header.setCssStyles({ display: "flex" });
|
||||
header.setCssStyles({ alignItems: "center" });
|
||||
header.setCssStyles({ gap: "12px" });
|
||||
header.setCssStyles({ marginBottom: "12px" });
|
||||
const titleWrap = header.createDiv();
|
||||
titleWrap.setCssStyles({ flex: "1" });
|
||||
titleWrap.createEl("strong", { text: agent.name });
|
||||
const scopeLabel = wk.scopeRoot || "(whole vault)";
|
||||
titleWrap.createEl("div", {
|
||||
text: `Scope: ${scopeLabel} · topics: ${wk.topicsRoot}/ · log: ${wk.logPath}`,
|
||||
cls: "af-form-hint",
|
||||
});
|
||||
|
||||
const openBtn = header.createEl("button", { cls: "af-btn-sm" });
|
||||
openBtn.appendText("Open log");
|
||||
openBtn.onclick = () => {
|
||||
const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath;
|
||||
const file = deps.plugin.app.vault.getAbstractFileByPath(logFullPath);
|
||||
if (file instanceof TFile) {
|
||||
void deps.plugin.app.workspace.getLeaf().openFile(file);
|
||||
} else {
|
||||
new Notice(`Log file not found: ${logFullPath}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Read and parse log.md
|
||||
const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath;
|
||||
const logFile = deps.plugin.app.vault.getAbstractFileByPath(logFullPath);
|
||||
let report: ReturnType<typeof parseLatestLintReport> = null;
|
||||
if (logFile instanceof TFile) {
|
||||
try {
|
||||
const content = await deps.plugin.app.vault.cachedRead(logFile);
|
||||
report = parseLatestLintReport(content);
|
||||
} catch {
|
||||
report = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
const empty = card.createDiv({ cls: "af-form-hint" });
|
||||
empty.setText(
|
||||
"No lint report yet. Run wiki-lint manually or wait for the weekly task to fire.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Latest lint header
|
||||
const reportHeader = card.createDiv();
|
||||
reportHeader.setCssStyles({ display: "flex" });
|
||||
reportHeader.setCssStyles({ alignItems: "baseline" });
|
||||
reportHeader.setCssStyles({ gap: "12px" });
|
||||
reportHeader.setCssStyles({ marginBottom: "8px" });
|
||||
reportHeader.createEl("strong", { text: `Lint ${report.date}` });
|
||||
reportHeader.createSpan({
|
||||
cls: "af-form-hint",
|
||||
text: `${report.summary.length} summary lines · ${report.autoApplied.length} auto-applied · ${report.needsReview.length} needs review`,
|
||||
});
|
||||
|
||||
// Summary bullets (read-only)
|
||||
if (report.summary.length > 0) {
|
||||
const sumDetails = card.createEl("details");
|
||||
sumDetails.createEl("summary", { text: "Summary" });
|
||||
const sumList = sumDetails.createEl("ul");
|
||||
sumList.setCssStyles({ marginTop: "4px" });
|
||||
for (const item of report.summary) {
|
||||
sumList.createEl("li", { text: item });
|
||||
}
|
||||
}
|
||||
|
||||
if (report.autoApplied.length > 0) {
|
||||
const autoDetails = card.createEl("details");
|
||||
autoDetails.createEl("summary", { text: `Auto-applied (${report.autoApplied.length})` });
|
||||
const autoList = autoDetails.createEl("ul");
|
||||
autoList.setCssStyles({ marginTop: "4px" });
|
||||
for (const item of report.autoApplied) {
|
||||
autoList.createEl("li", { text: item });
|
||||
}
|
||||
}
|
||||
|
||||
if (report.refreshChained.length > 0) {
|
||||
const refDetails = card.createEl("details");
|
||||
refDetails.createEl("summary", {
|
||||
text: `Refresh chained (${report.refreshChained.length})`,
|
||||
});
|
||||
const refList = refDetails.createEl("ul");
|
||||
refList.setCssStyles({ marginTop: "4px" });
|
||||
for (const item of report.refreshChained) {
|
||||
refList.createEl("li", { text: item });
|
||||
}
|
||||
}
|
||||
|
||||
// Needs review queue — the actionable bit
|
||||
const reviewWrap = card.createDiv();
|
||||
reviewWrap.setCssStyles({ marginTop: "12px" });
|
||||
reviewWrap.createEl("strong", { text: `Needs review (${report.needsReview.length})` });
|
||||
|
||||
if (report.needsReview.length === 0) {
|
||||
reviewWrap.createDiv({
|
||||
cls: "af-form-hint",
|
||||
text: "All clear. Nothing requires manual review from this lint pass.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewList = reviewWrap.createDiv();
|
||||
reviewList.setCssStyles({ display: "flex" });
|
||||
reviewList.setCssStyles({ flexDirection: "column" });
|
||||
reviewList.setCssStyles({ gap: "6px" });
|
||||
reviewList.setCssStyles({ marginTop: "8px" });
|
||||
|
||||
for (const item of report.needsReview) {
|
||||
const row = reviewList.createDiv();
|
||||
row.setCssStyles({ display: "flex" });
|
||||
row.setCssStyles({ alignItems: "flex-start" });
|
||||
row.setCssStyles({ gap: "8px" });
|
||||
row.setCssStyles({ padding: "8px 10px" });
|
||||
row.setCssStyles({ background: "var(--background-secondary)" });
|
||||
row.setCssStyles({ borderRadius: "4px" });
|
||||
row.setCssStyles({ fontSize: "13px" });
|
||||
|
||||
const text = row.createDiv();
|
||||
text.setCssStyles({ flex: "1" });
|
||||
text.setText(item);
|
||||
|
||||
const dismissBtn = row.createEl("button", { cls: "af-btn-sm", text: "Dismiss" });
|
||||
dismissBtn.title =
|
||||
"Hide this item from the dashboard until the next lint pass (does not modify log.md).";
|
||||
dismissBtn.onclick = () => {
|
||||
row.remove();
|
||||
};
|
||||
}
|
||||
}
|
||||
411
styles.css
411
styles.css
|
|
@ -334,6 +334,19 @@
|
|||
height: 14px;
|
||||
}
|
||||
|
||||
.af-search-result-empty {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--af-text-muted);
|
||||
}
|
||||
|
||||
.af-search-result-footer {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--af-text-muted);
|
||||
border-top: 1px solid var(--af-border);
|
||||
}
|
||||
|
||||
/* ─── Status Pills ─── */
|
||||
.af-status-pills {
|
||||
display: flex;
|
||||
|
|
@ -649,10 +662,6 @@
|
|||
height: 12px;
|
||||
}
|
||||
|
||||
.af-icon-back {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
/* ─── Outlined Buttons ─── */
|
||||
.af-btn-sm {
|
||||
|
|
@ -1446,12 +1455,6 @@
|
|||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.af-detail-value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.af-run-list-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
|
@ -1573,7 +1576,6 @@
|
|||
}
|
||||
|
||||
.af-kanban-running { border-color: rgba(249, 226, 175, 0.3); }
|
||||
.af-kanban-review { border-color: rgba(137, 180, 250, 0.3); }
|
||||
.af-kanban-failed { border-color: rgba(243, 139, 168, 0.15); }
|
||||
|
||||
/* Drag and drop states */
|
||||
|
|
@ -1801,23 +1803,6 @@
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.af-kanban-card-tags {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.af-kanban-card-tag {
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.af-kanban-card-tag.monitoring { background: var(--af-blue-bg); color: var(--af-blue); }
|
||||
.af-kanban-card-tag.devops { background: var(--af-orange-bg); color: var(--af-orange); }
|
||||
.af-kanban-card-tag.sample { background: var(--af-purple-bg); color: var(--af-purple); }
|
||||
.af-kanban-card-tag.default { background: var(--af-bg-surface); color: var(--af-text-muted); }
|
||||
|
||||
.af-kanban-card-error {
|
||||
padding: 6px 0;
|
||||
font-size: 11px;
|
||||
|
|
@ -1907,21 +1892,6 @@
|
|||
background: var(--af-accent-bg);
|
||||
}
|
||||
|
||||
/* ─── Priority Badge (detail page) ─── */
|
||||
.af-priority-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--af-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.af-priority-badge.critical { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
|
||||
.af-priority-badge.high { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
|
||||
.af-priority-badge.medium { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
|
||||
.af-priority-badge.low { background: var(--af-bg-surface); color: var(--af-text-secondary); }
|
||||
|
||||
/* ─── Schedule body toggle ─── */
|
||||
.af-schedule-body {
|
||||
padding-left: 4px;
|
||||
|
|
@ -2166,10 +2136,6 @@
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
.af-detail-value.af-accent {
|
||||
color: var(--af-accent);
|
||||
}
|
||||
|
||||
.af-output-block {
|
||||
background: var(--af-bg-primary);
|
||||
border: 1px solid var(--af-border);
|
||||
|
|
@ -2281,6 +2247,10 @@
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
.af-empty-action {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 1000px) {
|
||||
.af-dash-grid {
|
||||
|
|
@ -2333,11 +2303,6 @@
|
|||
margin: 8px 0 0 16px;
|
||||
}
|
||||
|
||||
.agent-fleet-textarea {
|
||||
width: 100%;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
/* ─── Create Agent Modal ─── */
|
||||
.af-create-agent-modal {
|
||||
max-width: 600px;
|
||||
|
|
@ -2584,13 +2549,6 @@
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
.af-form-desc {
|
||||
font-size: 10px;
|
||||
color: var(--af-text-muted);
|
||||
font-weight: 400;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Info-icon tooltip — replaces verbose subtitles on form labels */
|
||||
.af-form-tooltip {
|
||||
display: inline-flex;
|
||||
|
|
@ -2970,34 +2928,6 @@
|
|||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ─── Chat Slideover ─── */
|
||||
.af-slideover.af-chat-slideover {
|
||||
width: 540px;
|
||||
}
|
||||
|
||||
.af-chat-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.af-chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.af-chat-header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--af-accent);
|
||||
}
|
||||
|
||||
.af-chat-header-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.af-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
|
@ -3298,13 +3228,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.af-chat-working-indicator {
|
||||
font-size: 11px;
|
||||
color: var(--af-text-secondary);
|
||||
padding: 0 0 6px 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.af-chat-input-area {
|
||||
border-top: 1px solid var(--af-border);
|
||||
padding: 12px 16px 16px 16px;
|
||||
|
|
@ -3802,105 +3725,6 @@
|
|||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ─── MCP Tool Item (simple list fallback) ─── */
|
||||
.af-mcp-tool-item {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
background: var(--af-bg-surface);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--af-text-secondary);
|
||||
}
|
||||
|
||||
/* ── MCP Discovery Progress ── */
|
||||
|
||||
.af-mcp-progress {
|
||||
margin: 0 0 20px 0;
|
||||
padding: 16px 20px;
|
||||
background: var(--af-card-bg);
|
||||
border: 1px solid var(--af-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.af-mcp-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.af-mcp-spinner {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.af-mcp-spinner span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--af-blue);
|
||||
animation: af-mcp-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.af-mcp-spinner span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.af-mcp-spinner span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes af-mcp-bounce {
|
||||
0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
30% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.af-mcp-progress-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--af-text-primary);
|
||||
}
|
||||
|
||||
.af-mcp-progress-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--af-border);
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.af-mcp-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--af-blue);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.af-mcp-progress-fill-slow {
|
||||
/* When tool discovery is running (the slow phase), add a shimmer */
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--af-blue) 0%,
|
||||
color-mix(in srgb, var(--af-blue) 60%, white) 50%,
|
||||
var(--af-blue) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: af-mcp-shimmer 1.5s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes af-mcp-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.af-mcp-progress-detail {
|
||||
font-size: 11px;
|
||||
color: var(--af-text-secondary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── MCP Auth Button ── */
|
||||
|
||||
.af-mcp-auth-row {
|
||||
|
|
@ -3932,24 +3756,6 @@
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.af-mcp-hint-row {
|
||||
font-size: 11px;
|
||||
color: var(--af-text-secondary);
|
||||
padding: 8px 0 0;
|
||||
border-top: 1px solid var(--af-border);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.af-mcp-hint-row .af-link {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.af-mcp-hint-row .af-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── MCP Slideover Actions ── */
|
||||
|
||||
.af-slideover-section .af-btn-sm {
|
||||
|
|
@ -4467,10 +4273,6 @@
|
|||
border-top: 1px solid var(--af-border);
|
||||
}
|
||||
|
||||
.af-wk-schedule-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Run-detail transcript disclosure ── */
|
||||
|
||||
.af-run-transcript {
|
||||
|
|
@ -4520,12 +4322,6 @@
|
|||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.af-wk-schedule-block .af-form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--af-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ─── Chat: conversations side rail ───────────────────────────────────── */
|
||||
/* Adjacent to the messages column inside the chat view, this rail lists all
|
||||
* parallel conversations for the currently-selected agent. Styled to match
|
||||
|
|
@ -4732,3 +4528,176 @@
|
|||
.af-chat-convo-collapse-btn.is-collapsed .af-convo-toggle-bar {
|
||||
width: 8.33%;
|
||||
}
|
||||
|
||||
/* Muted recovery hint below the message inside an error bubble */
|
||||
.af-chat-error-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--af-text-secondary);
|
||||
}
|
||||
|
||||
/* ─── Agent card: heartbeat status line ─────────────────────────────────── */
|
||||
/* Compact line above the card footer: heart-pulse icon + schedule/next-run
|
||||
* text + a small quick-toggle (same update path as the Overview tab). */
|
||||
.af-agent-card-heartbeat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--af-text-muted);
|
||||
}
|
||||
|
||||
.af-agent-card-hb-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--af-green);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.af-agent-card-hb-icon svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.af-agent-card-hb-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Smaller variant of the standard pill toggle for inline card rows */
|
||||
.af-agent-card-toggle-sm {
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.af-agent-card-toggle-sm::after {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.af-agent-card-toggle-sm.on::after {
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
/* ─── Agent form: adapter-switch permission mapping hint ────────────────── */
|
||||
/* Muted one-liner under the permission-mode field, shown only after the user
|
||||
* switches the adapter, explaining how the mode vocabulary was translated. */
|
||||
.af-adapter-map-hint {
|
||||
font-size: 11px;
|
||||
color: var(--af-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ─── Task form: schedule type segmented control ────────────────────────── */
|
||||
.af-segmented {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--af-border);
|
||||
border-radius: var(--af-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.af-segmented-btn {
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--af-text-secondary);
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.af-segmented-btn + .af-segmented-btn {
|
||||
border-left: 1px solid var(--af-border);
|
||||
}
|
||||
|
||||
.af-segmented-btn:hover {
|
||||
color: var(--af-text-normal, var(--text-normal));
|
||||
}
|
||||
|
||||
.af-segmented-btn.active {
|
||||
background: var(--af-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ─── Dashboard: first-run welcome card ─────────────────────────────────── */
|
||||
.af-welcome-card {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--af-border);
|
||||
border-radius: var(--af-radius-md);
|
||||
background: var(--af-bg-card, var(--background-secondary));
|
||||
}
|
||||
|
||||
.af-welcome-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.af-welcome-dismiss {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.af-welcome-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--af-text-secondary);
|
||||
}
|
||||
|
||||
.af-welcome-steps {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.af-welcome-step {
|
||||
flex: 1 1 180px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--af-border);
|
||||
border-radius: var(--af-radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--af-transition), background var(--af-transition);
|
||||
}
|
||||
|
||||
.af-welcome-step:hover {
|
||||
border-color: var(--af-accent);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.af-welcome-step-num {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--af-accent);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.af-welcome-step-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.af-welcome-step-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--af-text-muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@
|
|||
"0.13.5": "1.11.4",
|
||||
"0.13.6": "1.11.4",
|
||||
"0.14.0": "1.11.4",
|
||||
"0.15.0": "1.11.4"
|
||||
}
|
||||
"0.15.0": "1.11.4",
|
||||
"0.16.0": "1.11.4"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue