From 4e0936765ebdc7d7790e5c76db0616f110fe2152 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Mon, 13 Apr 2026 16:40:13 -0700 Subject: [PATCH 1/6] feat: skills and memory-files storage subsystems with audit log Add the on-disk storage layers for the PR1 dashboard's skills and memory files tabs. Both subsystems validate paths, parse and serialize content atomically via tmp-then-rename, cap content size, and record every edit in a new SQLite audit log. Skills at /home/phantom/.claude/skills//SKILL.md are parsed with a Zod-validated strict YAML frontmatter schema (name, description, when_to_use required; allowed-tools, argument-hint, arguments, context, disable-model-invocation optional) and linted for missing fields, body size, and shell red-list patterns. Memory files are any markdown under /home/phantom/.claude/ excluding skills/, plugins/, agents/, and the settings.json pair. Adds skill_audit_log and memory_file_audit_log tables with indices. Updates the migration test for the four new migrations. --- src/db/__tests__/migrate.test.ts | 4 +- src/db/schema.ts | 29 ++ src/memory-files/__tests__/storage.test.ts | 146 ++++++++++ src/memory-files/audit.ts | 54 ++++ src/memory-files/paths.ts | 75 +++++ src/memory-files/storage.ts | 225 +++++++++++++++ src/skills/__tests__/frontmatter.test.ts | 93 +++++++ src/skills/__tests__/linter.test.ts | 48 ++++ src/skills/__tests__/paths.test.ts | 78 ++++++ src/skills/__tests__/storage.test.ts | 132 +++++++++ src/skills/audit.ts | 57 ++++ src/skills/frontmatter.ts | 142 ++++++++++ src/skills/linter.ts | 95 +++++++ src/skills/paths.ts | 62 +++++ src/skills/storage.ts | 306 +++++++++++++++++++++ 15 files changed, 1544 insertions(+), 2 deletions(-) create mode 100644 src/memory-files/__tests__/storage.test.ts create mode 100644 src/memory-files/audit.ts create mode 100644 src/memory-files/paths.ts create mode 100644 src/memory-files/storage.ts create mode 100644 src/skills/__tests__/frontmatter.test.ts create mode 100644 src/skills/__tests__/linter.test.ts create mode 100644 src/skills/__tests__/paths.test.ts create mode 100644 src/skills/__tests__/storage.test.ts create mode 100644 src/skills/audit.ts create mode 100644 src/skills/frontmatter.ts create mode 100644 src/skills/linter.ts create mode 100644 src/skills/paths.ts create mode 100644 src/skills/storage.ts diff --git a/src/db/__tests__/migrate.test.ts b/src/db/__tests__/migrate.test.ts index 618bed0..59a5707 100644 --- a/src/db/__tests__/migrate.test.ts +++ b/src/db/__tests__/migrate.test.ts @@ -35,7 +35,7 @@ describe("runMigrations", () => { runMigrations(db); const migrationCount = db.query("SELECT COUNT(*) as count FROM _migrations").get() as { count: number }; - expect(migrationCount.count).toBe(10); + expect(migrationCount.count).toBe(14); }); test("tracks applied migration indices", () => { @@ -47,6 +47,6 @@ describe("runMigrations", () => { .all() .map((r) => (r as { index_num: number }).index_num); - expect(indices).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(indices).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); }); }); diff --git a/src/db/schema.ts b/src/db/schema.ts index 3054c12..f756db1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -103,4 +103,33 @@ export const MIGRATIONS: string[] = [ // "dropped:" = skipped at the delivery branch, "error:" = // Slack threw during send. Existing rows keep null on migration. "ALTER TABLE scheduled_jobs ADD COLUMN last_delivery_status TEXT", + + // PR1 dashboard: skills editor audit log. Every create/update/delete from + // the UI API writes a row here so the user can see the history of their + // skills. Agent-originated edits (via the Write tool) are not captured + // today; a future PR may add a file-watcher. + `CREATE TABLE IF NOT EXISTS skill_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + skill_name TEXT NOT NULL, + action TEXT NOT NULL, + previous_body TEXT, + new_body TEXT, + actor TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + + "CREATE INDEX IF NOT EXISTS idx_skill_audit_log_name ON skill_audit_log(skill_name, id DESC)", + + // PR1 dashboard: memory file editor audit log. Same pattern as skills. + `CREATE TABLE IF NOT EXISTS memory_file_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL, + action TEXT NOT NULL, + previous_content TEXT, + new_content TEXT, + actor TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + + "CREATE INDEX IF NOT EXISTS idx_memory_file_audit_log_path ON memory_file_audit_log(file_path, id DESC)", ]; diff --git a/src/memory-files/__tests__/storage.test.ts b/src/memory-files/__tests__/storage.test.ts new file mode 100644 index 0000000..f8752f6 --- /dev/null +++ b/src/memory-files/__tests__/storage.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isValidMemoryFilePath } from "../paths.ts"; +import { deleteMemoryFile, listMemoryFiles, readMemoryFile, writeMemoryFile } from "../storage.ts"; + +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "phantom-memfiles-")); + process.env.PHANTOM_MEMORY_FILES_ROOT = tmp; +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + Reflect.deleteProperty(process.env, "PHANTOM_MEMORY_FILES_ROOT"); +}); + +describe("isValidMemoryFilePath", () => { + test("accepts canonical memory paths", () => { + expect(isValidMemoryFilePath("CLAUDE.md")).toBe(true); + expect(isValidMemoryFilePath("rules/no-friday.md")).toBe(true); + expect(isValidMemoryFilePath("memory/people/cheema.md")).toBe(true); + }); + + test("rejects skills, plugins, agents subtrees", () => { + expect(isValidMemoryFilePath("skills/mirror.md")).toBe(false); + expect(isValidMemoryFilePath("plugins/x.md")).toBe(false); + expect(isValidMemoryFilePath("agents/x.md")).toBe(false); + }); + + test("rejects settings.json files", () => { + expect(isValidMemoryFilePath("settings.json")).toBe(false); + expect(isValidMemoryFilePath("settings.local.json")).toBe(false); + }); + + test("rejects non-.md files", () => { + expect(isValidMemoryFilePath("notes.txt")).toBe(false); + expect(isValidMemoryFilePath("a.json")).toBe(false); + }); + + test("rejects hidden files and traversal", () => { + expect(isValidMemoryFilePath(".hidden.md")).toBe(false); + expect(isValidMemoryFilePath("../../etc/passwd.md")).toBe(false); + expect(isValidMemoryFilePath("/absolute.md")).toBe(false); + expect(isValidMemoryFilePath("null\0byte.md")).toBe(false); + }); +}); + +describe("listMemoryFiles", () => { + test("finds markdown files under the root", () => { + writeFileSync(join(tmp, "CLAUDE.md"), "top\n"); + mkdirSync(join(tmp, "rules")); + writeFileSync(join(tmp, "rules", "no-friday.md"), "rule\n"); + mkdirSync(join(tmp, "memory"), { recursive: true }); + writeFileSync(join(tmp, "memory", "notes.md"), "notes\n"); + const result = listMemoryFiles(); + const paths = result.files.map((f) => f.path).sort(); + expect(paths).toContain("CLAUDE.md"); + expect(paths).toContain("rules/no-friday.md"); + expect(paths).toContain("memory/notes.md"); + }); + + test("excludes skills/, plugins/, agents/", () => { + mkdirSync(join(tmp, "skills", "mirror"), { recursive: true }); + writeFileSync(join(tmp, "skills", "mirror", "SKILL.md"), "skill\n"); + mkdirSync(join(tmp, "plugins", "x"), { recursive: true }); + writeFileSync(join(tmp, "plugins", "x", "p.md"), "plugin\n"); + mkdirSync(join(tmp, "agents")); + writeFileSync(join(tmp, "agents", "a.md"), "agent\n"); + writeFileSync(join(tmp, "CLAUDE.md"), "top\n"); + const result = listMemoryFiles(); + const paths = result.files.map((f) => f.path); + expect(paths).toContain("CLAUDE.md"); + expect(paths.some((p) => p.startsWith("skills/"))).toBe(false); + expect(paths.some((p) => p.startsWith("plugins/"))).toBe(false); + expect(paths.some((p) => p.startsWith("agents/"))).toBe(false); + }); + + test("excludes non-.md files and hidden files", () => { + writeFileSync(join(tmp, "settings.json"), "{}"); + writeFileSync(join(tmp, ".hidden.md"), "h\n"); + writeFileSync(join(tmp, "notes.txt"), "t\n"); + writeFileSync(join(tmp, "CLAUDE.md"), "top\n"); + const result = listMemoryFiles(); + const paths = result.files.map((f) => f.path); + expect(paths).toEqual(["CLAUDE.md"]); + }); +}); + +describe("writeMemoryFile + readMemoryFile", () => { + test("creates a new file at a nested path", () => { + const result = writeMemoryFile({ path: "memory/sub/notes.md", content: "hello\n" }, { mustExist: false }); + expect(result.ok).toBe(true); + expect(existsSync(join(tmp, "memory", "sub", "notes.md"))).toBe(true); + const read = readMemoryFile("memory/sub/notes.md"); + expect(read.ok).toBe(true); + if (!read.ok) return; + expect(read.file.content).toBe("hello\n"); + }); + + test("refuses to create when file exists", () => { + writeMemoryFile({ path: "CLAUDE.md", content: "first\n" }, { mustExist: false }); + const second = writeMemoryFile({ path: "CLAUDE.md", content: "second\n" }, { mustExist: false }); + expect(second.ok).toBe(false); + }); + + test("updates existing file", () => { + writeMemoryFile({ path: "CLAUDE.md", content: "first\n" }, { mustExist: false }); + const updated = writeMemoryFile({ path: "CLAUDE.md", content: "second\n" }, { mustExist: true }); + expect(updated.ok).toBe(true); + if (!updated.ok) return; + expect(updated.previousContent).toBe("first\n"); + expect(updated.file.content).toBe("second\n"); + }); + + test("rejects content over 256KB", () => { + const giant = "x".repeat(300 * 1024); + const result = writeMemoryFile({ path: "memory/giant.md", content: giant }, { mustExist: false }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(413); + }); + + test("rejects invalid paths", () => { + const result = writeMemoryFile({ path: "skills/evil.md", content: "nope" }, { mustExist: false }); + expect(result.ok).toBe(false); + }); +}); + +describe("deleteMemoryFile", () => { + test("removes an existing file", () => { + writeMemoryFile({ path: "CLAUDE.md", content: "c\n" }, { mustExist: false }); + const result = deleteMemoryFile("CLAUDE.md"); + expect(result.ok).toBe(true); + expect(existsSync(join(tmp, "CLAUDE.md"))).toBe(false); + }); + + test("returns 404 for missing file", () => { + const result = deleteMemoryFile("nope.md"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(404); + }); +}); diff --git a/src/memory-files/audit.ts b/src/memory-files/audit.ts new file mode 100644 index 0000000..08e5360 --- /dev/null +++ b/src/memory-files/audit.ts @@ -0,0 +1,54 @@ +// Audit log for memory file edits. Same pattern as skills/audit.ts. + +import type { Database } from "bun:sqlite"; + +export type MemoryFileAuditAction = "create" | "update" | "delete"; + +export type MemoryFileAuditEntry = { + id: number; + file_path: string; + action: MemoryFileAuditAction; + previous_content: string | null; + new_content: string | null; + actor: string; + created_at: string; +}; + +export function recordMemoryFileEdit( + db: Database, + params: { + path: string; + action: MemoryFileAuditAction; + previousContent: string | null; + newContent: string | null; + actor: string; + }, +): void { + db.run( + `INSERT INTO memory_file_audit_log (file_path, action, previous_content, new_content, actor) + VALUES (?, ?, ?, ?, ?)`, + [params.path, params.action, params.previousContent, params.newContent, params.actor], + ); +} + +export function listMemoryFileEdits(db: Database, filePath?: string, limit = 50): MemoryFileAuditEntry[] { + if (filePath) { + return db + .query( + `SELECT id, file_path, action, previous_content, new_content, actor, created_at + FROM memory_file_audit_log + WHERE file_path = ? + ORDER BY id DESC + LIMIT ?`, + ) + .all(filePath, limit) as MemoryFileAuditEntry[]; + } + return db + .query( + `SELECT id, file_path, action, previous_content, new_content, actor, created_at + FROM memory_file_audit_log + ORDER BY id DESC + LIMIT ?`, + ) + .all(limit) as MemoryFileAuditEntry[]; +} diff --git a/src/memory-files/paths.ts b/src/memory-files/paths.ts new file mode 100644 index 0000000..1077e78 --- /dev/null +++ b/src/memory-files/paths.ts @@ -0,0 +1,75 @@ +// Resolve and validate memory file paths under the user-scope .claude directory. +// +// Memory files are arbitrary `.md` files the operator writes as instructions for +// their agent. They live under /home/phantom/.claude/ (the user-scope settings +// root that the SDK loads). We expose everything under that root EXCEPT: +// +// - skills/** (has its own tab) +// - plugins/** (PR2 scope) +// - agents/** (PR3 scope) +// - settings.json, settings.local.json (PR3 scope, JSON not markdown) +// - any non-.md file +// - hidden files (names starting with '.') +// +// Paths are always validated to live canonically under the root. + +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +const USER_ENV_OVERRIDE = "PHANTOM_MEMORY_FILES_ROOT"; + +// Segments under .claude that we do NOT expose as memory files. +// Top-level hits are excluded; nested hits with the same top-level segment +// are also excluded. +export const EXCLUDED_TOP_DIRS = new Set(["skills", "plugins", "agents"]); +export const EXCLUDED_TOP_FILES = new Set(["settings.json", "settings.local.json"]); + +export function getMemoryFilesRoot(): string { + const override = process.env[USER_ENV_OVERRIDE]; + if (override) { + return resolve(override); + } + return resolve(homedir(), ".claude"); +} + +// The public-facing "path" is the relative path from the memory files root, +// always POSIX-style. We validate that: +// - path has no null bytes +// - path does not start with '/' or '\\' +// - path has no '..' segments +// - path ends with '.md' +// - path is not a hidden file (no segment starts with '.') +// - path is not under an excluded top-level directory +// - path is not an excluded top-level file +export function isValidMemoryFilePath(relative: string): boolean { + if (typeof relative !== "string" || relative.length === 0) return false; + if (relative.includes("\0")) return false; + if (relative.startsWith("/") || relative.startsWith("\\")) return false; + if (!relative.endsWith(".md")) return false; + + const segments = relative.split("/").filter((s) => s.length > 0); + if (segments.length === 0) return false; + + for (const seg of segments) { + if (seg === "." || seg === "..") return false; + if (seg.startsWith(".")) return false; + } + + const top = segments[0]; + if (segments.length === 1 && EXCLUDED_TOP_FILES.has(top)) return false; + if (EXCLUDED_TOP_DIRS.has(top)) return false; + + return true; +} + +export function resolveMemoryFilePath(relative: string): { root: string; absolute: string } { + if (!isValidMemoryFilePath(relative)) { + throw new Error(`Invalid memory file path: ${JSON.stringify(relative)}`); + } + const root = getMemoryFilesRoot(); + const absolute = resolve(root, relative); + if (!absolute.startsWith(`${root}/`) && absolute !== root) { + throw new Error(`Path escape detected: ${absolute} is not inside ${root}`); + } + return { root, absolute }; +} diff --git a/src/memory-files/storage.ts b/src/memory-files/storage.ts new file mode 100644 index 0000000..87aacb1 --- /dev/null +++ b/src/memory-files/storage.ts @@ -0,0 +1,225 @@ +// CRUD for memory files under /home/phantom/.claude/**.md (excluding reserved +// subtrees). Atomic writes via tmp-then-rename. Subdirectories created on +// demand. Directory traversal blocked by paths.ts validation. + +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, relative as relPath } from "node:path"; +import { + EXCLUDED_TOP_DIRS, + EXCLUDED_TOP_FILES, + getMemoryFilesRoot, + isValidMemoryFilePath, + resolveMemoryFilePath, +} from "./paths.ts"; + +const MAX_BYTES = 256 * 1024; // 256 KB per memory file + +export type MemoryFileSummary = { + path: string; // POSIX relative path from the root + size: number; + mtime: string; // ISO + top_level: string; +}; + +export type MemoryFileDetail = MemoryFileSummary & { + content: string; +}; + +function ensureDir(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +function walk(root: string, current: string, out: string[]): void { + if (!existsSync(current)) return; + let entries: string[]; + try { + entries = readdirSync(current); + } catch { + return; + } + for (const name of entries) { + if (name.startsWith(".")) continue; + const full = join(current, name); + let stats: ReturnType; + try { + stats = statSync(full); + } catch { + continue; + } + const rel = relPath(root, full).split("\\").join("/"); + + if (stats.isDirectory()) { + const topSegment = rel.split("/")[0]; + if (EXCLUDED_TOP_DIRS.has(topSegment)) continue; + walk(root, full, out); + continue; + } + + if (!stats.isFile()) continue; + if (!name.endsWith(".md")) continue; + + const topSegment = rel.split("/")[0]; + if (rel === topSegment && EXCLUDED_TOP_FILES.has(topSegment)) continue; + if (EXCLUDED_TOP_DIRS.has(topSegment)) continue; + + out.push(rel); + } +} + +export function listMemoryFiles(): { files: MemoryFileSummary[] } { + const root = getMemoryFilesRoot(); + const relative: string[] = []; + walk(root, root, relative); + relative.sort(); + + const files: MemoryFileSummary[] = []; + for (const rel of relative) { + if (!isValidMemoryFilePath(rel)) continue; + const full = join(root, rel); + let stats: ReturnType; + try { + stats = statSync(full); + } catch { + continue; + } + files.push({ + path: rel, + size: stats.size, + mtime: stats.mtime.toISOString(), + top_level: rel.split("/")[0], + }); + } + + return { files }; +} + +export type ReadResult = { ok: true; file: MemoryFileDetail } | { ok: false; status: 404 | 422 | 500; error: string }; + +export function readMemoryFile(relative: string): ReadResult { + let absolute: string; + try { + absolute = resolveMemoryFilePath(relative).absolute; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 422, error: msg }; + } + if (!existsSync(absolute)) { + return { ok: false, status: 404, error: `Memory file not found: ${relative}` }; + } + let content: string; + let stats: ReturnType; + try { + content = readFileSync(absolute, "utf-8"); + stats = statSync(absolute); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 500, error: `Failed to read memory file: ${msg}` }; + } + return { + ok: true, + file: { + path: relative, + size: stats.size, + mtime: stats.mtime.toISOString(), + top_level: relative.split("/")[0], + content, + }, + }; +} + +function writeAtomic(file: string, content: string): void { + const dir = dirname(file); + ensureDir(dir); + const tmp = join(dir, `.memory.tmp-${process.pid}-${Date.now()}`); + writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o644 }); + renameSync(tmp, file); +} + +export type WriteResult = + | { ok: true; file: MemoryFileDetail; previousContent: string | null } + | { ok: false; status: 400 | 404 | 409 | 413 | 422 | 500; error: string }; + +export type WriteInput = { + path: string; + content: string; +}; + +export function writeMemoryFile(input: WriteInput, options: { mustExist: boolean }): WriteResult { + const byteLength = new TextEncoder().encode(input.content).byteLength; + if (byteLength > MAX_BYTES) { + return { + ok: false, + status: 413, + error: `Content is ${(byteLength / 1024).toFixed(1)} KB, over the ${MAX_BYTES / 1024} KB limit.`, + }; + } + + let absolute: string; + try { + absolute = resolveMemoryFilePath(input.path).absolute; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 422, error: msg }; + } + + let previousContent: string | null = null; + if (existsSync(absolute)) { + if (!options.mustExist) { + return { ok: false, status: 409, error: `Memory file already exists: ${input.path}` }; + } + try { + previousContent = readFileSync(absolute, "utf-8"); + } catch { + previousContent = null; + } + } else if (options.mustExist) { + return { ok: false, status: 404, error: `Memory file not found: ${input.path}` }; + } + + try { + writeAtomic(absolute, input.content); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 500, error: `Failed to write memory file: ${msg}` }; + } + + const read = readMemoryFile(input.path); + if (!read.ok) { + return { ok: false, status: 500, error: `Write succeeded but read-back failed: ${read.error}` }; + } + return { ok: true, file: read.file, previousContent }; +} + +export type DeleteResult = + | { ok: true; deleted: string; previousContent: string | null } + | { ok: false; status: 404 | 422 | 500; error: string }; + +export function deleteMemoryFile(relative: string): DeleteResult { + let absolute: string; + try { + absolute = resolveMemoryFilePath(relative).absolute; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 422, error: msg }; + } + if (!existsSync(absolute)) { + return { ok: false, status: 404, error: `Memory file not found: ${relative}` }; + } + let previousContent: string | null = null; + try { + previousContent = readFileSync(absolute, "utf-8"); + } catch { + previousContent = null; + } + try { + rmSync(absolute, { force: true }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 500, error: `Failed to delete memory file: ${msg}` }; + } + return { ok: true, deleted: relative, previousContent }; +} + +export const MEMORY_FILE_MAX_BYTES = MAX_BYTES; diff --git a/src/skills/__tests__/frontmatter.test.ts b/src/skills/__tests__/frontmatter.test.ts new file mode 100644 index 0000000..3d8dd41 --- /dev/null +++ b/src/skills/__tests__/frontmatter.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { + MAX_BODY_BYTES, + getBodyByteLength, + isBodyWithinLimit, + parseFrontmatter, + serializeSkill, +} from "../frontmatter.ts"; + +const validRaw = `--- +name: mirror +description: weekly self-audit +when_to_use: Use on Friday evening. +allowed-tools: + - Read + - Glob +context: inline +--- + +# Mirror + +## Goal +A body. +`; + +describe("parseFrontmatter", () => { + test("parses a valid SKILL.md", () => { + const result = parseFrontmatter(validRaw); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.parsed.frontmatter.name).toBe("mirror"); + expect(result.parsed.frontmatter.description).toBe("weekly self-audit"); + expect(result.parsed.frontmatter.when_to_use).toBe("Use on Friday evening."); + expect(result.parsed.frontmatter["allowed-tools"]).toEqual(["Read", "Glob"]); + expect(result.parsed.frontmatter.context).toBe("inline"); + expect(result.parsed.body.startsWith("# Mirror")).toBe(true); + }); + + test("rejects input without opening ---", () => { + const result = parseFrontmatter("# No frontmatter here"); + expect(result.ok).toBe(false); + }); + + test("rejects input with no closing ---", () => { + const result = parseFrontmatter("---\nname: m\n\n# body"); + expect(result.ok).toBe(false); + }); + + test("rejects missing required name", () => { + const raw = "---\ndescription: x\nwhen_to_use: y\n---\n\n# body"; + const result = parseFrontmatter(raw); + expect(result.ok).toBe(false); + }); + + test("rejects invalid name format", () => { + const raw = "---\nname: Bad Name\ndescription: x\nwhen_to_use: y\n---\n\n# body"; + const result = parseFrontmatter(raw); + expect(result.ok).toBe(false); + }); + + test("rejects unknown frontmatter keys (strict mode)", () => { + const raw = "---\nname: m\ndescription: x\nwhen_to_use: y\nrogue: true\n---\n\n# body"; + const result = parseFrontmatter(raw); + expect(result.ok).toBe(false); + }); +}); + +describe("serializeSkill", () => { + test("round-trips a parsed skill", () => { + const parsed = parseFrontmatter(validRaw); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + const serialized = serializeSkill(parsed.parsed.frontmatter, parsed.parsed.body); + const reparsed = parseFrontmatter(serialized); + expect(reparsed.ok).toBe(true); + if (!reparsed.ok) return; + expect(reparsed.parsed.frontmatter.name).toBe("mirror"); + expect(reparsed.parsed.body.startsWith("# Mirror")).toBe(true); + }); +}); + +describe("getBodyByteLength and isBodyWithinLimit", () => { + test("counts UTF-8 bytes correctly", () => { + expect(getBodyByteLength("hello")).toBe(5); + expect(getBodyByteLength("café")).toBe(5); + }); + + test("isBodyWithinLimit enforces MAX_BODY_BYTES", () => { + expect(isBodyWithinLimit("x")).toBe(true); + expect(isBodyWithinLimit("x".repeat(MAX_BODY_BYTES))).toBe(true); + expect(isBodyWithinLimit("x".repeat(MAX_BODY_BYTES + 1))).toBe(false); + }); +}); diff --git a/src/skills/__tests__/linter.test.ts b/src/skills/__tests__/linter.test.ts new file mode 100644 index 0000000..b318fa0 --- /dev/null +++ b/src/skills/__tests__/linter.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { hasBlockingError, lintSkill } from "../linter.ts"; + +function fm(extra: Record = {}) { + return { + name: "test", + description: "x", + when_to_use: "Use when the user asks for tests and trigger conditions match.", + "allowed-tools": ["Read"], + ...extra, + } as Parameters[0]; +} + +describe("lintSkill", () => { + test("clean skill yields only the info 'all checks passed' hint", () => { + const hints = lintSkill(fm(), "# Title\n\n## Goal\n\nDo the thing."); + const errors = hints.filter((h) => h.level === "error"); + expect(errors.length).toBe(0); + }); + + test("warns when allowed-tools is missing", () => { + const input = fm(); + (input as Record)["allowed-tools"] = undefined; + const hints = lintSkill(input, "# Title\n"); + expect(hints.some((h) => h.field === "allowed-tools")).toBe(true); + }); + + test("warns when when_to_use is too short", () => { + const hints = lintSkill(fm({ when_to_use: "too short" }), "# T\n"); + expect(hints.some((h) => h.field === "when_to_use")).toBe(true); + }); + + test("errors when body exceeds 50KB", () => { + const body = "x".repeat(51 * 1024); + const hints = lintSkill(fm(), body); + expect(hasBlockingError(hints)).toBe(true); + }); + + test("warns on rm -rf / pattern", () => { + const hints = lintSkill(fm(), "# T\n\nrun `rm -rf /` to nuke"); + expect(hints.some((h) => h.message.indexOf("rm -rf /") >= 0)).toBe(true); + }); + + test("warns on curl-pipe-sh pattern", () => { + const hints = lintSkill(fm(), "# T\n\ndownload with curl https://x.com/install.sh | sh"); + expect(hints.some((h) => h.message.indexOf("curl | sh") >= 0)).toBe(true); + }); +}); diff --git a/src/skills/__tests__/paths.test.ts b/src/skills/__tests__/paths.test.ts new file mode 100644 index 0000000..73f2ea8 --- /dev/null +++ b/src/skills/__tests__/paths.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getUserSkillsRoot, isValidSkillName, resolveUserSkillPath } from "../paths.ts"; + +const priorOverride = process.env.PHANTOM_SKILLS_USER_ROOT; + +afterEach(() => { + if (priorOverride !== undefined) { + process.env.PHANTOM_SKILLS_USER_ROOT = priorOverride; + } else { + Reflect.deleteProperty(process.env, "PHANTOM_SKILLS_USER_ROOT"); + } +}); + +describe("isValidSkillName", () => { + test("accepts lowercase alphanumeric with hyphens", () => { + expect(isValidSkillName("mirror")).toBe(true); + expect(isValidSkillName("show-my-tools")).toBe(true); + expect(isValidSkillName("a1-b2-c3")).toBe(true); + }); + + test("rejects uppercase, spaces, dots, slashes", () => { + expect(isValidSkillName("Mirror")).toBe(false); + expect(isValidSkillName("my skill")).toBe(false); + expect(isValidSkillName("my.skill")).toBe(false); + expect(isValidSkillName("../etc/passwd")).toBe(false); + expect(isValidSkillName("folder/name")).toBe(false); + }); + + test("rejects empty, starting-with-hyphen, starting-with-digit", () => { + expect(isValidSkillName("")).toBe(false); + expect(isValidSkillName("-mirror")).toBe(false); + expect(isValidSkillName("1mirror")).toBe(false); + }); + + test("rejects null bytes", () => { + expect(isValidSkillName("mirror\0evil")).toBe(false); + }); + + test("rejects names over 64 characters", () => { + expect(isValidSkillName("a".repeat(64))).toBe(true); + expect(isValidSkillName("a".repeat(65))).toBe(false); + }); +}); + +describe("getUserSkillsRoot", () => { + test("honors PHANTOM_SKILLS_USER_ROOT override", () => { + const tmp = mkdtempSync(join(tmpdir(), "phantom-skills-")); + process.env.PHANTOM_SKILLS_USER_ROOT = tmp; + try { + expect(getUserSkillsRoot()).toBe(tmp); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe("resolveUserSkillPath", () => { + test("returns a path inside the skills root", () => { + const tmp = mkdtempSync(join(tmpdir(), "phantom-skills-")); + process.env.PHANTOM_SKILLS_USER_ROOT = tmp; + try { + const r = resolveUserSkillPath("mirror"); + expect(r.root).toBe(tmp); + expect(r.dir.startsWith(tmp)).toBe(true); + expect(r.file).toBe(join(tmp, "mirror", "SKILL.md")); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + test("throws on invalid name", () => { + expect(() => resolveUserSkillPath("../etc")).toThrow(); + expect(() => resolveUserSkillPath("")).toThrow(); + }); +}); diff --git a/src/skills/__tests__/storage.test.ts b/src/skills/__tests__/storage.test.ts new file mode 100644 index 0000000..47fb3c9 --- /dev/null +++ b/src/skills/__tests__/storage.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { deleteSkill, listSkills, readSkill, writeSkill } from "../storage.ts"; + +let tmp: string; + +const validSkill = { + name: "mirror", + description: "weekly self-audit", + when_to_use: "Use when the user asks for a mirror on Fridays.", +}; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "phantom-skills-")); + process.env.PHANTOM_SKILLS_USER_ROOT = tmp; +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + Reflect.deleteProperty(process.env, "PHANTOM_SKILLS_USER_ROOT"); +}); + +describe("listSkills", () => { + test("returns empty list when root does not exist", () => { + rmSync(tmp, { recursive: true, force: true }); + const result = listSkills(); + expect(result.skills).toEqual([]); + expect(result.errors).toEqual([]); + }); + + test("lists a valid skill", () => { + const skillDir = join(tmp, "mirror"); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: mirror\ndescription: weekly\nwhen_to_use: Use on Friday.\n---\n\n# Mirror\n", + ); + const result = listSkills(); + expect(result.skills.length).toBe(1); + expect(result.skills[0].name).toBe("mirror"); + }); + + test("skips directories with bad names", () => { + const skillDir = join(tmp, "BAD NAME"); + mkdirSync(skillDir); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: bad\ndescription: x\nwhen_to_use: Use now.\n---\n\n# B\n"); + const result = listSkills(); + expect(result.skills.length).toBe(0); + }); + + test("surfaces parse errors", () => { + const skillDir = join(tmp, "broken"); + mkdirSync(skillDir); + writeFileSync(join(skillDir, "SKILL.md"), "not valid yaml at all"); + const result = listSkills(); + expect(result.errors.length).toBe(1); + expect(result.errors[0].name).toBe("broken"); + }); +}); + +describe("writeSkill and readSkill", () => { + test("creates a new skill", () => { + const result = writeSkill( + { name: "mirror", frontmatter: validSkill, body: "# Mirror\n\n## Goal\n\nDo it.\n" }, + { mustExist: false }, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.skill.name).toBe("mirror"); + expect(result.previousBody).toBe(null); + expect(existsSync(join(tmp, "mirror", "SKILL.md"))).toBe(true); + }); + + test("refuses to overwrite on create", () => { + writeSkill({ name: "mirror", frontmatter: validSkill, body: "# Mirror\n" }, { mustExist: false }); + const second = writeSkill({ name: "mirror", frontmatter: validSkill, body: "# Again\n" }, { mustExist: false }); + expect(second.ok).toBe(false); + }); + + test("updates an existing skill and returns the previous body", () => { + writeSkill({ name: "mirror", frontmatter: validSkill, body: "# First\n" }, { mustExist: false }); + const updated = writeSkill({ name: "mirror", frontmatter: validSkill, body: "# Second\n" }, { mustExist: true }); + expect(updated.ok).toBe(true); + if (!updated.ok) return; + expect(updated.previousBody?.includes("First")).toBe(true); + expect(updated.skill.body.includes("Second")).toBe(true); + }); + + test("read returns 404 for missing skill", () => { + const result = readSkill("does-not-exist"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(404); + }); + + test("write rejects body over 50KB", () => { + const giantBody = "x".repeat(60 * 1024); + const result = writeSkill({ name: "mirror", frontmatter: validSkill, body: giantBody }, { mustExist: false }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(413); + }); + + test("write rejects mismatched frontmatter.name and path name", () => { + const result = writeSkill( + { name: "mirror", frontmatter: { ...validSkill, name: "thread" }, body: "# Body\n" }, + { mustExist: false }, + ); + expect(result.ok).toBe(false); + }); +}); + +describe("deleteSkill", () => { + test("removes an existing skill", () => { + writeSkill({ name: "mirror", frontmatter: validSkill, body: "# Mirror\n" }, { mustExist: false }); + const result = deleteSkill("mirror"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.deleted).toBe("mirror"); + expect(existsSync(join(tmp, "mirror", "SKILL.md"))).toBe(false); + }); + + test("returns 404 for missing skill", () => { + const result = deleteSkill("nope"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(404); + }); +}); diff --git a/src/skills/audit.ts b/src/skills/audit.ts new file mode 100644 index 0000000..d7f00d2 --- /dev/null +++ b/src/skills/audit.ts @@ -0,0 +1,57 @@ +// Audit log for skill edits. Every create/update/delete from the UI API writes +// a row here so the user can see the history of their skills. The agent's own +// Write tool edits bypass this path today; if we want to catch those we'll add +// a file watcher in a later PR. + +import type { Database } from "bun:sqlite"; + +export type SkillAuditAction = "create" | "update" | "delete"; + +export type SkillAuditEntry = { + id: number; + skill_name: string; + action: SkillAuditAction; + previous_body: string | null; + new_body: string | null; + actor: string; + created_at: string; +}; + +export function recordSkillEdit( + db: Database, + params: { + name: string; + action: SkillAuditAction; + previousBody: string | null; + newBody: string | null; + actor: string; + }, +): void { + db.run( + `INSERT INTO skill_audit_log (skill_name, action, previous_body, new_body, actor) + VALUES (?, ?, ?, ?, ?)`, + [params.name, params.action, params.previousBody, params.newBody, params.actor], + ); +} + +export function listSkillEdits(db: Database, skillName?: string, limit = 50): SkillAuditEntry[] { + if (skillName) { + return db + .query( + `SELECT id, skill_name, action, previous_body, new_body, actor, created_at + FROM skill_audit_log + WHERE skill_name = ? + ORDER BY id DESC + LIMIT ?`, + ) + .all(skillName, limit) as SkillAuditEntry[]; + } + return db + .query( + `SELECT id, skill_name, action, previous_body, new_body, actor, created_at + FROM skill_audit_log + ORDER BY id DESC + LIMIT ?`, + ) + .all(limit) as SkillAuditEntry[]; +} diff --git a/src/skills/frontmatter.ts b/src/skills/frontmatter.ts new file mode 100644 index 0000000..268070d --- /dev/null +++ b/src/skills/frontmatter.ts @@ -0,0 +1,142 @@ +// Parse and serialize SKILL.md frontmatter. +// +// Format (verified from cli.js:9050-9112, see 03b findings doc): +// +// --- +// name: skill-name +// description: one-line description +// when_to_use: When Claude should auto-invoke this skill, including trigger phrases. +// allowed-tools: +// - Read +// - Glob +// - mcp__phantom-reflective__phantom_memory_search +// argument-hint: "[topic]" +// arguments: +// - topic +// context: inline +// disable-model-invocation: false +// --- +// +// # Skill Title +// +// body... +// +// The SDK only requires `name`, `description`, `when_to_use`. Everything else is +// optional. Zod validates the shape and rejects unknown fields loudly so typos +// surface to the user instead of silently doing nothing. + +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { z } from "zod"; + +export const SKILL_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/; +export const MAX_BODY_BYTES = 50 * 1024; // 50 KB + +export const SkillContextSchema = z.enum(["inline", "fork"]); + +export const SkillFrontmatterSchema = z + .object({ + name: z + .string() + .min(1) + .regex(SKILL_NAME_PATTERN, "name must be lowercase letters, digits, and hyphens, starting with a letter"), + description: z.string().min(1, "description is required").max(240), + when_to_use: z.string().min(1, "when_to_use is required"), + "allowed-tools": z.array(z.string().min(1)).optional(), + "argument-hint": z.string().optional(), + arguments: z.array(z.string().min(1)).optional(), + context: SkillContextSchema.optional(), + "disable-model-invocation": z.boolean().optional(), + }) + .strict(); + +export type SkillFrontmatter = z.infer; + +export type ParsedSkill = { + frontmatter: SkillFrontmatter; + body: string; +}; + +export type ParseResult = { ok: true; parsed: ParsedSkill } | { ok: false; error: string }; + +export function parseFrontmatter(raw: string): ParseResult { + if (typeof raw !== "string") { + return { ok: false, error: "Input must be a string" }; + } + + const normalized = raw.replace(/^\uFEFF/, ""); + const lines = normalized.split(/\r?\n/); + + if (lines[0]?.trim() !== "---") { + return { ok: false, error: "SKILL.md must start with a YAML frontmatter block opened by '---'" }; + } + + let endIndex = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === "---") { + endIndex = i; + break; + } + } + if (endIndex === -1) { + return { ok: false, error: "SKILL.md frontmatter block is not closed with '---'" }; + } + + const yamlText = lines.slice(1, endIndex).join("\n"); + const body = lines + .slice(endIndex + 1) + .join("\n") + .replace(/^\n+/, ""); + + let yamlParsed: unknown; + try { + yamlParsed = parseYaml(yamlText); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, error: `Invalid YAML frontmatter: ${msg}` }; + } + + if (yamlParsed == null || typeof yamlParsed !== "object") { + return { ok: false, error: "Frontmatter must be a YAML object" }; + } + + const result = SkillFrontmatterSchema.safeParse(yamlParsed); + if (!result.success) { + const issue = result.error.issues[0]; + const path = issue.path.length > 0 ? issue.path.join(".") : "frontmatter"; + return { ok: false, error: `${path}: ${issue.message}` }; + } + + return { ok: true, parsed: { frontmatter: result.data, body } }; +} + +export function serializeSkill(frontmatter: SkillFrontmatter, body: string): string { + const ordered: Record = {}; + const orderedKeys: Array = [ + "name", + "description", + "when_to_use", + "allowed-tools", + "argument-hint", + "arguments", + "context", + "disable-model-invocation", + ]; + for (const key of orderedKeys) { + const value = frontmatter[key]; + if (value !== undefined) { + ordered[key] = value; + } + } + + const yaml = stringifyYaml(ordered, { lineWidth: 0, defaultStringType: "PLAIN" }).trimEnd(); + const trimmedBody = body.replace(/^\n+/, "").replace(/\s+$/, ""); + return `---\n${yaml}\n---\n\n${trimmedBody}\n`; +} + +export function getBodyByteLength(body: string): number { + return new TextEncoder().encode(body).byteLength; +} + +export function isBodyWithinLimit(body: string): boolean { + return getBodyByteLength(body) <= MAX_BODY_BYTES; +} diff --git a/src/skills/linter.ts b/src/skills/linter.ts new file mode 100644 index 0000000..145d535 --- /dev/null +++ b/src/skills/linter.ts @@ -0,0 +1,95 @@ +// Lint a SKILL.md for common mistakes and surface actionable warnings. +// +// The linter is advisory, not prescriptive. The Cardinal Rule says the agent is +// the brain; if a user wants to ship a skill with `Bash(*)` we let them. What +// we do is surface obvious shell red flags so they know what they are doing. + +import { MAX_BODY_BYTES, type SkillFrontmatter, getBodyByteLength } from "./frontmatter.ts"; + +export type LintLevel = "info" | "warning" | "error"; + +export type LintHint = { + level: LintLevel; + field: string; + message: string; +}; + +// Red-list patterns we warn on. Not a security boundary, just a nudge. +const SHELL_RED_LIST: Array<{ pattern: RegExp; label: string }> = [ + { pattern: /rm\s+-rf\s+\//, label: "rm -rf /" }, + { pattern: /curl[^\n]*\|\s*sh/, label: "curl | sh" }, + { pattern: /wget[^\n]*\|\s*sh/, label: "wget | sh" }, + { pattern: /\|\s*sudo/, label: "pipe to sudo" }, + { pattern: /base64\s+-d[^\n]*\|\s*sh/, label: "base64 -d | sh" }, + { pattern: /chmod\s+777/, label: "chmod 777" }, + { pattern: /eval\s*\(\s*['"]/, label: "eval() with string literal" }, +]; + +export function lintSkill(frontmatter: SkillFrontmatter, body: string): LintHint[] { + const hints: LintHint[] = []; + + if (!frontmatter["allowed-tools"] || frontmatter["allowed-tools"].length === 0) { + hints.push({ + level: "warning", + field: "allowed-tools", + message: + "No allowed-tools set. The agent has full access by default. Consider listing the specific tools this skill needs.", + }); + } + + const whenToUseWords = frontmatter.when_to_use.trim().split(/\s+/).length; + if (whenToUseWords < 6) { + hints.push({ + level: "warning", + field: "when_to_use", + message: "when_to_use should include trigger phrases so the model knows when to invoke this skill.", + }); + } + + const bytes = getBodyByteLength(body); + if (bytes > MAX_BODY_BYTES) { + hints.push({ + level: "error", + field: "body", + message: `Body is ${(bytes / 1024).toFixed(1)} KB, over the ${MAX_BODY_BYTES / 1024} KB limit.`, + }); + } else if (bytes > MAX_BODY_BYTES * 0.8) { + hints.push({ + level: "info", + field: "body", + message: `Body is ${(bytes / 1024).toFixed(1)} KB, approaching the ${MAX_BODY_BYTES / 1024} KB limit.`, + }); + } + + for (const { pattern, label } of SHELL_RED_LIST) { + if (pattern.test(body)) { + hints.push({ + level: "warning", + field: "body", + message: `Body contains a pattern often used in destructive shell commands: ${label}.`, + }); + } + } + + if (!/^#\s+/m.test(body)) { + hints.push({ + level: "info", + field: "body", + message: "Body does not start with a Markdown heading. Conventional SKILL.md uses '# Title' as the first line.", + }); + } + + if (hints.length === 0) { + hints.push({ + level: "info", + field: "body", + message: "All checks passed.", + }); + } + + return hints; +} + +export function hasBlockingError(hints: LintHint[]): boolean { + return hints.some((h) => h.level === "error"); +} diff --git a/src/skills/paths.ts b/src/skills/paths.ts new file mode 100644 index 0000000..824acd8 --- /dev/null +++ b/src/skills/paths.ts @@ -0,0 +1,62 @@ +// Resolve and validate skill directory paths. +// +// Skills live in two scopes: +// user: ${HOME}/.claude/skills//SKILL.md (loaded via settingSources 'user') +// project: ${CWD}/.claude/skills//SKILL.md (loaded via settingSources 'project') +// +// PR1 exposes only the user scope in the dashboard. Project-scope skills are +// read-only informational today and will surface in a later PR if we decide +// to let the operator edit them from the UI. +// +// Path validation guarantees: +// - names are a strict subset of [a-z0-9][a-z0-9-]* max 64 chars +// - the resolved SKILL.md path canonically lives under the skills root +// - no null bytes, no relative segments, no symlinks leaking outside + +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +const USER_ENV_OVERRIDE = "PHANTOM_SKILLS_USER_ROOT"; +const NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/; + +export type SkillPathResolution = { + root: string; + dir: string; + file: string; +}; + +export function getUserSkillsRoot(): string { + const override = process.env[USER_ENV_OVERRIDE]; + if (override) { + return resolve(override); + } + return resolve(homedir(), ".claude", "skills"); +} + +export function getProjectSkillsRoot(cwd: string = process.cwd()): string { + return resolve(cwd, ".claude", "skills"); +} + +export function isValidSkillName(name: string): boolean { + if (typeof name !== "string") return false; + if (name.includes("\0")) return false; + return NAME_PATTERN.test(name); +} + +export function resolveUserSkillPath(name: string): SkillPathResolution { + if (!isValidSkillName(name)) { + throw new Error(`Invalid skill name: must match ${NAME_PATTERN.source}. Got: ${JSON.stringify(name)}`); + } + const root = getUserSkillsRoot(); + const dir = resolve(root, name); + const file = resolve(dir, "SKILL.md"); + + if (!dir.startsWith(`${root}/`) && dir !== root) { + throw new Error(`Path escape detected: ${dir} is not inside ${root}`); + } + if (!file.startsWith(`${dir}/`) && file !== `${dir}/SKILL.md`) { + throw new Error(`SKILL.md path escape detected: ${file}`); + } + + return { root, dir, file }; +} diff --git a/src/skills/storage.ts b/src/skills/storage.ts new file mode 100644 index 0000000..d438b26 --- /dev/null +++ b/src/skills/storage.ts @@ -0,0 +1,306 @@ +// Storage layer for SKILL.md files under the user-scope skills root. +// +// Atomic writes via tmp-then-rename on the same filesystem. No file locking: +// the founder's decision is last-write-wins. We still go through tmp+rename so +// a crash mid-write never leaves a torn file on disk. + +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + MAX_BODY_BYTES, + type ParseResult, + type SkillFrontmatter, + getBodyByteLength, + isBodyWithinLimit, + parseFrontmatter, + serializeSkill, +} from "./frontmatter.ts"; +import { getUserSkillsRoot, isValidSkillName, resolveUserSkillPath } from "./paths.ts"; + +export type SkillSource = "user" | "built-in" | "agent" | "unknown"; + +export type SkillSummary = { + name: string; + description: string; + when_to_use: string; + source: SkillSource; + path: string; + mtime: string; // ISO + size: number; + has_allowed_tools: boolean; + disable_model_invocation: boolean; +}; + +export type SkillDetail = SkillSummary & { + frontmatter: SkillFrontmatter; + body: string; + raw: string; +}; + +export type ListResult = { + skills: SkillSummary[]; + errors: Array<{ name: string; error: string }>; +}; + +const BUILT_IN_MARKER_FIELD = "x-phantom-source"; // optional, future use + +function ensureDir(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +function detectSource(frontmatter: SkillFrontmatter): SkillSource { + const marker = (frontmatter as unknown as Record)[BUILT_IN_MARKER_FIELD]; + if (typeof marker === "string") { + if (marker === "built-in") return "built-in"; + if (marker === "agent") return "agent"; + if (marker === "user") return "user"; + } + return "user"; +} + +function summaryFromParsed( + name: string, + path: string, + raw: string, + frontmatter: SkillFrontmatter, + mtime: Date, +): SkillSummary { + return { + name, + description: frontmatter.description, + when_to_use: frontmatter.when_to_use, + source: detectSource(frontmatter), + path, + mtime: mtime.toISOString(), + size: new TextEncoder().encode(raw).byteLength, + has_allowed_tools: Array.isArray(frontmatter["allowed-tools"]) && frontmatter["allowed-tools"].length > 0, + disable_model_invocation: frontmatter["disable-model-invocation"] === true, + }; +} + +export function listSkills(): ListResult { + const root = getUserSkillsRoot(); + const errors: Array<{ name: string; error: string }> = []; + const skills: SkillSummary[] = []; + + if (!existsSync(root)) { + return { skills, errors }; + } + + let entries: string[]; + try { + entries = readdirSync(root); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { skills, errors: [{ name: "", error: `Failed to list skills root: ${msg}` }] }; + } + + for (const entry of entries.sort()) { + if (!isValidSkillName(entry)) { + continue; + } + const skillFile = join(root, entry, "SKILL.md"); + if (!existsSync(skillFile)) { + continue; + } + let raw: string; + let stats: ReturnType; + try { + raw = readFileSync(skillFile, "utf-8"); + stats = statSync(skillFile); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + errors.push({ name: entry, error: `Failed to read: ${msg}` }); + continue; + } + + const parsed = parseFrontmatter(raw); + if (!parsed.ok) { + errors.push({ name: entry, error: parsed.error }); + continue; + } + + skills.push(summaryFromParsed(entry, skillFile, raw, parsed.parsed.frontmatter, stats.mtime)); + } + + // Built-in first (by mtime asc, stable), then user by mtime desc. + skills.sort((a, b) => { + const aBuiltin = a.source === "built-in" ? 0 : 1; + const bBuiltin = b.source === "built-in" ? 0 : 1; + if (aBuiltin !== bBuiltin) return aBuiltin - bBuiltin; + if (a.source === "built-in") return a.name.localeCompare(b.name); + return b.mtime.localeCompare(a.mtime); + }); + + return { skills, errors }; +} + +export type ReadResult = { ok: true; skill: SkillDetail } | { ok: false; status: 404 | 422 | 500; error: string }; + +export function readSkill(name: string): ReadResult { + if (!isValidSkillName(name)) { + return { ok: false, status: 422, error: `Invalid skill name: ${JSON.stringify(name)}` }; + } + let file: string; + try { + file = resolveUserSkillPath(name).file; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 422, error: msg }; + } + if (!existsSync(file)) { + return { ok: false, status: 404, error: `Skill not found: ${name}` }; + } + let raw: string; + let stats: ReturnType; + try { + raw = readFileSync(file, "utf-8"); + stats = statSync(file); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 500, error: `Failed to read skill: ${msg}` }; + } + const parsed: ParseResult = parseFrontmatter(raw); + if (!parsed.ok) { + return { ok: false, status: 422, error: parsed.error }; + } + const summary = summaryFromParsed(name, file, raw, parsed.parsed.frontmatter, stats.mtime); + return { + ok: true, + skill: { + ...summary, + frontmatter: parsed.parsed.frontmatter, + body: parsed.parsed.body, + raw, + }, + }; +} + +export type WriteResult = + | { ok: true; skill: SkillDetail; previousBody: string | null } + | { ok: false; status: 400 | 404 | 409 | 413 | 422 | 500; error: string }; + +export type WriteInput = { + name: string; + frontmatter: SkillFrontmatter; + body: string; +}; + +function writeAtomic(file: string, content: string): void { + const dir = dirname(file); + ensureDir(dir); + const tmp = join(dir, `.SKILL.md.tmp-${process.pid}-${Date.now()}`); + writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o644 }); + renameSync(tmp, file); +} + +export function writeSkill(input: WriteInput, options: { mustExist: boolean }): WriteResult { + const { name, frontmatter, body } = input; + + if (!isValidSkillName(name)) { + return { ok: false, status: 422, error: `Invalid skill name: ${JSON.stringify(name)}` }; + } + if (frontmatter.name !== name) { + return { + ok: false, + status: 422, + error: `Frontmatter name '${frontmatter.name}' does not match path name '${name}'`, + }; + } + if (!isBodyWithinLimit(body)) { + return { + ok: false, + status: 413, + error: `Body is ${(getBodyByteLength(body) / 1024).toFixed(1)} KB, over the ${MAX_BODY_BYTES / 1024} KB limit.`, + }; + } + + let file: string; + try { + file = resolveUserSkillPath(name).file; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 422, error: msg }; + } + + let previousBody: string | null = null; + if (existsSync(file)) { + if (!options.mustExist) { + return { ok: false, status: 409, error: `Skill already exists: ${name}` }; + } + try { + const prevRaw = readFileSync(file, "utf-8"); + const prevParsed = parseFrontmatter(prevRaw); + if (prevParsed.ok) { + previousBody = prevParsed.parsed.body; + } else { + previousBody = prevRaw; + } + } catch { + previousBody = null; + } + } else if (options.mustExist) { + return { ok: false, status: 404, error: `Skill not found: ${name}` }; + } + + const serialized = serializeSkill(frontmatter, body); + + try { + writeAtomic(file, serialized); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 500, error: `Failed to write skill: ${msg}` }; + } + + const read = readSkill(name); + if (!read.ok) { + return { ok: false, status: 500, error: `Write succeeded but read-back failed: ${read.error}` }; + } + return { ok: true, skill: read.skill, previousBody }; +} + +export type DeleteResult = + | { ok: true; deleted: string; previousBody: string | null } + | { ok: false; status: 404 | 422 | 500; error: string }; + +export function deleteSkill(name: string): DeleteResult { + if (!isValidSkillName(name)) { + return { ok: false, status: 422, error: `Invalid skill name: ${JSON.stringify(name)}` }; + } + let dir: string; + let file: string; + try { + const r = resolveUserSkillPath(name); + dir = r.dir; + file = r.file; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 422, error: msg }; + } + if (!existsSync(file)) { + return { ok: false, status: 404, error: `Skill not found: ${name}` }; + } + let previousBody: string | null = null; + try { + const prevRaw = readFileSync(file, "utf-8"); + const prevParsed = parseFrontmatter(prevRaw); + previousBody = prevParsed.ok ? prevParsed.parsed.body : prevRaw; + } catch { + previousBody = null; + } + try { + rmSync(file, { force: true }); + // Best-effort: remove the parent directory if empty + try { + rmSync(dir, { recursive: false }); + } catch { + // directory not empty or missing; non-fatal + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, status: 500, error: `Failed to delete skill: ${msg}` }; + } + return { ok: true, deleted: name, previousBody }; +} From 717c052b3c6f80ae97a3ba66d5c0cb67b83b33d4 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Mon, 13 Apr 2026 16:40:21 -0700 Subject: [PATCH 2/6] feat: cookie-auth skills and memory-files UI API routes Wire /ui/api/skills and /ui/api/memory-files into the existing serve.ts request pipeline. Both route sets live behind the phantom_session cookie check and return JSON bodies. Every mutating call records a row in the appropriate audit log table. Routes: - GET /ui/api/skills list - POST /ui/api/skills create - GET /ui/api/skills/:name read - PUT /ui/api/skills/:name update - DELETE /ui/api/skills/:name delete - GET /ui/api/memory-files list - POST /ui/api/memory-files create (body includes path) - GET /ui/api/memory-files/ read - PUT /ui/api/memory-files/ update - DELETE /ui/api/memory-files/ delete Adds setDashboardDb() to hand the database into the api dispatch so the audit log writes go through the already-open connection used elsewhere. --- src/index.ts | 7 +- src/ui/api/__tests__/memory-files.test.ts | 126 ++++++++++++ src/ui/api/__tests__/skills.test.ts | 152 ++++++++++++++ src/ui/api/memory-files.ts | 187 ++++++++++++++++++ src/ui/api/skills.ts | 231 ++++++++++++++++++++++ src/ui/serve.ts | 24 +++ 6 files changed, 725 insertions(+), 2 deletions(-) create mode 100644 src/ui/api/__tests__/memory-files.test.ts create mode 100644 src/ui/api/__tests__/skills.test.ts create mode 100644 src/ui/api/memory-files.ts create mode 100644 src/ui/api/skills.ts diff --git a/src/index.ts b/src/index.ts index 1b01d01..0b96a9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { existsSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; +import { createReflectiveToolServer } from "./agent/in-process-reflective-tools.ts"; import { createInProcessToolServer } from "./agent/in-process-tools.ts"; import { AgentRuntime } from "./agent/runtime.ts"; import type { RuntimeEvent } from "./agent/runtime.ts"; @@ -54,7 +55,7 @@ import { createSecretToolServer } from "./secrets/tools.ts"; import { createBrowserToolServer } from "./ui/browser-mcp.ts"; import { setLoginPageAgentName } from "./ui/login-page.ts"; import { closePreviewResources, createPreviewToolServer, getOrCreatePreviewContext } from "./ui/preview.ts"; -import { setPublicDir, setSecretSavedCallback, setSecretsDb } from "./ui/serve.ts"; +import { setDashboardDb, setPublicDir, setSecretSavedCallback, setSecretsDb } from "./ui/serve.ts"; import { createWebUiToolServer } from "./ui/tools.ts"; async function main(): Promise { @@ -85,6 +86,7 @@ async function main(): Promise { const db = getDatabase(); runMigrations(db); setSecretsDb(db); + setDashboardDb(db); console.log("[phantom] Database ready"); // Seed working memory file if it does not exist yet @@ -193,6 +195,7 @@ async function main(): Promise { runtime.setMcpServerFactories({ "phantom-dynamic-tools": () => createInProcessToolServer(registry), "phantom-scheduler": () => createSchedulerToolServer(scheduler as Scheduler), + "phantom-reflective": () => createReflectiveToolServer(memory.isReady() ? memory : null, db), "phantom-web-ui": () => createWebUiToolServer(config.public_url, config.name), "phantom-secrets": () => createSecretToolServer({ db, baseUrl: secretsBaseUrl }), "phantom-preview": () => createPreviewToolServer(config.port), @@ -210,7 +213,7 @@ async function main(): Promise { }); const emailStatus = process.env.RESEND_API_KEY ? " + email" : ""; console.log( - `[mcp] MCP server initialized (dynamic tools + scheduler + web UI + secrets + preview + browser${emailStatus} wired to agent)`, + `[mcp] MCP server initialized (dynamic tools + scheduler + reflective + web UI + secrets + preview + browser${emailStatus} wired to agent)`, ); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/ui/api/__tests__/memory-files.test.ts b/src/ui/api/__tests__/memory-files.test.ts new file mode 100644 index 0000000..5a82f5f --- /dev/null +++ b/src/ui/api/__tests__/memory-files.test.ts @@ -0,0 +1,126 @@ +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { MIGRATIONS } from "../../../db/schema.ts"; +import { handleUiRequest, setDashboardDb, setPublicDir } from "../../serve.ts"; +import { createSession, revokeAllSessions } from "../../session.ts"; + +setPublicDir(resolve(import.meta.dir, "../../../../public")); + +let tmp: string; +let db: Database; +let sessionToken: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "phantom-memfiles-api-")); + process.env.PHANTOM_MEMORY_FILES_ROOT = tmp; + db = new Database(":memory:"); + for (const migration of MIGRATIONS) { + try { + db.run(migration); + } catch { + // ignore ALTER TABLE duplicate failures + } + } + setDashboardDb(db); + sessionToken = createSession().sessionToken; +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + Reflect.deleteProperty(process.env, "PHANTOM_MEMORY_FILES_ROOT"); + db.close(); + revokeAllSessions(); +}); + +function req(path: string, init?: RequestInit): Request { + return new Request(`http://localhost${path}`, { + ...init, + headers: { + Cookie: `phantom_session=${encodeURIComponent(sessionToken)}`, + Accept: "application/json", + ...((init?.headers as Record) ?? {}), + }, + }); +} + +describe("memory-files API", () => { + test("401 without session cookie", async () => { + const res = await handleUiRequest( + new Request("http://localhost/ui/api/memory-files", { headers: { Accept: "application/json" } }), + ); + expect(res.status).toBe(401); + }); + + test("GET /ui/api/memory-files returns empty list", async () => { + const res = await handleUiRequest(req("/ui/api/memory-files")); + expect(res.status).toBe(200); + const body = (await res.json()) as { files: unknown[] }; + expect(body.files.length).toBe(0); + }); + + test("POST creates a memory file at a nested path", async () => { + const res = await handleUiRequest( + req("/ui/api/memory-files", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "memory/notes.md", content: "# Notes\n" }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { file: { path: string; content: string } }; + expect(body.file.path).toBe("memory/notes.md"); + expect(body.file.content).toBe("# Notes\n"); + }); + + test("POST rejects skills/ paths", async () => { + const res = await handleUiRequest( + req("/ui/api/memory-files", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "skills/evil.md", content: "x" }), + }), + ); + expect(res.status).toBe(422); + }); + + test("GET encoded path returns the file", async () => { + await handleUiRequest( + req("/ui/api/memory-files", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "CLAUDE.md", content: "# Top\n" }), + }), + ); + const res = await handleUiRequest(req(`/ui/api/memory-files/${encodeURIComponent("CLAUDE.md")}`)); + expect(res.status).toBe(200); + const body = (await res.json()) as { file: { path: string; content: string } }; + expect(body.file.path).toBe("CLAUDE.md"); + }); + + test("PUT updates and DELETE removes", async () => { + await handleUiRequest( + req("/ui/api/memory-files", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "CLAUDE.md", content: "first" }), + }), + ); + const put = await handleUiRequest( + req(`/ui/api/memory-files/${encodeURIComponent("CLAUDE.md")}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "second" }), + }), + ); + expect(put.status).toBe(200); + const del = await handleUiRequest( + req(`/ui/api/memory-files/${encodeURIComponent("CLAUDE.md")}`, { method: "DELETE" }), + ); + expect(del.status).toBe(200); + const list = (await (await handleUiRequest(req("/ui/api/memory-files"))).json()) as { files: unknown[] }; + expect(list.files.length).toBe(0); + }); +}); diff --git a/src/ui/api/__tests__/skills.test.ts b/src/ui/api/__tests__/skills.test.ts new file mode 100644 index 0000000..1250165 --- /dev/null +++ b/src/ui/api/__tests__/skills.test.ts @@ -0,0 +1,152 @@ +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { MIGRATIONS } from "../../../db/schema.ts"; +import { handleUiRequest, setDashboardDb, setPublicDir } from "../../serve.ts"; +import { createSession, revokeAllSessions } from "../../session.ts"; + +setPublicDir(resolve(import.meta.dir, "../../../../public")); + +let tmp: string; +let db: Database; +let sessionToken: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "phantom-skills-api-")); + process.env.PHANTOM_SKILLS_USER_ROOT = tmp; + db = new Database(":memory:"); + for (const migration of MIGRATIONS) { + try { + db.run(migration); + } catch { + // ALTER TABLE may fail on a fresh schema; safe to ignore in tests + } + } + setDashboardDb(db); + const session = createSession(); + sessionToken = session.sessionToken; +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + Reflect.deleteProperty(process.env, "PHANTOM_SKILLS_USER_ROOT"); + db.close(); + revokeAllSessions(); +}); + +function req(path: string, init?: RequestInit): Request { + return new Request(`http://localhost${path}`, { + ...init, + headers: { + Cookie: `phantom_session=${encodeURIComponent(sessionToken)}`, + Accept: "application/json", + ...((init?.headers as Record) ?? {}), + }, + }); +} + +describe("skills API", () => { + test("401 without session cookie", async () => { + const res = await handleUiRequest( + new Request("http://localhost/ui/api/skills", { headers: { Accept: "application/json" } }), + ); + expect(res.status).toBe(401); + }); + + test("GET /ui/api/skills returns empty list initially", async () => { + const res = await handleUiRequest(req("/ui/api/skills")); + expect(res.status).toBe(200); + const body = (await res.json()) as { skills: unknown[]; errors: unknown[] }; + expect(Array.isArray(body.skills)).toBe(true); + expect(body.skills.length).toBe(0); + }); + + test("POST creates a new skill", async () => { + const res = await handleUiRequest( + req("/ui/api/skills", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + frontmatter: { + name: "mirror", + description: "weekly", + when_to_use: "Use on Friday evening when the user asks for a mirror.", + }, + body: "# Mirror\n\n## Goal\nA goal.\n", + }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { skill: { name: string } }; + expect(body.skill.name).toBe("mirror"); + }); + + test("GET /ui/api/skills/:name returns 404 when missing", async () => { + const res = await handleUiRequest(req("/ui/api/skills/ghost")); + expect(res.status).toBe(404); + }); + + test("PUT updates and records audit", async () => { + await handleUiRequest( + req("/ui/api/skills", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + frontmatter: { + name: "mirror", + description: "v1", + when_to_use: "Use on Friday evening when the user asks.", + }, + body: "# First\n", + }), + }), + ); + const res = await handleUiRequest( + req("/ui/api/skills/mirror", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + frontmatter: { + name: "mirror", + description: "v2", + when_to_use: "Use on Friday evening when the user asks.", + }, + body: "# Second\n", + }), + }), + ); + expect(res.status).toBe(200); + const rows = db.query("SELECT action, previous_body, new_body FROM skill_audit_log ORDER BY id").all() as Array<{ + action: string; + previous_body: string | null; + new_body: string | null; + }>; + expect(rows.length).toBeGreaterThanOrEqual(2); + const update = rows.find((r) => r.action === "update"); + expect(update?.previous_body?.includes("First")).toBe(true); + expect(update?.new_body?.includes("Second")).toBe(true); + }); + + test("DELETE removes the skill", async () => { + await handleUiRequest( + req("/ui/api/skills", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + frontmatter: { + name: "mirror", + description: "x", + when_to_use: "Use when the user asks for it and trigger phrases match.", + }, + body: "# T\n", + }), + }), + ); + const res = await handleUiRequest(req("/ui/api/skills/mirror", { method: "DELETE" })); + expect(res.status).toBe(200); + const list = (await (await handleUiRequest(req("/ui/api/skills"))).json()) as { skills: unknown[] }; + expect(list.skills.length).toBe(0); + }); +}); diff --git a/src/ui/api/memory-files.ts b/src/ui/api/memory-files.ts new file mode 100644 index 0000000..1a76450 --- /dev/null +++ b/src/ui/api/memory-files.ts @@ -0,0 +1,187 @@ +// UI API routes for memory files CRUD. +// +// All routes live under /ui/api/memory-files and are cookie-auth gated. +// +// GET /ui/api/memory-files -> list +// GET /ui/api/memory-files/ -> read one +// POST /ui/api/memory-files -> create (body: { path, content }) +// PUT /ui/api/memory-files/ -> update (body: { content }) +// DELETE /ui/api/memory-files/ -> delete +// +// `` is a URL-encoded relative path from the memory files root. The path +// may include forward slashes. We extract it by stripping the route prefix. + +import type { Database } from "bun:sqlite"; +import { recordMemoryFileEdit } from "../../memory-files/audit.ts"; +import { + type DeleteResult, + MEMORY_FILE_MAX_BYTES, + type MemoryFileDetail, + type ReadResult, + type WriteResult, + deleteMemoryFile, + listMemoryFiles, + readMemoryFile, + writeMemoryFile, +} from "../../memory-files/storage.ts"; + +type MemoryFilesApiDeps = { + db: Database; +}; + +function json(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + ...((init?.headers as Record) ?? {}), + }, + }); +} + +async function readJson(req: Request): Promise { + try { + return await req.json(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { __error: `Invalid JSON body: ${msg}` }; + } +} + +function detailJson(file: MemoryFileDetail): Record { + return { + path: file.path, + size: file.size, + mtime: file.mtime, + top_level: file.top_level, + content: file.content, + }; +} + +function readResponse(result: ReadResult): Response { + if (!result.ok) return json({ error: result.error }, { status: result.status }); + return json({ file: detailJson(result.file) }); +} + +function writeResponse(result: WriteResult): Response { + if (!result.ok) return json({ error: result.error }, { status: result.status }); + return json({ file: detailJson(result.file) }); +} + +function deleteResponse(result: DeleteResult): Response { + if (!result.ok) return json({ error: result.error }, { status: result.status }); + return json({ deleted: result.deleted }); +} + +function parseWriteBody(raw: unknown): { ok: true; content: string } | { ok: false; error: string } { + if (!raw || typeof raw !== "object") { + return { ok: false, error: "Request body must be a JSON object" }; + } + const shape = raw as { content?: unknown }; + if (typeof shape.content !== "string") { + return { ok: false, error: "content field must be a string" }; + } + return { ok: true, content: shape.content }; +} + +function parseCreateBody(raw: unknown): { ok: true; path: string; content: string } | { ok: false; error: string } { + if (!raw || typeof raw !== "object") { + return { ok: false, error: "Request body must be a JSON object" }; + } + const shape = raw as { path?: unknown; content?: unknown }; + if (typeof shape.path !== "string") { + return { ok: false, error: "path field must be a string" }; + } + if (typeof shape.content !== "string") { + return { ok: false, error: "content field must be a string" }; + } + return { ok: true, path: shape.path, content: shape.content }; +} + +export async function handleMemoryFilesApi(req: Request, url: URL, deps: MemoryFilesApiDeps): Promise { + const pathname = url.pathname; + + // GET /ui/api/memory-files + if (pathname === "/ui/api/memory-files" && req.method === "GET") { + const result = listMemoryFiles(); + return json({ + files: result.files, + limits: { max_content_bytes: MEMORY_FILE_MAX_BYTES }, + }); + } + + // POST /ui/api/memory-files + if (pathname === "/ui/api/memory-files" && req.method === "POST") { + const body = await readJson(req); + if (body && typeof body === "object" && "__error" in body) { + return json({ error: (body as { __error: string }).__error }, { status: 400 }); + } + const parsed = parseCreateBody(body); + if (!parsed.ok) return json({ error: parsed.error }, { status: 422 }); + const result = writeMemoryFile({ path: parsed.path, content: parsed.content }, { mustExist: false }); + if (result.ok) { + recordMemoryFileEdit(deps.db, { + path: parsed.path, + action: "create", + previousContent: null, + newContent: result.file.content, + actor: "user", + }); + } + return writeResponse(result); + } + + // /ui/api/memory-files/ + if (pathname.startsWith("/ui/api/memory-files/")) { + const encoded = pathname.slice("/ui/api/memory-files/".length); + let relative: string; + try { + relative = decodeURIComponent(encoded); + } catch { + return json({ error: "Invalid URL-encoded path" }, { status: 400 }); + } + + if (req.method === "GET") { + return readResponse(readMemoryFile(relative)); + } + + if (req.method === "PUT") { + const body = await readJson(req); + if (body && typeof body === "object" && "__error" in body) { + return json({ error: (body as { __error: string }).__error }, { status: 400 }); + } + const parsed = parseWriteBody(body); + if (!parsed.ok) return json({ error: parsed.error }, { status: 422 }); + const result = writeMemoryFile({ path: relative, content: parsed.content }, { mustExist: true }); + if (result.ok) { + recordMemoryFileEdit(deps.db, { + path: relative, + action: "update", + previousContent: result.previousContent, + newContent: result.file.content, + actor: "user", + }); + } + return writeResponse(result); + } + + if (req.method === "DELETE") { + const result = deleteMemoryFile(relative); + if (result.ok) { + recordMemoryFileEdit(deps.db, { + path: relative, + action: "delete", + previousContent: result.previousContent, + newContent: null, + actor: "user", + }); + } + return deleteResponse(result); + } + + return json({ error: "Method not allowed" }, { status: 405 }); + } + + return null; +} diff --git a/src/ui/api/skills.ts b/src/ui/api/skills.ts new file mode 100644 index 0000000..f90c58a --- /dev/null +++ b/src/ui/api/skills.ts @@ -0,0 +1,231 @@ +// UI API routes for skills CRUD. +// +// All routes live under /ui/api/skills and are cookie-auth gated at the +// serve.ts level (the router dispatches only after isAuthenticated passes). +// +// GET /ui/api/skills -> list +// GET /ui/api/skills/:name -> read one +// POST /ui/api/skills -> create (body: { name, frontmatter, body }) +// PUT /ui/api/skills/:name -> update (body: { frontmatter, body }) +// DELETE /ui/api/skills/:name -> delete +// +// JSON bodies in and out. All error responses are { error: string }. + +import type { Database } from "bun:sqlite"; +import { recordSkillEdit } from "../../skills/audit.ts"; +import { + MAX_BODY_BYTES, + type SkillFrontmatter, + SkillFrontmatterSchema, + getBodyByteLength, +} from "../../skills/frontmatter.ts"; +import { lintSkill } from "../../skills/linter.ts"; +import { + type DeleteResult, + type ReadResult, + type WriteResult, + deleteSkill, + listSkills, + readSkill, + writeSkill, +} from "../../skills/storage.ts"; + +type SkillsApiDeps = { + db: Database; +}; + +function json(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + ...((init?.headers as Record) ?? {}), + }, + }); +} + +function parseWriteBody( + raw: unknown, +): { ok: true; frontmatter: SkillFrontmatter; body: string } | { ok: false; error: string } { + if (!raw || typeof raw !== "object") { + return { ok: false, error: "Request body must be a JSON object" }; + } + const shape = raw as { frontmatter?: unknown; body?: unknown }; + if (typeof shape.body !== "string") { + return { ok: false, error: "body field must be a string" }; + } + if (shape.frontmatter == null || typeof shape.frontmatter !== "object") { + return { ok: false, error: "frontmatter field must be an object" }; + } + const parsed = SkillFrontmatterSchema.safeParse(shape.frontmatter); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const path = issue.path.length > 0 ? issue.path.join(".") : "frontmatter"; + return { ok: false, error: `${path}: ${issue.message}` }; + } + return { ok: true, frontmatter: parsed.data, body: shape.body }; +} + +function readResponse(result: ReadResult): Response { + if (!result.ok) { + return json({ error: result.error }, { status: result.status }); + } + return json({ + skill: { + name: result.skill.name, + description: result.skill.description, + when_to_use: result.skill.when_to_use, + source: result.skill.source, + path: result.skill.path, + mtime: result.skill.mtime, + size: result.skill.size, + has_allowed_tools: result.skill.has_allowed_tools, + disable_model_invocation: result.skill.disable_model_invocation, + frontmatter: result.skill.frontmatter, + body: result.skill.body, + lint: lintSkill(result.skill.frontmatter, result.skill.body), + }, + }); +} + +function writeResponse(result: WriteResult): Response { + if (!result.ok) { + return json({ error: result.error }, { status: result.status }); + } + return json({ + skill: { + name: result.skill.name, + description: result.skill.description, + when_to_use: result.skill.when_to_use, + source: result.skill.source, + path: result.skill.path, + mtime: result.skill.mtime, + size: result.skill.size, + has_allowed_tools: result.skill.has_allowed_tools, + disable_model_invocation: result.skill.disable_model_invocation, + frontmatter: result.skill.frontmatter, + body: result.skill.body, + lint: lintSkill(result.skill.frontmatter, result.skill.body), + }, + }); +} + +function deleteResponse(result: DeleteResult): Response { + if (!result.ok) { + return json({ error: result.error }, { status: result.status }); + } + return json({ deleted: result.deleted }); +} + +async function readJson(req: Request): Promise { + try { + return await req.json(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { __error: `Invalid JSON body: ${msg}` }; + } +} + +export async function handleSkillsApi(req: Request, url: URL, deps: SkillsApiDeps): Promise { + const pathname = url.pathname; + + // GET /ui/api/skills + if (pathname === "/ui/api/skills" && req.method === "GET") { + const result = listSkills(); + return json({ + skills: result.skills, + errors: result.errors, + limits: { max_body_bytes: MAX_BODY_BYTES }, + }); + } + + // POST /ui/api/skills + if (pathname === "/ui/api/skills" && req.method === "POST") { + const body = await readJson(req); + if (body && typeof body === "object" && "__error" in body) { + return json({ error: (body as { __error: string }).__error }, { status: 400 }); + } + const parsed = parseWriteBody(body); + if (!parsed.ok) { + return json({ error: parsed.error }, { status: 422 }); + } + const result = writeSkill( + { name: parsed.frontmatter.name, frontmatter: parsed.frontmatter, body: parsed.body }, + { mustExist: false }, + ); + if (result.ok) { + recordSkillEdit(deps.db, { + name: result.skill.name, + action: "create", + previousBody: null, + newBody: result.skill.body, + actor: "user", + }); + } + return writeResponse(result); + } + + // /ui/api/skills/:name + const match = pathname.match(/^\/ui\/api\/skills\/([^/]+)$/); + if (match) { + const name = decodeURIComponent(match[1]); + + if (req.method === "GET") { + return readResponse(readSkill(name)); + } + + if (req.method === "PUT") { + const body = await readJson(req); + if (body && typeof body === "object" && "__error" in body) { + return json({ error: (body as { __error: string }).__error }, { status: 400 }); + } + const parsed = parseWriteBody(body); + if (!parsed.ok) { + return json({ error: parsed.error }, { status: 422 }); + } + if (parsed.frontmatter.name !== name) { + return json( + { error: `Frontmatter name '${parsed.frontmatter.name}' does not match path name '${name}'` }, + { status: 422 }, + ); + } + const bytes = getBodyByteLength(parsed.body); + if (bytes > MAX_BODY_BYTES) { + return json( + { error: `Body is ${(bytes / 1024).toFixed(1)} KB, over the ${MAX_BODY_BYTES / 1024} KB limit.` }, + { status: 413 }, + ); + } + const result = writeSkill({ name, frontmatter: parsed.frontmatter, body: parsed.body }, { mustExist: true }); + if (result.ok) { + recordSkillEdit(deps.db, { + name, + action: "update", + previousBody: result.previousBody, + newBody: result.skill.body, + actor: "user", + }); + } + return writeResponse(result); + } + + if (req.method === "DELETE") { + const result = deleteSkill(name); + if (result.ok) { + recordSkillEdit(deps.db, { + name, + action: "delete", + previousBody: result.previousBody, + newBody: null, + actor: "user", + }); + } + return deleteResponse(result); + } + + return json({ error: "Method not allowed" }, { status: 405 }); + } + + return null; +} diff --git a/src/ui/serve.ts b/src/ui/serve.ts index c431bea..ed3cd13 100644 --- a/src/ui/serve.ts +++ b/src/ui/serve.ts @@ -6,12 +6,15 @@ import { consumeMagicLink, createSession, isValidSession } from "./session.ts"; import { secretsExpiredHtml, secretsFormHtml } from "../secrets/form-page.ts"; import { getSecretRequest, saveSecrets, validateMagicToken } from "../secrets/store.ts"; +import { handleMemoryFilesApi } from "./api/memory-files.ts"; +import { handleSkillsApi } from "./api/skills.ts"; const COOKIE_NAME = "phantom_session"; const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days in seconds let publicDir = resolve(process.cwd(), "public"); let secretsDb: Database | null = null; +let dashboardDb: Database | null = null; type SecretSavedCallback = (requestId: string, secretNames: string[]) => Promise; let onSecretSaved: SecretSavedCallback | null = null; @@ -20,6 +23,10 @@ export function setSecretsDb(db: Database): void { secretsDb = db; } +export function setDashboardDb(db: Database): void { + dashboardDb = db; +} + export function setSecretSavedCallback(fn: SecretSavedCallback): void { onSecretSaved = fn; } @@ -125,6 +132,23 @@ export async function handleUiRequest(req: Request): Promise { return createSSEResponse(); } + // Dashboard API routes (PR1). Return as soon as one matches so the static + // file fallthrough below never sees them. + if (url.pathname.startsWith("/ui/api/skills")) { + if (!dashboardDb) { + return Response.json({ error: "Dashboard API not initialized" }, { status: 503 }); + } + const apiResponse = await handleSkillsApi(req, url, { db: dashboardDb }); + if (apiResponse) return apiResponse; + } + if (url.pathname.startsWith("/ui/api/memory-files")) { + if (!dashboardDb) { + return Response.json({ error: "Dashboard API not initialized" }, { status: 503 }); + } + const apiResponse = await handleMemoryFilesApi(req, url, { db: dashboardDb }); + if (apiResponse) return apiResponse; + } + // Static files const filePath = isPathSafe(url.pathname); if (!filePath) { From c9dd1a9bdd13e9d4cd65ff42fcc7b606df202472 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Mon, 13 Apr 2026 16:40:31 -0700 Subject: [PATCH 3/6] feat: in-process reflective tools and dashboard awareness prompt block Expose phantom_memory_search and phantom_list_sessions to the agent itself as a new in-process MCP server (phantom-reflective). This is what makes the five reflective built-in skills actually fireable: they can query memory with temporal filters and enumerate recent sessions without having to round-trip through the external MCP endpoint at /mcp. phantom_memory_search wraps MemorySystem.recallEpisodes and recallFacts with an optional days_back filter that maps to RecallOptions.timeRange and the 'temporal' strategy. phantom_list_sessions reads the sessions SQLite table directly with channel and days_back filters. Also adds a small 'dashboard awareness' prompt block wired into the environment section of the system prompt. The agent now knows the dashboard exists at /ui/dashboard/, that its skills and memory files are editable there, and that it should point the operator at those URLs when asked 'what can I edit' or similar. --- src/agent/in-process-reflective-tools.ts | 170 ++++++++++++++++++ src/agent/prompt-assembler.ts | 3 + .../prompt-blocks/dashboard-awareness.ts | 46 +++++ 3 files changed, 219 insertions(+) create mode 100644 src/agent/in-process-reflective-tools.ts create mode 100644 src/agent/prompt-blocks/dashboard-awareness.ts diff --git a/src/agent/in-process-reflective-tools.ts b/src/agent/in-process-reflective-tools.ts new file mode 100644 index 0000000..2d24740 --- /dev/null +++ b/src/agent/in-process-reflective-tools.ts @@ -0,0 +1,170 @@ +// In-process MCP server exposing reflective memory and session tools to the +// agent itself, so the built-in reflective skills (mirror, thread, echo, +// overheard, ritual) can actually fire. +// +// The external MCP server at /mcp already has similar tools for outside +// clients (phantom_memory_query, phantom_history). Those are served by +// src/mcp/tools-universal.ts. The Agent SDK subprocess cannot see the external +// MCP server without going through HTTP, so we expose a thin in-process server +// with two tools and register it via runtime.setMcpServerFactories() in +// src/index.ts. +// +// Naming note: we call the tools phantom_memory_search and +// phantom_list_sessions (matching the SKILL.md allowed-tools field) even +// though the external server's equivalents are called phantom_memory_query +// and phantom_history. The builder brief and the skill catalog use the new +// names; the old external-facing names stay for backward compatibility. + +import type { Database } from "bun:sqlite"; +import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"; +import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; +import type { MemorySystem } from "../memory/system.ts"; +import type { RecallOptions } from "../memory/types.ts"; + +type DbRow = Record; + +function ok(data: unknown): { content: Array<{ type: "text"; text: string }> } { + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +function err(message: string): { content: Array<{ type: "text"; text: string }>; isError: true } { + return { + content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }], + isError: true, + }; +} + +function daysAgo(n: number): Date { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - n); + return d; +} + +export function createReflectiveToolServer(memory: MemorySystem | null, db: Database): McpSdkServerConfigWithInstance { + const memorySearch = tool( + "phantom_memory_search", + `Search the agent's persistent memory for past sessions, topics, and facts. Supports semantic search and temporal filtering. + +Use this when reflecting on past behavior, looking up prior conversations, finding patterns, or checking whether a question has already been resolved. Returns episodes (past conversation turns with outcomes), facts (learned knowledge about the user, team, codebase), and optional procedures. + +- query: the semantic search text. For temporal scans, use a broad query like "this week" or the topic keyword. +- memory_type: "episodic" for past sessions, "semantic" for facts, "all" for both. Default is "all". +- days_back: optional. Limits results to items from the last N days. +- limit: max results per type. Default 10, max 50. + +Each episode includes summary, detail, outcome, started_at, tools_used, and lessons. Facts include natural_language, category, confidence, and valid_from.`, + { + query: z.string().min(1).describe("Semantic search text or topic keyword"), + memory_type: z + .enum(["episodic", "semantic", "all"]) + .optional() + .default("all") + .describe("Which memory tier to search"), + days_back: z.number().int().min(1).max(365).optional().describe("Optional: limit to items from the last N days"), + limit: z.number().int().min(1).max(50).optional().default(10).describe("Max results per tier"), + }, + async (input) => { + if (!memory || !memory.isReady()) { + return ok({ + warning: "Memory system is not available. Returning empty results.", + results: { episodes: [], facts: [] }, + totalMatches: 0, + }); + } + try { + const limit = input.limit ?? 10; + const opts: RecallOptions = { limit }; + if (input.days_back !== undefined) { + opts.timeRange = { from: daysAgo(input.days_back), to: new Date() }; + opts.strategy = "temporal"; + } + + const results: Record = {}; + + if (input.memory_type === "episodic" || input.memory_type === "all") { + results.episodes = await memory.recallEpisodes(input.query, opts).catch(() => []); + } + if (input.memory_type === "semantic" || input.memory_type === "all") { + results.facts = await memory.recallFacts(input.query, { limit }).catch(() => []); + } + + const total = Object.values(results).reduce((sum, arr) => sum + arr.length, 0); + return ok({ + query: input.query, + days_back: input.days_back ?? null, + results, + totalMatches: total, + }); + } catch (caught: unknown) { + const msg = caught instanceof Error ? caught.message : String(caught); + return err(`Memory search failed: ${msg}`); + } + }, + ); + + const listSessions = tool( + "phantom_list_sessions", + `List recent conversation sessions the agent has had, with channel, start time, turn count, and total cost. + +Use this to anchor reflective observations to specific days and channels, to count interactions across a window, or to pick the most expensive recent session for a cost explanation. Returns the most recently active sessions first. + +- limit: max sessions to return. Default 20, max 200. +- days_back: optional. Only sessions active within the last N days. +- channel: optional. Substring filter on channel_id (e.g. "slack" to get all slack sessions). + +Each row has session_key, channel_id, conversation_id, status, total_cost_usd, turn_count, created_at, last_active_at.`, + { + limit: z.number().int().min(1).max(200).optional().default(20), + days_back: z.number().int().min(1).max(365).optional(), + channel: z.string().optional(), + }, + async (input) => { + try { + const conds: string[] = []; + const params: unknown[] = []; + if (input.days_back !== undefined) { + const cutoff = daysAgo(input.days_back).toISOString(); + conds.push("last_active_at >= ?"); + params.push(cutoff); + } + if (input.channel) { + conds.push("channel_id LIKE ?"); + params.push(`%${input.channel}%`); + } + const where = conds.length > 0 ? `WHERE ${conds.join(" AND ")}` : ""; + const limit = input.limit ?? 20; + params.push(limit); + + const rows = db + .query( + `SELECT session_key, sdk_session_id, channel_id, conversation_id, status, + total_cost_usd, input_tokens, output_tokens, turn_count, + created_at, last_active_at + FROM sessions + ${where} + ORDER BY last_active_at DESC + LIMIT ?`, + ) + .all(...(params as string[])) as DbRow[]; + + return ok({ + sessions: rows, + count: rows.length, + filters: { + days_back: input.days_back ?? null, + channel: input.channel ?? null, + }, + }); + } catch (caught: unknown) { + const msg = caught instanceof Error ? caught.message : String(caught); + return err(`Session listing failed: ${msg}`); + } + }, + ); + + return createSdkMcpServer({ + name: "phantom-reflective", + tools: [memorySearch, listSessions], + }); +} diff --git a/src/agent/prompt-assembler.ts b/src/agent/prompt-assembler.ts index 9f972ed..589c97f 100644 --- a/src/agent/prompt-assembler.ts +++ b/src/agent/prompt-assembler.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import type { PhantomConfig } from "../config/types.ts"; import type { EvolvedConfig } from "../evolution/types.ts"; import type { RoleTemplate } from "../roles/types.ts"; +import { buildDashboardAwarenessLines } from "./prompt-blocks/dashboard-awareness.ts"; import { buildEvolvedSections } from "./prompt-blocks/evolved.ts"; import { buildInstructions } from "./prompt-blocks/instructions.ts"; import { buildSecurity } from "./prompt-blocks/security.ts"; @@ -135,6 +136,8 @@ function buildEnvironment(config: PhantomConfig): string { lines.push(""); lines.push(...buildUIGuidanceLines(publicUrl ?? undefined)); lines.push(""); + lines.push(...buildDashboardAwarenessLines(publicUrl ?? undefined)); + lines.push(""); lines.push("SELF-VALIDATE EVERY UI PAGE YOU CREATE."); lines.push("After phantom_create_page succeeds, always call phantom_preview_page with"); lines.push("the same path. Review the screenshot, the HTTP status, the page title,"); diff --git a/src/agent/prompt-blocks/dashboard-awareness.ts b/src/agent/prompt-blocks/dashboard-awareness.ts new file mode 100644 index 0000000..fa2673e --- /dev/null +++ b/src/agent/prompt-blocks/dashboard-awareness.ts @@ -0,0 +1,46 @@ +// Dashboard awareness block: tells the agent that the operator has a +// dashboard at /ui/dashboard where they can edit skills and memory files, +// so the agent can direct them to it when asked "what can I edit" or +// "how do I customize you". +// +// This is one of two complementary paths. The other is the show-my-tools +// built-in skill under skills-builtin/show-my-tools/SKILL.md which actually +// enumerates the current catalog. The block is always-on; the skill fires +// on demand. + +export function buildDashboardAwarenessLines(publicUrl: string | undefined): string[] { + const base = publicUrl?.replace(/\/$/, "") ?? ""; + const dashboardUrl = base ? `${base}/ui/dashboard/` : "/ui/dashboard/"; + const skillsUrl = base ? `${base}/ui/dashboard/#/skills` : "/ui/dashboard/#/skills"; + const memoryUrl = base ? `${base}/ui/dashboard/#/memory-files` : "/ui/dashboard/#/memory-files"; + + const lines: string[] = []; + lines.push(""); + lines.push("=== YOUR DASHBOARD ==="); + lines.push(""); + lines.push("Your operator has a dashboard they use to shape how you work. It is a"); + lines.push("hand-crafted UI, separate from the pages you generate with phantom_create_page."); + lines.push("Two tabs are live today:"); + lines.push(""); + lines.push(`- Skills: ${skillsUrl}`); + lines.push(" Markdown files under /home/phantom/.claude/skills//SKILL.md."); + lines.push(" Your operator can create, edit, and delete skills here. You read every"); + lines.push(" skill's name, description, and when_to_use at the start of every message,"); + lines.push(" so any edit your operator makes is live on your next turn. You can also"); + lines.push(" write your own skills by creating SKILL.md files at the same path; they"); + lines.push(" appear in the dashboard automatically."); + lines.push(""); + lines.push(`- Memory files: ${memoryUrl}`); + lines.push(" Arbitrary markdown files under /home/phantom/.claude/. Includes"); + lines.push(" CLAUDE.md (your top-level memory), rules/*.md (scoped rules), and"); + lines.push(" memory/*.md (anything your operator wants you to know permanently)."); + lines.push(" Edits are picked up on your next session start."); + lines.push(""); + lines.push("When your operator asks 'what can I customize', 'how do I edit your skills',"); + lines.push(`'show me the dashboard', or anything similar, point them at ${dashboardUrl}`); + lines.push("and (if they want the current catalog) fire the show-my-tools skill."); + lines.push(""); + lines.push("Other tabs (sessions, cost, scheduler, evolution, memory explorer, settings)"); + lines.push("are marked Coming Soon in the dashboard today and will light up in later PRs."); + return lines; +} From de9f7f64e002416b24060e4d913f4e16dddb91c6 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Mon, 13 Apr 2026 16:40:48 -0700 Subject: [PATCH 4/6] feat: dashboard shell, skills editor, memory-files editor The hand-crafted operator dashboard at /ui/dashboard/. A single static HTML shell with a sticky nav, a sidebar of eight tabs, and a main content area that hash-routes between live tabs and Coming Soon placeholders. Two tabs are live in PR1: - Skills: left column of skill cards grouped by source (built-in vs yours) with substring search. Right column is a full-fidelity editor with a structured YAML frontmatter form (name, description, when_to_use, allowed-tools chip input with autocomplete, argument hint, context select, user-invoke-only toggle) and a large JetBrains Mono body textarea. Tab inserts two spaces, Cmd/Ctrl+S saves, dirty-state dot pulses next to the title, lint hints render under the body, delete has a confirm modal. New skill modal offers blank or duplicate-from-mirror templates. - Memory files: same split layout over any .md file under /home/phantom/.claude/. New file modal accepts any path ending in .md, creates nested directories automatically, and opens the editor on the new file. CLAUDE.md gets a small info banner noting it is the top-level memory loaded every session. Six Coming Soon placeholders (sessions, cost, scheduler, evolution, memory explorer, settings) render a serif headline, the expected PR, and a link back to skills. No React, no build step. Vanilla JS with one namespaced helper per tab. Tailwind and DaisyUI tokens are not loaded on this shell; the dashboard inherits the phantom- vocabulary spiritually by declaring its own phantom-nav, phantom-chip, phantom-mono, phantom-meta, and phantom-muted classes from the same token values as the base template. Light and dark themes share the same primary indigo with cream or warm-deep-dark surfaces. Also adds a Dashboard quick-link to the existing /ui/ landing page. beforeunload guards unsaved edits and the router respects the dirty state on navigation. --- public/dashboard/dashboard.css | 968 +++++++++++++++++++++++++++++++ public/dashboard/dashboard.js | 346 +++++++++++ public/dashboard/index.html | 104 ++++ public/dashboard/memory-files.js | 374 ++++++++++++ public/dashboard/skills.js | 609 +++++++++++++++++++ public/index.html | 8 +- 6 files changed, 2405 insertions(+), 4 deletions(-) create mode 100644 public/dashboard/dashboard.css create mode 100644 public/dashboard/dashboard.js create mode 100644 public/dashboard/index.html create mode 100644 public/dashboard/memory-files.js create mode 100644 public/dashboard/skills.js diff --git a/public/dashboard/dashboard.css b/public/dashboard/dashboard.css new file mode 100644 index 0000000..90ffee4 --- /dev/null +++ b/public/dashboard/dashboard.css @@ -0,0 +1,968 @@ +/* Dashboard stylesheet. Inherits the phantom-* token vocabulary from the + project's _base.html system. Defines dashboard-specific layout and a few + component classes that are not in the base vocabulary (sidebar, three-pane + editor, chip input, tooltip). */ + +:root { + --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 20px; --space-6: 24px; --space-8: 32px; --space-10: 40px; --space-12: 48px; --space-16: 64px; + --radius-sm: 8px; --radius-md: 10px; --radius-lg: 14px; --radius-xl: 20px; --radius-pill: 9999px; + --motion-fast: 100ms; --motion-base: 150ms; --motion-slow: 300ms; + --ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94); + --ease-reveal: cubic-bezier(0.4, 0, 0.2, 1); + + --dash-sidebar-width: 248px; + --dash-header-height: 56px; +} + +[data-theme="phantom-light"] { + --color-base-100: #faf9f5; + --color-base-200: #ffffff; + --color-base-300: #ece9df; + --color-base-content: #1c1917; + --color-primary: #4850c4; + --color-primary-content: #ffffff; + --color-success: #16a34a; + --color-warning: #ca8a04; + --color-error: #dc2626; + --color-info: #2563eb; + color-scheme: light; +} + +[data-theme="phantom-dark"] { + --color-base-100: #0b0a09; + --color-base-200: #161412; + --color-base-300: #26211d; + --color-base-content: #f7f6f1; + --color-primary: #7078e0; + --color-primary-content: #0b0a09; + --color-success: #4ade80; + --color-warning: #fbbf24; + --color-error: #f87171; + --color-info: #60a5fa; + color-scheme: dark; +} + +html { + transition: background-color var(--motion-base) ease, color var(--motion-base) ease; +} + +body { + background: var(--color-base-100); + color: var(--color-base-content); + font-family: Inter, system-ui, -apple-system, sans-serif; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; + margin: 0; + min-height: 100vh; +} + +@keyframes dash-fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes dash-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} +@keyframes dash-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ==== Nav (reduced copy of phantom-nav to stay self-contained) ==== */ +.phantom-nav { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-3) var(--space-8); + border-bottom: 1px solid var(--color-base-300); + background: color-mix(in oklab, var(--color-base-100) 85%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 20; +} +.phantom-nav-brand { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-family: 'Instrument Serif', Georgia, serif; + font-size: 18px; + color: var(--color-base-content); + text-decoration: none; +} +.phantom-nav-logo { + display: inline-flex; + width: 22px; height: 22px; + border-radius: 6px; + background: var(--color-primary); + color: var(--color-primary-content); + align-items: center; + justify-content: center; + font-family: 'Instrument Serif', serif; + font-size: 14px; + font-weight: 500; +} +.phantom-breadcrumb-sep { + color: color-mix(in oklab, var(--color-base-content) 25%, transparent); +} +.phantom-breadcrumb { + font-size: 13px; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); +} +.phantom-nav-spacer { flex: 1; } +.phantom-nav-date { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); + display: none; +} +@media (min-width: 640px) { + .phantom-nav-date { display: inline; } +} +.phantom-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 6px 10px; + border: 1px solid var(--color-base-300); + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-base-content); + cursor: pointer; + transition: border-color var(--motion-fast) var(--ease-out), background-color var(--motion-fast) var(--ease-out); +} +.phantom-chip:hover { + border-color: color-mix(in oklab, var(--color-primary) 40%, var(--color-base-300)); + background: color-mix(in oklab, var(--color-primary) 4%, transparent); +} +.phantom-mono { + font-family: 'JetBrains Mono', monospace; +} +.phantom-muted { color: color-mix(in oklab, var(--color-base-content) 55%, transparent); } +.phantom-meta { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); +} + +/* ==== Shell layout ==== */ +.dash-shell { + display: grid; + grid-template-columns: var(--dash-sidebar-width) 1fr; + min-height: calc(100vh - var(--dash-header-height)); +} +@media (max-width: 820px) { + .dash-shell { grid-template-columns: 1fr; } +} + +/* ==== Sidebar ==== */ +.dash-sidebar { + border-right: 1px solid var(--color-base-300); + padding: var(--space-6) var(--space-4) var(--space-6); + background: color-mix(in oklab, var(--color-base-100) 98%, var(--color-base-200)); + display: flex; + flex-direction: column; + gap: var(--space-2); +} +@media (max-width: 820px) { + .dash-sidebar { + border-right: none; + border-bottom: 1px solid var(--color-base-300); + } +} +.dash-sidebar-eyebrow { + font-family: Inter, sans-serif; + font-size: 10px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.09em; + text-transform: uppercase; + color: color-mix(in oklab, var(--color-base-content) 42%, transparent); + padding: 0 var(--space-2) var(--space-2); +} +.dash-sidebar-nav { + display: flex; + flex-direction: column; + gap: 2px; +} +.dash-sidebar-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 9px 12px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + color: color-mix(in oklab, var(--color-base-content) 68%, transparent); + text-decoration: none; + transition: background-color var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); +} +.dash-sidebar-item:hover { + color: var(--color-base-content); + background: color-mix(in oklab, var(--color-base-content) 5%, transparent); +} +.dash-sidebar-item[aria-current="page"] { + background: color-mix(in oklab, var(--color-primary) 10%, transparent); + color: var(--color-primary); +} +.dash-sidebar-icon { + width: 16px; height: 16px; + flex-shrink: 0; + opacity: 0.85; +} +.dash-sidebar-item-soon { + opacity: 0.52; + cursor: default; + pointer-events: auto; +} +.dash-sidebar-item-soon:hover { + background: transparent; + color: color-mix(in oklab, var(--color-base-content) 68%, transparent); +} +.dash-sidebar-soon-pill { + margin-left: auto; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: color-mix(in oklab, var(--color-base-content) 45%, transparent); + background: color-mix(in oklab, var(--color-base-content) 6%, transparent); + padding: 2px 7px; + border-radius: var(--radius-pill); +} +.dash-sidebar-footer { + margin-top: auto; + padding: var(--space-4) var(--space-2) 0; + border-top: 1px solid color-mix(in oklab, var(--color-base-300) 70%, transparent); +} +.dash-sidebar-footer .phantom-meta { display: block; margin: 2px 0; } + +/* ==== Main route area ==== */ +.dash-main { + padding: var(--space-8); + max-width: 1320px; + width: 100%; + box-sizing: border-box; + animation: dash-fade-in var(--motion-slow) var(--ease-reveal); +} +@media (max-width: 820px) { + .dash-main { padding: var(--space-5); } +} +.dash-route { display: none; } +.dash-route[data-active="true"] { display: block; } + +.dash-header { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-6); +} +.dash-header-eyebrow { + font-family: Inter, sans-serif; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: color-mix(in oklab, var(--color-base-content) 48%, transparent); + margin: 0; +} +.dash-header-title { + font-family: 'Instrument Serif', Georgia, serif; + font-size: 36px; + font-weight: 500; + line-height: 1.1; + letter-spacing: -0.012em; + margin: 0; +} +.dash-header-lead { + font-size: 14px; + line-height: 1.55; + color: color-mix(in oklab, var(--color-base-content) 62%, transparent); + max-width: 620px; + margin: 4px 0 0; +} +.dash-header-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); + flex-wrap: wrap; +} + +/* ==== Buttons ==== */ +.dash-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + font-family: Inter, sans-serif; + font-size: 13px; + font-weight: 500; + line-height: 1; + padding: 9px 16px; + border-radius: var(--radius-pill); + border: 1px solid transparent; + background: var(--color-base-content); + color: var(--color-base-100); + cursor: pointer; + text-decoration: none; + transition: opacity var(--motion-fast) var(--ease-out), + transform var(--motion-fast) var(--ease-out), + background-color var(--motion-fast) var(--ease-out), + border-color var(--motion-fast) var(--ease-out); +} +.dash-btn:hover { opacity: 0.88; } +.dash-btn:active { transform: translateY(1px); } +.dash-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.dash-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 28%, transparent); +} +.dash-btn-primary { + background: var(--color-primary); + color: var(--color-primary-content); +} +.dash-btn-ghost { + background: transparent; + color: var(--color-base-content); + border-color: var(--color-base-300); +} +.dash-btn-ghost:hover { + background: color-mix(in oklab, var(--color-base-content) 5%, transparent); + opacity: 1; +} +.dash-btn-danger { + background: var(--color-error); + color: #ffffff; +} +.dash-btn-danger:hover { opacity: 0.92; } +.dash-btn-sm { + font-size: 12px; + padding: 7px 12px; +} +.dash-btn-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + padding: 5px 10px; + border: 1px solid var(--color-base-300); + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-base-content); + cursor: pointer; + transition: border-color var(--motion-fast), background-color var(--motion-fast); +} +.dash-btn-chip:hover { + border-color: color-mix(in oklab, var(--color-primary) 40%, var(--color-base-300)); + background: color-mix(in oklab, var(--color-primary) 4%, transparent); +} + +/* ==== Two-column list + editor layout ==== */ +.dash-split { + display: grid; + grid-template-columns: 320px 1fr; + gap: var(--space-5); + align-items: start; +} +@media (max-width: 1100px) { + .dash-split { grid-template-columns: 280px 1fr; } +} +@media (max-width: 860px) { + .dash-split { grid-template-columns: 1fr; } +} + +/* ==== List column ==== */ +.dash-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.dash-list-search { + position: relative; + margin-bottom: var(--space-2); +} +.dash-list-search input { + width: 100%; + box-sizing: border-box; + font-family: Inter, sans-serif; + font-size: 13px; + background: var(--color-base-200); + color: var(--color-base-content); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + padding: 9px 12px 9px 34px; + transition: border-color var(--motion-base) var(--ease-out), box-shadow var(--motion-base) var(--ease-out); +} +.dash-list-search input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 18%, transparent); +} +.dash-list-search svg { + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); + pointer-events: none; +} +.dash-list-group-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: color-mix(in oklab, var(--color-base-content) 42%, transparent); + padding: var(--space-3) var(--space-2) 4px; +} +.dash-list-card { + display: block; + text-decoration: none; + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + background: var(--color-base-200); + cursor: pointer; + transition: border-color var(--motion-fast) var(--ease-out), background-color var(--motion-fast) var(--ease-out); +} +.dash-list-card:hover { + border-color: color-mix(in oklab, var(--color-primary) 30%, var(--color-base-300)); +} +.dash-list-card[aria-current="page"] { + border-color: var(--color-primary); + background: color-mix(in oklab, var(--color-primary) 5%, var(--color-base-200)); +} +.dash-list-card-row { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: 3px; +} +.dash-list-card-title { + font-size: 13px; + font-weight: 600; + color: var(--color-base-content); + margin: 0; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dash-list-card-desc { + font-size: 12px; + line-height: 1.45; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); + margin: 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; +} +.dash-source-chip { + font-size: 10px; + font-weight: 500; + letter-spacing: 0.03em; + padding: 2px 7px; + border-radius: var(--radius-pill); + white-space: nowrap; +} +.dash-source-chip-built-in { + background: color-mix(in oklab, var(--color-primary) 12%, transparent); + color: var(--color-primary); +} +.dash-source-chip-user { + background: color-mix(in oklab, var(--color-base-content) 7%, transparent); + color: color-mix(in oklab, var(--color-base-content) 65%, transparent); +} +.dash-source-chip-agent { + background: color-mix(in oklab, var(--color-info) 12%, transparent); + color: var(--color-info); +} +.dash-list-card-meta { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: color-mix(in oklab, var(--color-base-content) 45%, transparent); +} + +/* ==== Empty state ==== */ +.dash-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-12) var(--space-5); + text-align: center; + border: 1px dashed var(--color-base-300); + border-radius: var(--radius-lg); +} +.dash-empty-icon { + width: 44px; + height: 44px; + margin-bottom: var(--space-3); + opacity: 0.35; +} +.dash-empty-title { + font-family: 'Instrument Serif', Georgia, serif; + font-size: 20px; + font-weight: 500; + color: var(--color-base-content); + margin: 0 0 var(--space-2); +} +.dash-empty-body { + font-size: 13px; + line-height: 1.55; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); + margin: 0 0 var(--space-4); + max-width: 380px; +} + +/* ==== Editor column ==== */ +.dash-editor { + background: var(--color-base-200); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-lg); + padding: var(--space-5) var(--space-5) var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-4); +} +.dash-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + padding-bottom: var(--space-3); + border-bottom: 1px solid color-mix(in oklab, var(--color-base-300) 60%, transparent); +} +.dash-editor-title-wrap { flex: 1; min-width: 0; } +.dash-editor-title { + font-family: 'Instrument Serif', Georgia, serif; + font-size: 22px; + font-weight: 500; + margin: 0; + display: flex; + align-items: center; + gap: var(--space-2); +} +.dash-editor-subtitle { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); + margin: 4px 0 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dash-editor-actions { + display: flex; + gap: var(--space-2); + align-items: center; + flex-shrink: 0; +} + +.dash-dirty-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-warning); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-warning) 22%, transparent); + display: inline-block; + opacity: 0; + transition: opacity var(--motion-base) var(--ease-out); +} +.dash-dirty-dot[data-dirty="true"] { opacity: 1; } + +/* ==== Form ==== */ +.dash-form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} +.dash-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); +} +@media (max-width: 760px) { + .dash-form-grid { grid-template-columns: 1fr; } +} +.dash-field { + display: flex; + flex-direction: column; + gap: 6px; +} +.dash-field-label { + font-size: 12px; + font-weight: 500; + color: color-mix(in oklab, var(--color-base-content) 72%, transparent); + display: flex; + align-items: center; + gap: 6px; +} +.dash-field-hint { + font-size: 11px; + color: color-mix(in oklab, var(--color-base-content) 48%, transparent); + margin-top: 2px; +} +.dash-field-tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + background: color-mix(in oklab, var(--color-base-content) 10%, transparent); + color: color-mix(in oklab, var(--color-base-content) 70%, transparent); + font-size: 10px; + font-weight: 600; + cursor: help; + position: relative; +} +.dash-field-tip::after { + content: attr(data-tip); + position: absolute; + top: calc(100% + 6px); + left: 0; + max-width: 280px; + background: var(--color-base-content); + color: var(--color-base-100); + font-weight: 400; + font-size: 11px; + line-height: 1.45; + padding: 8px 10px; + border-radius: var(--radius-sm); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); + opacity: 0; + pointer-events: none; + z-index: 50; + white-space: normal; + width: max-content; + transition: opacity var(--motion-base) var(--ease-out); +} +.dash-field-tip:hover::after, +.dash-field-tip:focus::after { + opacity: 1; +} + +.dash-input, .dash-textarea, .dash-select { + width: 100%; + box-sizing: border-box; + font-family: Inter, system-ui, sans-serif; + font-size: 14px; + line-height: 1.45; + background: var(--color-base-100); + color: var(--color-base-content); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + padding: 10px 13px; + transition: border-color var(--motion-base) var(--ease-out), + box-shadow var(--motion-base) var(--ease-out); +} +.dash-input::placeholder, .dash-textarea::placeholder { + color: color-mix(in oklab, var(--color-base-content) 40%, transparent); +} +.dash-input:focus, .dash-textarea:focus, .dash-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 18%, transparent); +} +.dash-textarea { + resize: vertical; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 12.5px; + line-height: 1.6; + min-height: 100px; +} +.dash-textarea-tall { + min-height: 420px; + max-height: 72vh; +} + +.dash-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; + font-size: 12px; + color: color-mix(in oklab, var(--color-base-content) 72%, transparent); + user-select: none; +} +.dash-toggle input { position: absolute; opacity: 0; pointer-events: none; } +.dash-toggle-track { + width: 30px; + height: 18px; + border-radius: var(--radius-pill); + background: color-mix(in oklab, var(--color-base-content) 15%, transparent); + position: relative; + transition: background-color var(--motion-base) var(--ease-out); +} +.dash-toggle-track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-base-200); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform var(--motion-base) var(--ease-out); +} +.dash-toggle input:checked + .dash-toggle-track { + background: var(--color-primary); +} +.dash-toggle input:checked + .dash-toggle-track::after { + transform: translateX(12px); +} +.dash-toggle:focus-within .dash-toggle-track { + box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 18%, transparent); +} + +/* ==== Chip input (for allowed-tools) ==== */ +.dash-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 10px; + background: var(--color-base-100); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + min-height: 42px; + align-items: center; + transition: border-color var(--motion-base) var(--ease-out), box-shadow var(--motion-base) var(--ease-out); +} +.dash-chips:focus-within { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 18%, transparent); +} +.dash-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + padding: 3px 8px; + background: color-mix(in oklab, var(--color-primary) 10%, transparent); + color: var(--color-primary); + border-radius: var(--radius-pill); +} +.dash-chip button { + background: none; + border: 0; + font-size: 14px; + line-height: 1; + color: inherit; + opacity: 0.6; + padding: 0 0 0 2px; + cursor: pointer; +} +.dash-chip button:hover { opacity: 1; } +.dash-chips input { + flex: 1; + min-width: 120px; + border: 0; + outline: 0; + background: transparent; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--color-base-content); +} +.dash-chips input::placeholder { + color: color-mix(in oklab, var(--color-base-content) 40%, transparent); +} + +/* ==== Lint hints ==== */ +.dash-lint { + display: flex; + flex-direction: column; + gap: 6px; +} +.dash-lint-hint { + display: flex; + align-items: flex-start; + gap: var(--space-2); + font-size: 12px; + line-height: 1.5; + padding: 8px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--color-base-300); + background: var(--color-base-100); +} +.dash-lint-hint[data-level="error"] { + border-color: color-mix(in oklab, var(--color-error) 50%, var(--color-base-300)); + background: color-mix(in oklab, var(--color-error) 6%, transparent); + color: color-mix(in oklab, var(--color-error) 95%, var(--color-base-content)); +} +.dash-lint-hint[data-level="warning"] { + border-color: color-mix(in oklab, var(--color-warning) 45%, var(--color-base-300)); + background: color-mix(in oklab, var(--color-warning) 6%, transparent); +} +.dash-lint-hint[data-level="info"] { + border-color: color-mix(in oklab, var(--color-base-300) 80%, transparent); + background: var(--color-base-100); + color: color-mix(in oklab, var(--color-base-content) 70%, transparent); +} +.dash-lint-dot { + width: 6px; height: 6px; + border-radius: 50%; + margin-top: 6px; + flex-shrink: 0; +} +.dash-lint-hint[data-level="error"] .dash-lint-dot { background: var(--color-error); } +.dash-lint-hint[data-level="warning"] .dash-lint-dot { background: var(--color-warning); } +.dash-lint-hint[data-level="info"] .dash-lint-dot { background: color-mix(in oklab, var(--color-base-content) 45%, transparent); } + +/* ==== Alert ==== */ +.dash-alert { + display: flex; + gap: var(--space-3); + align-items: flex-start; + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + background: var(--color-base-200); + font-size: 13px; + line-height: 1.55; +} +.dash-alert-info { + border-color: color-mix(in oklab, var(--color-info) 40%, var(--color-base-300)); + background: color-mix(in oklab, var(--color-info) 5%, transparent); +} + +/* ==== Modal ==== */ +.dash-modal-backdrop { + position: fixed; + inset: 0; + background: color-mix(in oklab, var(--color-base-100) 70%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-6); + animation: dash-fade-in var(--motion-base) var(--ease-reveal); +} +.dash-modal { + background: var(--color-base-200); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-xl); + padding: var(--space-6); + max-width: 480px; + width: 100%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18), 0 4px 12px rgba(0, 0, 0, 0.06); +} +.dash-modal-title { + font-family: 'Instrument Serif', Georgia, serif; + font-size: 22px; + font-weight: 500; + margin: 0 0 var(--space-2); +} +.dash-modal-body { + font-size: 13px; + line-height: 1.55; + color: color-mix(in oklab, var(--color-base-content) 70%, transparent); + margin: 0 0 var(--space-4); +} +.dash-modal-actions { + display: flex; + gap: var(--space-2); + justify-content: flex-end; + margin-top: var(--space-4); +} + +/* ==== Toast ==== */ +.dash-toast-container { + position: fixed; + top: calc(var(--dash-header-height) + var(--space-4)); + right: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-2); + z-index: 200; + pointer-events: none; +} +.dash-toast { + pointer-events: auto; + padding: 10px 14px; + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + background: var(--color-base-200); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + font-size: 13px; + line-height: 1.4; + max-width: 340px; + animation: dash-fade-in var(--motion-slow) var(--ease-reveal); +} +.dash-toast-success { border-color: color-mix(in oklab, var(--color-success) 40%, var(--color-base-300)); } +.dash-toast-error { border-color: color-mix(in oklab, var(--color-error) 45%, var(--color-base-300)); } +.dash-toast-title { font-weight: 600; color: var(--color-base-content); margin: 0 0 2px; } +.dash-toast-body { color: color-mix(in oklab, var(--color-base-content) 70%, transparent); margin: 0; } + +/* ==== Coming soon placeholder ==== */ +.dash-soon { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 420px; + text-align: center; + padding: var(--space-12) var(--space-6); + border: 1px dashed color-mix(in oklab, var(--color-base-300) 80%, transparent); + border-radius: var(--radius-xl); + background: color-mix(in oklab, var(--color-base-200) 50%, transparent); +} +.dash-soon-eyebrow { + font-family: Inter, sans-serif; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--color-primary); + margin: 0 0 var(--space-3); +} +.dash-soon-title { + font-family: 'Instrument Serif', Georgia, serif; + font-size: 32px; + font-weight: 500; + line-height: 1.1; + margin: 0 0 var(--space-2); +} +.dash-soon-body { + font-size: 14px; + line-height: 1.6; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); + max-width: 480px; + margin: 0 auto var(--space-4); +} + +/* ==== Skeleton ==== */ +.dash-skeleton { + height: 14px; + border-radius: 6px; + background: linear-gradient(90deg, + var(--color-base-300) 25%, + color-mix(in oklab, var(--color-base-300) 50%, transparent) 50%, + var(--color-base-300) 75%); + background-size: 200% 100%; + animation: dash-shimmer 1.5s infinite; +} +.dash-skeleton-card { + padding: var(--space-4); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + background: var(--color-base-200); + display: flex; + flex-direction: column; + gap: 8px; +} diff --git a/public/dashboard/dashboard.js b/public/dashboard/dashboard.js new file mode 100644 index 0000000..9872538 --- /dev/null +++ b/public/dashboard/dashboard.js @@ -0,0 +1,346 @@ +// Dashboard shell: router, theme toggle, sidebar wiring, toast helpers, +// modal helpers, and a thin fetch wrapper. Each tab module (skills.js, +// memory-files.js) registers with the shell and is told to mount/unmount +// on route changes. + +(function () { + var routes = {}; + var activeRoute = null; + var dirtyCheckers = []; + + function qs(sel) { return document.querySelector(sel); } + function qsa(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); } + + function esc(s) { + if (s == null) return ""; + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function toast(kind, title, body) { + var container = qs("#toast-container"); + if (!container) return; + var el = document.createElement("div"); + el.className = "dash-toast dash-toast-" + kind; + el.setAttribute("role", kind === "error" ? "alert" : "status"); + var titleHtml = '

' + esc(title) + "

"; + var bodyHtml = body ? '

' + esc(body) + "

" : ""; + el.innerHTML = titleHtml + bodyHtml; + container.appendChild(el); + setTimeout(function () { + el.style.transition = "opacity 200ms ease, transform 200ms ease"; + el.style.opacity = "0"; + el.style.transform = "translateY(-6px)"; + }, kind === "error" ? 5000 : 2800); + setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + }, kind === "error" ? 5400 : 3100); + } + + function api(method, url, body) { + var init = { method: method, credentials: "same-origin", headers: {} }; + if (body !== undefined) { + init.headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + return fetch(url, init).then(function (res) { + var ct = res.headers.get("Content-Type") || ""; + var pJson = ct.indexOf("application/json") >= 0 ? res.json() : res.text(); + return pJson.then(function (parsed) { + if (!res.ok) { + var msg = (parsed && parsed.error) || ("HTTP " + res.status); + var e = new Error(msg); + e.status = res.status; + throw e; + } + return parsed; + }); + }); + } + + function openModal(options) { + var backdrop = document.createElement("div"); + backdrop.className = "dash-modal-backdrop"; + backdrop.setAttribute("role", "dialog"); + backdrop.setAttribute("aria-modal", "true"); + + var modal = document.createElement("div"); + modal.className = "dash-modal"; + + var title = document.createElement("h2"); + title.className = "dash-modal-title"; + title.textContent = options.title || ""; + + var body = document.createElement("div"); + body.className = "dash-modal-body"; + if (typeof options.body === "string") { + body.textContent = options.body; + } else if (options.body instanceof Node) { + body.appendChild(options.body); + } + + var actions = document.createElement("div"); + actions.className = "dash-modal-actions"; + (options.actions || []).forEach(function (action) { + var btn = document.createElement("button"); + btn.className = "dash-btn " + (action.className || "dash-btn-ghost"); + btn.textContent = action.label; + btn.addEventListener("click", function () { + var result = action.onClick ? action.onClick(modal) : null; + Promise.resolve(result).then(function (shouldClose) { + if (shouldClose !== false) close(); + }); + }); + actions.appendChild(btn); + }); + + modal.appendChild(title); + modal.appendChild(body); + modal.appendChild(actions); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + var firstInput = modal.querySelector("input, textarea, select, button"); + if (firstInput) setTimeout(function () { firstInput.focus(); }, 50); + + function close() { + if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); + document.removeEventListener("keydown", onKey); + } + function onKey(e) { + if (e.key === "Escape") { e.preventDefault(); close(); } + } + document.addEventListener("keydown", onKey); + backdrop.addEventListener("click", function (e) { + if (e.target === backdrop) close(); + }); + + return { modal: modal, close: close }; + } + + function registerRoute(name, module) { + routes[name] = module; + } + + function registerDirtyChecker(fn) { + dirtyCheckers.push(fn); + } + + function anyDirty() { + for (var i = 0; i < dirtyCheckers.length; i++) { + if (dirtyCheckers[i]()) return true; + } + return false; + } + + function parseHash() { + var hash = window.location.hash || "#/skills"; + var clean = hash.replace(/^#\/?/, ""); + var parts = clean.split("/"); + return { + route: parts[0] || "skills", + arg: parts.length > 1 ? decodeURIComponent(parts.slice(1).join("/")) : null, + }; + } + + function setActiveSidebar(name) { + qsa(".dash-sidebar-item").forEach(function (item) { + if (item.getAttribute("data-route") === name) { + item.setAttribute("aria-current", "page"); + } else { + item.removeAttribute("aria-current"); + } + }); + } + + function setBreadcrumb(label) { + var sep = qs("#crumb-sep-2"); + var current = qs("#crumb-current"); + if (label) { + if (sep) sep.hidden = false; + if (current) { + current.hidden = false; + current.textContent = label; + } + } else { + if (sep) sep.hidden = true; + if (current) { + current.hidden = true; + current.textContent = ""; + } + } + } + + function renderSoon(name) { + var container = qs("#route-soon"); + if (!container) return; + container.setAttribute("data-active", "true"); + var labels = { + sessions: { + eyebrow: "PR2", + title: "Sessions", + body: "A live view of every session the agent has had, with channels, costs, turn counts, and outcomes. Click through for full transcripts and the memories consolidated from each run.", + }, + cost: { + eyebrow: "PR2", + title: "Cost", + body: "Daily and weekly cost breakdowns with model-level detail. Charts across time so you can see where the agent's budget actually goes, and alerts when anything drifts out of its baseline.", + }, + scheduler: { + eyebrow: "PR3", + title: "Scheduler", + body: "Every cron and one-shot job the agent has created, with next-run times, recent outcomes, and the ability to edit or pause a schedule without asking the agent to do it for you.", + }, + evolution: { + eyebrow: "PR3", + title: "Evolution timeline", + body: "The 6-step self-evolution pipeline rendered as a timeline: reflections, judges, validated changes, version bumps, and rollback points. You see exactly how the agent is changing itself over time.", + }, + memory: { + eyebrow: "PR4", + title: "Memory explorer", + body: "A read view over every episode, fact, and procedure the agent has consolidated. Search, filter by decay, inspect provenance, and watch memories get reinforced as they get reused.", + }, + settings: { + eyebrow: "PR3", + title: "Settings", + body: "A curated form over the agent's Claude Code settings: permissions, MCP servers, hooks, and the knobs that actually change how it thinks. Raw JSON escape hatch for the power users.", + }, + }; + var meta = labels[name] || { eyebrow: "Soon", title: name, body: "Coming in a later PR." }; + container.innerHTML = ( + '
' + + '

' + esc(meta.eyebrow) + ' · Coming soon

' + + '

' + esc(meta.title) + '

' + + '

' + esc(meta.body) + '

' + + 'Back to skills' + + '
' + ); + setBreadcrumb(meta.title); + } + + function deactivateAllRoutes() { + qsa(".dash-route").forEach(function (el) { + el.removeAttribute("data-active"); + el.hidden = true; + }); + } + + function navigate(hash) { + if (anyDirty()) { + var go = window.confirm("You have unsaved changes. Leave without saving?"); + if (!go) { + window.history.replaceState(null, "", activeRoute || "#/skills"); + return; + } + } + window.location.hash = hash; + } + + function onHashChange() { + var parsed = parseHash(); + var name = parsed.route; + deactivateAllRoutes(); + + var liveRoutes = ["skills", "memory-files"]; + var comingSoon = ["sessions", "cost", "scheduler", "evolution", "memory", "settings"]; + + if (liveRoutes.indexOf(name) >= 0 && routes[name]) { + var containerId = "route-" + name; + var container = qs("#" + containerId); + if (container) { + container.hidden = false; + container.setAttribute("data-active", "true"); + } + setActiveSidebar(name); + routes[name].mount(container, parsed.arg, { + esc: esc, + api: api, + toast: toast, + openModal: openModal, + navigate: navigate, + setBreadcrumb: setBreadcrumb, + registerDirtyChecker: registerDirtyChecker, + }); + activeRoute = window.location.hash || ("#/" + name); + } else if (comingSoon.indexOf(name) >= 0) { + var soon = qs("#route-soon"); + if (soon) soon.hidden = false; + setActiveSidebar(name); + renderSoon(name); + activeRoute = window.location.hash || ("#/" + name); + } else { + window.location.hash = "#/skills"; + } + } + + function initThemeToggle() { + var toggle = document.getElementById("theme-toggle"); + if (!toggle) return; + var sun = document.getElementById("icon-sun"); + var moon = document.getElementById("icon-moon"); + function update() { + var theme = document.documentElement.getAttribute("data-theme"); + var isDark = theme === "phantom-dark"; + if (sun) sun.style.display = isDark ? "inline" : "none"; + if (moon) moon.style.display = isDark ? "none" : "inline"; + } + update(); + toggle.addEventListener("click", function () { + var current = document.documentElement.getAttribute("data-theme"); + var next = current === "phantom-dark" ? "phantom-light" : "phantom-dark"; + document.documentElement.setAttribute("data-theme", next); + localStorage.setItem("phantom-theme", next); + update(); + }); + } + + function setNavDate() { + var el = document.getElementById("nav-date"); + if (el) el.textContent = new Date().toISOString().split("T")[0]; + } + + function init() { + setNavDate(); + initThemeToggle(); + + window.addEventListener("beforeunload", function (e) { + if (anyDirty()) { + e.preventDefault(); + e.returnValue = ""; + return ""; + } + }); + + // Intercept sidebar clicks on Coming Soon items so their hash still updates + qsa(".dash-sidebar-item").forEach(function (item) { + item.addEventListener("click", function (e) { + var target = item.getAttribute("href"); + if (!target) return; + e.preventDefault(); + navigate(target); + }); + }); + + window.addEventListener("hashchange", onHashChange); + if (!window.location.hash) { + window.location.hash = "#/skills"; + } else { + onHashChange(); + } + } + + window.PhantomDashboard = { + init: init, + registerRoute: registerRoute, + toast: toast, + api: api, + esc: esc, + openModal: openModal, + navigate: navigate, + }; +})(); diff --git a/public/dashboard/index.html b/public/dashboard/index.html new file mode 100644 index 0000000..7024027 --- /dev/null +++ b/public/dashboard/index.html @@ -0,0 +1,104 @@ + + + + + +Dashboard + + + + + + + + + + + + +
+ + + +
+ + + +
+ +
+ +
+ + + + + + + diff --git a/public/dashboard/memory-files.js b/public/dashboard/memory-files.js new file mode 100644 index 0000000..7bff2f9 --- /dev/null +++ b/public/dashboard/memory-files.js @@ -0,0 +1,374 @@ +// Memory files tab: list, create, edit, delete arbitrary .md files under +// /home/phantom/.claude/ (excluding skills/, plugins/, agents/, settings.json). + +(function () { + var state = { + files: [], + selectedPath: null, + currentFile: null, + lastLoadedContent: "", + search: "", + initialized: false, + }; + var ctx = null; + var root = null; + + function esc(s) { return ctx.esc(s); } + + function isDirty() { + if (!state.currentFile) return false; + var el = document.getElementById("memfile-body"); + if (!el) return false; + return el.value !== state.lastLoadedContent; + } + + function filteredFiles() { + var q = (state.search || "").trim().toLowerCase(); + if (!q) return state.files; + return state.files.filter(function (f) { return f.path.toLowerCase().indexOf(q) >= 0; }); + } + + function specialDescription(topLevel, path) { + if (path === "CLAUDE.md") return "Your agent's top-level memory. Loaded at the start of every session."; + if (topLevel === "rules") return "Rule file. Applies conditionally if frontmatter defines paths."; + if (topLevel === "memory") return "Free-form memory note."; + return "Markdown memory file."; + } + + function renderHeader() { + return ( + '
' + + '

Memory files

' + + '

Memory files

' + + '

Persistent markdown under /home/phantom/.claude/. CLAUDE.md is the top-level memory your agent loads every session. Rules, notes, and free-form markdown live here alongside it.

' + + '
' + + '' + + '
' + + '
' + ); + } + + function renderListCard(file) { + var isSelected = state.selectedPath === file.path ? ' aria-current="page"' : ""; + var label = file.path === "CLAUDE.md" ? "top-level" : file.top_level; + var sizeKb = file.size ? (file.size / 1024).toFixed(1) + " KB" : ""; + return ( + '' + + '
' + + '

' + esc(file.path) + '

' + + '' + esc(label) + '' + + '
' + + '

' + esc(specialDescription(file.top_level, file.path)) + '

' + + '
' + sizeKb + '
' + + '
' + ); + } + + function renderListColumn() { + var list = filteredFiles(); + var parts = []; + parts.push(''); + + if (state.files.length === 0) { + parts.push( + '
' + + '' + + '

No memory files yet

' + + '

Create a CLAUDE.md for top-level memory, or drop any markdown under memory/ or rules/.

' + + '' + + '
' + ); + return ''; + } + + // Group by top-level directory + var byGroup = {}; + list.forEach(function (f) { + var key = f.path === "CLAUDE.md" ? "top" : f.top_level; + if (!byGroup[key]) byGroup[key] = []; + byGroup[key].push(f); + }); + var order = ["top", "rules", "memory"]; + Object.keys(byGroup).forEach(function (k) { + if (order.indexOf(k) < 0) order.push(k); + }); + order.forEach(function (k) { + if (!byGroup[k]) return; + var label = k === "top" ? "Top level" : k; + parts.push('

' + esc(label) + "

"); + byGroup[k].forEach(function (f) { parts.push(renderListCard(f)); }); + }); + + if (list.length === 0) { + parts.push('

No files match "' + esc(state.search) + '".

'); + } + return ''; + } + + function renderEditor() { + if (!state.currentFile) { + return ( + '
' + + '
' + + '' + + '

Pick a memory file

' + + '

Select a file from the left to view or edit it, or create a new one from the button above.

' + + '
' + + '
' + ); + } + var f = state.currentFile; + var noteHtml = f.path === "CLAUDE.md" + ? '
This is your agent\'s top-level memory. It loads at the start of every conversation.
' + : ""; + + return ( + '
' + + '
' + + '
' + + '

' + esc(f.path) + '

' + + '

/home/phantom/.claude/' + esc(f.path) + '

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + + noteHtml + + + '
' + + '
' + + '' + + '' + + '
Markdown. ' + (f.size / 1024).toFixed(1) + ' KB. Saved atomically.
' + + '
' + + '
' + + '
' + ); + } + + function wireSearch() { + var search = document.getElementById("memfile-search"); + if (!search) return; + search.addEventListener("input", function () { + state.search = search.value || ""; + var col = document.getElementById("memfile-list-col"); + if (!col) return; + var wrapper = document.createElement("div"); + wrapper.innerHTML = renderListColumn(); + col.innerHTML = wrapper.firstChild.innerHTML; + wireSearch(); + wireListClicks(); + }); + } + + function wireListClicks() { + var links = document.querySelectorAll(".dash-list-card"); + Array.prototype.forEach.call(links, function (a) { + a.addEventListener("click", function (e) { + var href = a.getAttribute("href"); + if (!href) return; + e.preventDefault(); + ctx.navigate(href); + }); + }); + } + + function updateDirtyState() { + var dot = document.getElementById("memfile-dirty-dot"); + var save = document.getElementById("memfile-save-btn"); + var dirty = isDirty(); + if (dot) dot.setAttribute("data-dirty", dirty ? "true" : "false"); + if (save) save.disabled = !dirty; + } + + function render(rewireList) { + if (rewireList === undefined) rewireList = true; + var listHtml = renderListColumn(); + var editorHtml = renderEditor(); + root.innerHTML = ( + renderHeader() + + '
' + + '
' + listHtml + '
' + + '
' + editorHtml + '
' + + '
' + ); + if (rewireList) { + wireSearch(); + wireListClicks(); + var newBtn = document.getElementById("memfile-new-btn"); + if (newBtn) newBtn.addEventListener("click", openNewModal); + var newBtnEmpty = document.getElementById("memfile-new-btn-empty"); + if (newBtnEmpty) newBtnEmpty.addEventListener("click", openNewModal); + } + var bodyEl = document.getElementById("memfile-body"); + if (bodyEl) { + bodyEl.addEventListener("input", updateDirtyState); + bodyEl.addEventListener("keydown", function (e) { + if (e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + var start = bodyEl.selectionStart; + var end = bodyEl.selectionEnd; + bodyEl.value = bodyEl.value.substring(0, start) + " " + bodyEl.value.substring(end); + bodyEl.selectionStart = bodyEl.selectionEnd = start + 2; + updateDirtyState(); + } else if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + saveFile(); + } + }); + } + var saveBtn = document.getElementById("memfile-save-btn"); + if (saveBtn) saveBtn.addEventListener("click", saveFile); + var deleteBtn = document.getElementById("memfile-delete-btn"); + if (deleteBtn) deleteBtn.addEventListener("click", confirmDelete); + + if (state.currentFile) { + updateDirtyState(); + ctx.setBreadcrumb(state.currentFile.path); + } else { + ctx.setBreadcrumb("Memory files"); + } + } + + function openNewModal() { + var body = document.createElement("div"); + body.innerHTML = ( + '

Any markdown path under .claude/. Subdirectories are created automatically.

' + + '
' + + '
' + + '' + + '' + + '
Must end in .md. Examples: CLAUDE.md, rules/no-friday-deploys.md, memory/people/cheema.md.
' + + '
' + + '
' + ); + ctx.openModal({ + title: "New memory file", + body: body, + actions: [ + { label: "Cancel", className: "dash-btn-ghost", onClick: function () {} }, + { + label: "Create", + className: "dash-btn-primary", + onClick: function () { + var path = document.getElementById("new-memfile-path").value.trim(); + if (!path.endsWith(".md")) { + ctx.toast("error", "Invalid path", "Path must end in .md"); + return false; + } + return ctx.api("POST", "/ui/api/memory-files", { path: path, content: "" }) + .then(function (res) { + ctx.toast("success", "Created", path); + return loadList().then(function () { + ctx.navigate("#/memory-files/" + encodeURIComponent(res.file.path)); + }); + }) + .catch(function (err) { + ctx.toast("error", "Create failed", err.message || String(err)); + return false; + }); + }, + }, + ], + }); + } + + function saveFile() { + if (!state.currentFile) return; + var body = document.getElementById("memfile-body").value; + var saveBtn = document.getElementById("memfile-save-btn"); + if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = "Saving"; } + var path = state.currentFile.path; + ctx.api("PUT", "/ui/api/memory-files/" + encodeURIComponent(path), { content: body }) + .then(function (res) { + state.currentFile = res.file; + state.lastLoadedContent = res.file.content; + if (saveBtn) { saveBtn.textContent = "Save"; } + updateDirtyState(); + ctx.toast("success", "Saved", "Picked up on your agent's next session."); + loadList(); + }) + .catch(function (err) { + if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = "Save"; } + ctx.toast("error", "Save failed", err.message || String(err)); + }); + } + + function confirmDelete() { + if (!state.currentFile) return; + var path = state.currentFile.path; + ctx.openModal({ + title: "Delete " + path + "?", + body: "This removes the file from /home/phantom/.claude/" + path + ". You can re-create it later.", + actions: [ + { label: "Cancel", className: "dash-btn-ghost", onClick: function () {} }, + { + label: "Delete", + className: "dash-btn-danger", + onClick: function () { + return ctx.api("DELETE", "/ui/api/memory-files/" + encodeURIComponent(path)) + .then(function () { + state.currentFile = null; + state.lastLoadedContent = ""; + state.selectedPath = null; + ctx.toast("success", "Deleted", path); + return loadList().then(function () { ctx.navigate("#/memory-files"); }); + }) + .catch(function (err) { + ctx.toast("error", "Delete failed", err.message || String(err)); + return false; + }); + }, + }, + ], + }); + } + + function loadList() { + return ctx.api("GET", "/ui/api/memory-files").then(function (res) { + state.files = res.files || []; + render(true); + }).catch(function (err) { + ctx.toast("error", "Failed to load memory files", err.message || String(err)); + }); + } + + function loadDetail(path) { + return ctx.api("GET", "/ui/api/memory-files/" + encodeURIComponent(path)).then(function (res) { + state.currentFile = res.file; + state.lastLoadedContent = res.file.content; + state.selectedPath = path; + render(true); + }).catch(function (err) { + if (err.status === 404) { + ctx.toast("error", "Memory file not found", path); + ctx.navigate("#/memory-files"); + return; + } + ctx.toast("error", "Failed to load memory file", err.message || String(err)); + }); + } + + function mount(container, arg, dashCtx) { + ctx = dashCtx; + root = container; + ctx.setBreadcrumb("Memory files"); + if (!state.initialized) { + ctx.registerDirtyChecker(isDirty); + state.initialized = true; + } + return loadList().then(function () { + if (arg) return loadDetail(arg); + if (state.files.length > 0 && !state.selectedPath) { + return loadDetail(state.files[0].path); + } + }); + } + + window.PhantomDashboard.registerRoute("memory-files", { mount: mount }); +})(); diff --git a/public/dashboard/skills.js b/public/dashboard/skills.js new file mode 100644 index 0000000..f42c503 --- /dev/null +++ b/public/dashboard/skills.js @@ -0,0 +1,609 @@ +// Skills tab: list, search, editor, save, create, delete. +// +// Module contract: registers with PhantomDashboard via registerRoute('skills', module). +// mount(container, arg, ctx) is called on hash change. ctx has esc, api, toast, +// openModal, navigate, setBreadcrumb, registerDirtyChecker. + +(function () { + var state = { + skills: [], + errors: [], + selectedName: null, + currentDetail: null, + lastLoadedBody: "", + lastLoadedFrontmatter: null, + search: "", + initialized: false, + }; + var ctx = null; + var root = null; + + function esc(s) { return ctx.esc(s); } + + function isDirty() { + if (!state.currentDetail) return false; + var currentBody = (document.getElementById("skill-body") || {}).value; + if (currentBody == null) return false; + var fm = collectFrontmatter(); + if (!fm.ok) return false; + return currentBody !== state.lastLoadedBody || + JSON.stringify(fm.value) !== JSON.stringify(state.lastLoadedFrontmatter); + } + + function collectFrontmatter() { + var nameEl = document.getElementById("skill-field-name"); + var descEl = document.getElementById("skill-field-description"); + var whenEl = document.getElementById("skill-field-when"); + var argHintEl = document.getElementById("skill-field-arghint"); + var contextEl = document.getElementById("skill-field-context"); + var disableEl = document.getElementById("skill-field-disable"); + var toolsEl = document.getElementById("skill-field-tools"); + if (!nameEl) return { ok: false }; + var name = nameEl.value.trim(); + var fm = { + name: name, + description: (descEl.value || "").trim(), + when_to_use: (whenEl.value || "").trim(), + }; + var tools = toolsEl ? JSON.parse(toolsEl.getAttribute("data-tools") || "[]") : []; + if (tools.length > 0) fm["allowed-tools"] = tools; + var argHint = (argHintEl.value || "").trim(); + if (argHint) fm["argument-hint"] = argHint; + var contextValue = contextEl.value || ""; + if (contextValue) fm.context = contextValue; + if (disableEl && disableEl.checked) fm["disable-model-invocation"] = true; + return { ok: true, value: fm }; + } + + function renderHeader() { + return ( + '
' + + '

Skills

' + + '

Skills

' + + '

Markdown files the agent reads at the start of every message. Write instructions, procedures, or triggers. Saved skills are live on the next turn.

' + + '
' + + '' + + 'Design vocabulary' + + '
' + + '
' + ); + } + + function renderListCard(skill) { + var source = skill.source || "user"; + var label = source === "built-in" ? "built in" : source === "agent" ? "agent" : "you"; + var sourceClass = source === "built-in" ? "dash-source-chip-built-in" : source === "agent" ? "dash-source-chip-agent" : "dash-source-chip-user"; + var isSelected = state.selectedName === skill.name ? ' aria-current="page"' : ""; + var disablePill = skill.disable_model_invocation ? 'user only' : ""; + return ( + '' + + '
' + + '

' + esc(skill.name) + '

' + + '' + label + '' + + '
' + + '

' + esc(skill.description || "") + '

' + + '
' + + disablePill + + '' + (skill.size ? (skill.size + " B") : "") + '' + + '
' + + '
' + ); + } + + function filteredSkills() { + var q = (state.search || "").trim().toLowerCase(); + if (!q) return state.skills; + return state.skills.filter(function (s) { + return (s.name || "").toLowerCase().indexOf(q) >= 0 || + (s.description || "").toLowerCase().indexOf(q) >= 0 || + (s.when_to_use || "").toLowerCase().indexOf(q) >= 0; + }); + } + + function renderListColumn() { + var list = filteredSkills(); + var built = list.filter(function (s) { return s.source === "built-in"; }); + var user = list.filter(function (s) { return s.source !== "built-in"; }); + + var parts = []; + parts.push(''); + + if (state.skills.length === 0) { + parts.push(renderEmptyList()); + } else { + if (built.length > 0) { + parts.push('

Built in

'); + built.forEach(function (s) { parts.push(renderListCard(s)); }); + } + if (user.length > 0) { + parts.push('

Yours

'); + user.forEach(function (s) { parts.push(renderListCard(s)); }); + } + if (list.length === 0) { + parts.push('

No skills match "' + esc(state.search) + '".

'); + } + } + return ''; + } + + function renderEmptyList() { + return ( + '
' + + '' + + '

No skills yet

' + + '

Start from a built-in like mirror, or write one from scratch. The agent picks up new skills on its next message.

' + + '' + + '
' + ); + } + + function renderEditor() { + if (!state.currentDetail) { + return ( + '
' + + '
' + + '' + + '

Pick a skill

' + + '

Select a skill from the left to view or edit it. Or create a new one from the button above.

' + + '
' + + '
' + ); + } + + var d = state.currentDetail; + var fm = d.frontmatter; + var tools = fm["allowed-tools"] || []; + var allowedToolsJson = JSON.stringify(tools).replace(/'/g, "'"); + + return ( + '
' + + '
' + + '
' + + '

' + esc(d.name) + '

' + + '

' + esc(d.path) + '

' + + '
' + + '
' + + (d.source === "built-in" ? "" : '') + + '' + + '
' + + '
' + + + '
' + + '
' + + renderField("name", "Name", "skill-field-name", '', "Lowercase letters, digits, and hyphens. Matches the folder name under .claude/skills/.") + + renderField("context", "Context", "skill-field-context", renderContextSelect(fm.context), "inline runs in the current conversation. fork spawns a subagent with its own turn limit.") + + '
' + + + renderField("description", "Description", "skill-field-description", '', "One sentence. Appears in the list and in the model's system reminder.") + + + renderField("when_to_use", "When to use", "skill-field-when", '', "Trigger phrases and conditions. The model reads this to decide when to invoke the skill.") + + + renderField("allowed-tools", "Allowed tools", "skill-field-tools", renderToolsChips(tools, allowedToolsJson), "Fully qualified tool names. Leave empty for full access.") + + + '
' + + renderField("argument-hint", "Argument hint", "skill-field-arghint", '', "Optional. Shown after the skill name when the user types /skill.") + + renderDisableField(fm["disable-model-invocation"]) + + '
' + + + renderField("body", "SKILL.md body", "skill-body", '', "Markdown. Goal, Steps (each with a success criterion), and Rules. Saved atomically.") + + + '
' + + '
' + + '
' + ); + } + + function renderContextSelect(current) { + var options = [ + { value: "", label: "(default)" }, + { value: "inline", label: "inline" }, + { value: "fork", label: "fork (subagent)" }, + ]; + var html = '"; + return html; + } + + function renderDisableField(current) { + return ( + '
' + + 'User-invoke only ?' + + '' + + '
' + ); + } + + function renderField(id, label, inputId, control, hint) { + var tip = hint ? ' ?' : ""; + return ( + '
' + + '' + + control + + '
' + ); + } + + function renderToolsChips(tools, allowedToolsJson) { + var chips = tools.map(function (t, i) { + return '' + esc(t) + ''; + }).join(""); + return ( + '
' + + chips + + '' + + '' + + '' + + '
' + ); + } + + function renderLint(hints) { + var lint = document.getElementById("skill-lint"); + if (!lint) return; + lint.innerHTML = hints.map(function (h) { + return '
' + esc(h.message) + '
'; + }).join(""); + } + + function wireSearch() { + var search = document.getElementById("skill-search"); + if (!search) return; + search.addEventListener("input", function () { + state.search = search.value || ""; + var listCol = document.getElementById("skills-list-col"); + if (!listCol) return; + var newList = renderListColumn(); + var wrapper = document.createElement("div"); + wrapper.innerHTML = newList; + // Replace children inside listCol with the new aside content + listCol.innerHTML = wrapper.firstChild.innerHTML; + wireSearch(); + wireListClicks(); + }); + } + + function wireListClicks() { + // Intercept link clicks inside list column so hash changes are processed + // through the dashboard navigate helper (respects unsaved changes). + var links = document.querySelectorAll(".dash-list-card"); + Array.prototype.forEach.call(links, function (a) { + a.addEventListener("click", function (e) { + var href = a.getAttribute("href"); + if (!href) return; + e.preventDefault(); + ctx.navigate(href); + }); + }); + } + + function wireToolChips() { + var container = document.getElementById("skill-field-tools"); + var input = document.getElementById("skill-field-tools-input"); + if (!container || !input) return; + function save(tools) { + container.setAttribute("data-tools", JSON.stringify(tools)); + render(false); + } + function tools() { return JSON.parse(container.getAttribute("data-tools") || "[]"); } + input.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + var value = input.value.trim().replace(/,$/, ""); + if (!value) return; + var existing = tools(); + if (existing.indexOf(value) < 0) existing.push(value); + save(existing); + } else if (e.key === "Backspace" && input.value === "") { + var existing2 = tools(); + existing2.pop(); + save(existing2); + } + }); + input.addEventListener("blur", function () { + var value = input.value.trim(); + if (value) { + var existing = tools(); + if (existing.indexOf(value) < 0) existing.push(value); + input.value = ""; + save(existing); + } + }); + container.querySelectorAll("[data-tool-remove]").forEach(function (btn) { + btn.addEventListener("click", function () { + var idx = parseInt(btn.getAttribute("data-tool-remove"), 10); + var existing = tools(); + existing.splice(idx, 1); + save(existing); + }); + }); + } + + function render(rewireList) { + if (rewireList === undefined) rewireList = true; + var listHtml = renderListColumn(); + var editorHtml = renderEditor(); + + root.innerHTML = ( + renderHeader() + + '
' + + '
' + listHtml + '
' + + '
' + editorHtml + '
' + + '
' + ); + + if (rewireList) { + wireSearch(); + wireListClicks(); + var newBtn = document.getElementById("skill-new-btn"); + if (newBtn) newBtn.addEventListener("click", openNewSkillModal); + var newBtnEmpty = document.getElementById("skill-new-btn-empty"); + if (newBtnEmpty) newBtnEmpty.addEventListener("click", openNewSkillModal); + } + + wireToolChips(); + + var bodyEl = document.getElementById("skill-body"); + var nameEl = document.getElementById("skill-field-name"); + var descEl = document.getElementById("skill-field-description"); + var whenEl = document.getElementById("skill-field-when"); + var argHintEl = document.getElementById("skill-field-arghint"); + var contextEl = document.getElementById("skill-field-context"); + var disableEl = document.getElementById("skill-field-disable"); + [bodyEl, descEl, whenEl, argHintEl].forEach(function (el) { + if (el) el.addEventListener("input", updateDirtyState); + }); + if (contextEl) contextEl.addEventListener("change", updateDirtyState); + if (disableEl) disableEl.addEventListener("change", updateDirtyState); + + if (bodyEl) { + bodyEl.addEventListener("keydown", function (e) { + if (e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + var start = bodyEl.selectionStart; + var end = bodyEl.selectionEnd; + bodyEl.value = bodyEl.value.substring(0, start) + " " + bodyEl.value.substring(end); + bodyEl.selectionStart = bodyEl.selectionEnd = start + 2; + updateDirtyState(); + } else if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + saveSkill(); + } + }); + } + if (nameEl) nameEl.addEventListener("input", updateDirtyState); + + var saveBtn = document.getElementById("skill-save-btn"); + if (saveBtn) saveBtn.addEventListener("click", saveSkill); + var deleteBtn = document.getElementById("skill-delete-btn"); + if (deleteBtn) deleteBtn.addEventListener("click", confirmDelete); + + if (state.currentDetail) { + renderLint(state.currentDetail.lint || []); + updateDirtyState(); + ctx.setBreadcrumb(state.currentDetail.name); + } else { + ctx.setBreadcrumb("Skills"); + } + } + + function updateDirtyState() { + var dot = document.getElementById("skill-dirty-dot"); + var save = document.getElementById("skill-save-btn"); + var dirty = isDirty(); + if (dot) dot.setAttribute("data-dirty", dirty ? "true" : "false"); + if (save) save.disabled = !dirty; + } + + function openNewSkillModal() { + var body = document.createElement("div"); + body.innerHTML = ( + '

Pick a starting point. You can rename and edit everything after.

' + + '
' + + '
' + + '' + + '' + + '
Lowercase letters, digits, and hyphens.
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + ); + var modal = ctx.openModal({ + title: "New skill", + body: body, + actions: [ + { label: "Cancel", className: "dash-btn-ghost", onClick: function () {} }, + { + label: "Create", + className: "dash-btn-primary", + onClick: function () { + var name = document.getElementById("new-skill-name").value.trim(); + if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) { + ctx.toast("error", "Invalid name", "Use lowercase letters, digits, and hyphens. Start with a letter."); + return false; + } + var template = document.getElementById("new-skill-template").value; + return createNewSkill(name, template).then(function (ok) { + return ok !== false; + }); + }, + }, + ], + }); + return modal; + } + + function templateSkill(name, template) { + if (template === "mirror") { + var mirror = state.skills.filter(function (s) { return s.name === "mirror"; })[0]; + if (mirror) { + return ctx.api("GET", "/ui/api/skills/mirror").then(function (res) { + var fm = Object.assign({}, res.skill.frontmatter, { name: name, description: name + " (copied from mirror)" }); + return { frontmatter: fm, body: res.skill.body }; + }); + } + } + if (template === "thread") { + return ctx.api("GET", "/ui/api/skills/thread").then(function (res) { + var fm = Object.assign({}, res.skill.frontmatter, { name: name, description: name + " (copied from thread)" }); + return { frontmatter: fm, body: res.skill.body }; + }); + } + return Promise.resolve({ + frontmatter: { + name: name, + description: "A new skill", + when_to_use: "Describe when the agent should invoke this skill. Include specific trigger phrases.", + }, + body: "# " + name + "\n\n## Goal\n\nDescribe what this skill accomplishes.\n\n## Steps\n\n### 1. Step name\n\nWhat the agent does.\n\n**Success criteria**: How the agent knows the step is complete.\n\n## Rules\n\n- Things the agent should never do.\n", + }); + } + + function createNewSkill(name, template) { + return templateSkill(name, template).then(function (seed) { + return ctx.api("POST", "/ui/api/skills", { frontmatter: seed.frontmatter, body: seed.body }).then(function (res) { + ctx.toast("success", "Skill created", "Your agent picks this up on its next message."); + return loadList().then(function () { + ctx.navigate("#/skills/" + encodeURIComponent(res.skill.name)); + }); + }); + }).catch(function (err) { + ctx.toast("error", "Failed to create skill", err.message || String(err)); + return false; + }); + } + + function saveSkill() { + if (!state.currentDetail) return; + var body = document.getElementById("skill-body").value; + var fm = collectFrontmatter(); + if (!fm.ok) return; + var saveBtn = document.getElementById("skill-save-btn"); + if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = "Saving"; } + var name = state.currentDetail.name; + ctx.api("PUT", "/ui/api/skills/" + encodeURIComponent(name), { frontmatter: fm.value, body: body }) + .then(function (res) { + state.currentDetail = res.skill; + state.lastLoadedBody = res.skill.body; + state.lastLoadedFrontmatter = res.skill.frontmatter; + renderLint(res.skill.lint || []); + if (saveBtn) { saveBtn.textContent = "Save"; } + updateDirtyState(); + ctx.toast("success", "Saved", "Your agent picks this up on its next message."); + // Refresh list so mtime ordering updates + loadList(); + }) + .catch(function (err) { + if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = "Save"; } + ctx.toast("error", "Save failed", err.message || String(err)); + }); + } + + function confirmDelete() { + if (!state.currentDetail) return; + var name = state.currentDetail.name; + ctx.openModal({ + title: "Delete " + name + "?", + body: "This removes the SKILL.md file from /home/phantom/.claude/skills/" + name + "/. You can re-create it later.", + actions: [ + { label: "Cancel", className: "dash-btn-ghost", onClick: function () {} }, + { + label: "Delete", + className: "dash-btn-danger", + onClick: function () { + return ctx.api("DELETE", "/ui/api/skills/" + encodeURIComponent(name)) + .then(function () { + state.currentDetail = null; + state.lastLoadedBody = ""; + state.lastLoadedFrontmatter = null; + state.selectedName = null; + ctx.toast("success", "Deleted", name + " removed."); + return loadList().then(function () { + ctx.navigate("#/skills"); + }); + }) + .catch(function (err) { + ctx.toast("error", "Delete failed", err.message || String(err)); + return false; + }); + }, + }, + ], + }); + } + + function loadList() { + return ctx.api("GET", "/ui/api/skills").then(function (res) { + state.skills = res.skills || []; + state.errors = res.errors || []; + render(true); + if (state.errors.length > 0) { + state.errors.forEach(function (e) { + ctx.toast("error", "Skill parse error: " + e.name, e.error); + }); + } + }).catch(function (err) { + ctx.toast("error", "Failed to load skills", err.message || String(err)); + }); + } + + function loadDetail(name) { + return ctx.api("GET", "/ui/api/skills/" + encodeURIComponent(name)).then(function (res) { + state.currentDetail = res.skill; + state.lastLoadedBody = res.skill.body; + state.lastLoadedFrontmatter = res.skill.frontmatter; + state.selectedName = name; + render(true); + }).catch(function (err) { + if (err.status === 404) { + ctx.toast("error", "Skill not found", name); + ctx.navigate("#/skills"); + return; + } + ctx.toast("error", "Failed to load skill", err.message || String(err)); + }); + } + + function mount(container, arg, dashCtx) { + ctx = dashCtx; + root = container; + ctx.setBreadcrumb("Skills"); + if (!state.initialized) { + ctx.registerDirtyChecker(isDirty); + state.initialized = true; + } + return loadList().then(function () { + if (arg) { + return loadDetail(arg); + } + // Default: if any skills exist, open the first + if (state.skills.length > 0 && !state.selectedName) { + var first = state.skills[0].name; + return loadDetail(first); + } + }); + } + + window.PhantomDashboard.registerRoute("skills", { mount: mount }); +})(); diff --git a/public/index.html b/public/index.html index 2cfd9fd..6580a6c 100644 --- a/public/index.html +++ b/public/index.html @@ -160,11 +160,11 @@

Quick links

- - + + From 96a018f9f73bf59fd07fd78b0800b66b3db99a78 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Mon, 13 Apr 2026 16:41:05 -0700 Subject: [PATCH 5/6] feat: six built-in reflective skills seeded on container first boot Ship a small catalog of genuinely novel reflective skills that make a fresh phantom feel alive from message one. Each is a full SKILL.md with a strict YAML frontmatter, a Goal, numbered Steps with per-step success criteria, and Rules. - mirror: weekly self-audit playback. Pulls the last 7 days of memory, anchors observations to specific episodes, renders three sections (what I noticed, what I am unsure about, one question for you). - thread: evolution of thinking on a topic. Takes a topic, clusters mentions chronologically, identifies turning points, renders a short narrative with callouts. - echo: prior-answer surfacer. Before deriving a new answer to a substantive question, checks memory for semantically similar prior questions and surfaces the conclusion if the match is strong. - overheard: promises audit. Scans the last 14 days for commitment phrases, checks for follow-through, surfaces the top 3-5 open promises with draft followup offers. - ritual: latent patterns to scheduled jobs. Finds recurring behaviors in 60 days of sessions, verifies them against memory, proposes formalization as phantom_schedule jobs. - show-my-tools: utility skill that lists current skills, memory files, and the dashboard URLs. The discovery path for everything the operator can edit. All five reflective skills list the new in-process MCP tools (mcp__phantom-reflective__phantom_memory_search, mcp__phantom-reflective__phantom_list_sessions, mcp__phantom-scheduler__phantom_schedule) in their allowed-tools field so they can actually fire. The skills ship in /app/skills-builtin/ inside the image. The docker entrypoint copies each directory to /home/phantom/.claude/skills/ on first boot only. Existing directories are preserved, so operator edits survive container rebuilds. Dockerfile copies the skills-builtin tree in both the builder and runtime stages. --- Dockerfile | 2 + scripts/docker-entrypoint.sh | 16 +++++ skills-builtin/echo/SKILL.md | 75 +++++++++++++++++++ skills-builtin/mirror/SKILL.md | 66 +++++++++++++++++ skills-builtin/overheard/SKILL.md | 84 ++++++++++++++++++++++ skills-builtin/ritual/SKILL.md | 100 ++++++++++++++++++++++++++ skills-builtin/show-my-tools/SKILL.md | 63 ++++++++++++++++ skills-builtin/thread/SKILL.md | 82 +++++++++++++++++++++ 8 files changed, 488 insertions(+) create mode 100644 skills-builtin/echo/SKILL.md create mode 100644 skills-builtin/mirror/SKILL.md create mode 100644 skills-builtin/overheard/SKILL.md create mode 100644 skills-builtin/ritual/SKILL.md create mode 100644 skills-builtin/show-my-tools/SKILL.md create mode 100644 skills-builtin/thread/SKILL.md diff --git a/Dockerfile b/Dockerfile index e3b0e5e..35d190a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ COPY config/ config/ COPY phantom-config/ phantom-config/ COPY scripts/ scripts/ COPY public/ public/ +COPY skills-builtin/ skills-builtin/ COPY tsconfig.json biome.json ./ # --- Runtime Stage --- @@ -72,6 +73,7 @@ COPY --from=builder /app/src ./src COPY --from=builder /app/config ./config COPY --from=builder /app/scripts ./scripts COPY --from=builder /app/public ./public +COPY --from=builder /app/skills-builtin ./skills-builtin COPY --from=builder /app/package.json ./ COPY --from=builder /app/tsconfig.json ./ diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index bf60400..3056c22 100755 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -9,6 +9,22 @@ if [ ! -f /app/phantom-config/constitution.md ]; then cp -r /app/phantom-config-defaults/* /app/phantom-config/ 2>/dev/null || true fi +# Seed built-in skills into the user-scope .claude/skills volume on first run. +# Existing skills (user edits) are preserved: we only copy directories that are +# missing. The source lives at /app/skills-builtin/ and is a pristine copy from +# the repo; the target is on the phantom_claude Docker volume. +if [ -d /app/skills-builtin ]; then + mkdir -p /home/phantom/.claude/skills + for skill_dir in /app/skills-builtin/*/; do + name=$(basename "$skill_dir") + target="/home/phantom/.claude/skills/$name" + if [ ! -d "$target" ]; then + echo "[phantom] Seeding built-in skill: $name" + cp -r "$skill_dir" "$target" + fi + done +fi + # Determine service URLs from environment (with Docker Compose defaults) QDRANT_URL="${QDRANT_URL:-http://qdrant:6333}" OLLAMA_URL="${OLLAMA_URL:-http://ollama:11434}" diff --git a/skills-builtin/echo/SKILL.md b/skills-builtin/echo/SKILL.md new file mode 100644 index 0000000..e2fcd66 --- /dev/null +++ b/skills-builtin/echo/SKILL.md @@ -0,0 +1,75 @@ +--- +name: echo +description: Before answering a substantive question, quietly check whether the user has already resolved this question in the past. +when_to_use: Use when the user asks a substantive question that might have been asked and resolved before. Substantive questions are things like "how should I do X", "what is the right way to Y", "which approach is better", "what did we decide about Z". Do NOT fire on greetings, small talk, status checks, or operational queries like "what time is it" or "are you online". Before deriving a new answer, run a memory similarity check. If a strong prior match exists, surface it inline and ask whether anything has changed. +allowed-tools: + - mcp__phantom-reflective__phantom_memory_search +context: inline +--- + +# Echo: the prior-answer surfacer + +## Goal + +Respect the user's past thinking. Before deriving a new answer to a substantive question, check whether the user already resolved this question weeks or months ago. If yes, surface the prior answer inline and ask whether anything has changed. If no, proceed to answer normally without mentioning that you looked. + +The user should feel like you remember what they already decided, not like you are doing paperwork. + +## Steps + +### 1. Classify the question + +Determine whether the question is substantive. Skip if: +- It is a greeting or acknowledgment ("hey", "thanks", "got it"). +- It is an operational query ("are you online", "what time is it", "are you working on X"). +- It is an imperative with no open decision ("send this to Anna", "delete that file"). +- It is clearly a first-time question with no prior context ("what does this error mean in this new log line"). + +Proceed if: +- The user is asking for a recommendation or opinion. +- The user is asking "what did we decide" or "what is the right way". +- The user is weighing options on something they have discussed before. +- The user is asking a question that sounds like it could have been asked before. + +**Success criteria**: you have a yes or no on whether to run the echo check. If no, do not call the search tool at all. + +### 2. Search memory for prior answers + +Call `mcp__phantom-reflective__phantom_memory_search` with `query: ""`, `memory_type: "all"`, `limit: 5`. The query should be a restatement of the semantic intent, not a literal copy of the user's words. + +**Success criteria**: you have a list of 0-5 hits with similarity scores. + +### 3. Judge the match + +Examine the top hit. It is a strong match if all of these hold: +- Similarity score is above 0.80 if the tool returns one. +- The hit is at least 3 days old. +- The hit actually addresses the same question, not just the same keywords. +- You can clearly see what the prior conclusion was. + +If the top hit is NOT a strong match, proceed to answer the question normally from scratch. Do not mention the echo check to the user. + +**Success criteria**: you have a clear yes or no on the match. + +### 4. Surface the prior answer if there is one + +If there is a strong match, respond BEFORE deriving a new answer: + +> You asked something very similar on [date] and you landed on [paraphrase of the prior conclusion]. Has anything changed since then, or is that still your view? + +Wait for the user's response. + +If the user says "no, things are different now" or explains what changed, proceed to derive a new answer informed by the new context. + +If the user says "yes, that is still my view", acknowledge and ask what they want to do with that. Sometimes they just needed the reminder. + +**Success criteria**: the user is aware of their prior thinking, and you have their explicit signal on whether to rebuild from scratch or honor the prior answer. + +## Rules + +- Never surface weak matches. A low-confidence echo is worse than no echo because it erodes the user's trust in your memory. +- Never em-dash. +- Never mention that you ran the echo check if it did not fire. The user should not see the machinery when it does not apply. +- Be brief on the surface: two sentences, not four. +- If the prior conclusion has expired (for example, it is about a project that has since shipped), treat it as a weak match and proceed normally. +- Always paraphrase the prior conclusion in your own words. Do not copy-paste from memory. diff --git a/skills-builtin/mirror/SKILL.md b/skills-builtin/mirror/SKILL.md new file mode 100644 index 0000000..a130adf --- /dev/null +++ b/skills-builtin/mirror/SKILL.md @@ -0,0 +1,66 @@ +--- +name: mirror +description: Weekly self-audit playback. Surface patterns from the user's past week that they probably cannot see themselves. +when_to_use: Use when the user says "mirror", "weekly review", "show me my week", "what did I actually do this week", "reflect on last week", "how did my week go", or any similar reflective request. Also fires automatically on a Friday evening schedule if the user has enabled the mirror ritual. +allowed-tools: + - mcp__phantom-reflective__phantom_memory_search + - mcp__phantom-reflective__phantom_list_sessions + - Read +context: inline +--- + +# Mirror: the weekly self-audit + +## Goal + +Play back the last seven days to the user from memory. Not a task report. Not a highlight reel. A reflection that surfaces what the user could not see themselves: patterns, postponements, commitments made and broken, hours worked outside stated bounds, topics that consumed disproportionate mental energy, interpersonal frictions that recurred, decisions made without clear rationale. + +The goal is honest, warm observation. Never moralize. Never prescribe. Offer what you saw and let the user decide what it means. + +## Steps + +### 1. Pull the last seven days from memory + +Call `mcp__phantom-reflective__phantom_memory_search` with `days_back: 7`, `memory_type: "all"`, and a broad query like "this week" or the user's name. Return at least 20 episodes and 10 facts if available. + +**Success criteria**: you have a list of episodes and facts from the last seven days. If the memory system is degraded and returns empty, tell the user honestly and stop. + +### 2. Anchor with sessions + +Call `mcp__phantom-reflective__phantom_list_sessions` with `days_back: 7`, `limit: 50`. Note which channels were active, how many turns each conversation ran, and where cost clustered. + +**Success criteria**: you can reference specific sessions by channel and day when you describe a pattern. + +### 3. Look for patterns across the week + +Read the episodes and facts for: +- Repeated themes the user kept returning to. +- Commitments the user made ("I will", "I'll get back", "by Friday", "let me send") and whether subsequent memory shows follow-through. +- Postponements: things the user pushed off multiple days in a row. +- Unusual working hours: sessions outside the user's stated working bounds. +- Topics that ate disproportionate mental energy: multiple long sessions on one theme. +- Decisions made without stated rationale. +- Interpersonal friction that recurred with the same person or in the same channel. + +**Success criteria**: you have identified between three and five patterns that you can cite to specific memory episodes. + +### 4. Render as three sections + +Write the response in three clearly labeled sections: + +**What I noticed.** Three to five observations, each cited to memory references. Warm and direct. No moralizing. Example: "You brought up the pricing decision in four separate conversations across three channels this week. Each time you were the one who raised it. It seems to still be weighing on you." + +**What I am unsure about.** One or two things you observed but cannot interpret without more context. This is honest humility, not filler. Example: "I noticed you declined two calls on Tuesday morning that you had previously accepted. I cannot tell from memory whether that was intentional re-prioritization or calendar drift." + +**One question for you.** A single reflective prompt the user can take or leave. It should be specific to what you saw, not a generic coaching prompt. Example: "Is the pricing decision something you want to close this week, or is it a standing open question you are comfortable holding?" + +**Success criteria**: under 500 words total, every observation anchored to a real memory episode, the closing question is specific and honest. + +## Rules + +- Never fabricate patterns. If you only saw one instance of something, do not call it a pattern. +- Never moralize. "You worked late three nights" is an observation. "You should stop working late" is a prescription. Only the observation. +- Never em-dash. Use commas, periods, or regular dashes. +- Always cite at least one memory episode per observation. +- If the memory system is empty or degraded, say so clearly. Do not make things up to fill the structure. +- Stay under 500 words. Density over coverage. diff --git a/skills-builtin/overheard/SKILL.md b/skills-builtin/overheard/SKILL.md new file mode 100644 index 0000000..2e39fa1 --- /dev/null +++ b/skills-builtin/overheard/SKILL.md @@ -0,0 +1,84 @@ +--- +name: overheard +description: Find commitments the user made in the last two weeks and did not follow through on. A promises audit. +when_to_use: Use when the user says "overheard", "what did I promise", "what am I behind on", "promises audit", "am I dropping balls", "what did I commit to", "what do I owe people", or any similar commitment-check phrase. Also runs automatically once per day at a user-configured time if enabled. +allowed-tools: + - mcp__phantom-reflective__phantom_memory_search + - mcp__phantom-reflective__phantom_list_sessions +context: inline +--- + +# Overheard: the promises audit + +## Goal + +Find promises the user made in conversations (in Slack, in email, in messages) that start with phrases like "I'll send", "I will", "let me get back to you", "by Friday", "next week", "I'll follow up", and check whether there is evidence in subsequent memory of follow-through. Surface the gaps as a promises audit, with context for each and a draft action. + +The shadow backlog most people carry. The user does not need to be reminded of every forgotten ping; they need the two or three that actually matter. + +## Steps + +### 1. Pull the last 14 days of memory + +Call `mcp__phantom-reflective__phantom_memory_search` with `query: "I will"`, `days_back: 14`, `limit: 30`. Then call it again with `query: "let me"` and merge. Then `query: "follow up"`, and merge. Then `query: "by Friday"` and merge. The merge should deduplicate by episode id. + +**Success criteria**: you have a pool of recent episodes that contain commitment language. + +### 2. Extract candidate commitments + +For each episode, read the detail and pull out phrases where the user was the one making the commitment, not receiving one. Common patterns: +- "I will send you..." +- "Let me get back to you on..." +- "I'll check with and let you know." +- "By Friday I'll have..." +- "Next week I'll..." +- "I'll follow up on..." + +Skip commitments that are clearly to yourself, to the agent, or to no one in particular ("I will make more coffee"). Skip commitments that the user has already negated ("I thought I'd send X but actually I'm not going to"). + +**Success criteria**: you have a list of 5-15 candidate commitments with the original context, who the promise was to (if known), and when it was made. + +### 3. Check for follow-through + +For each candidate, search memory again for follow-up evidence. The query is a short paraphrase of what was promised. Example: if the commitment was "I'll send Anna the revised doc by Friday", search for "send Anna doc" or "revised doc Anna" with `days_back: 14`. + +Evidence of follow-through looks like: +- A later episode where the user clearly sent the thing. +- A later episode where the user explicitly said it was done. +- A later episode where someone thanked the user for the thing. +- A later episode where the topic was closed ("we decided to cancel that"). + +If none of those are present and the commitment's stated deadline has passed or is about to pass, it is an open promise. + +**Success criteria**: you have classified each candidate as "done", "open", or "unclear". + +### 4. Surface the top 3-5 open promises + +Pick the most important open promises. "Most important" means: +- The recipient is a known person (not "someone in the channel"). +- The deadline is past or within 24 hours. +- It has not been renegotiated. +- It is the kind of thing that matters if it drops (a deliverable, a followup, a decision). + +Skip low-stakes promises ("I'll check", "I'll think about it") unless they are the only ones open. + +Render each as: + +> **To Anna, committed Tuesday.** You said you would send the revised pricing doc "by Friday." I do not see evidence in memory that it has been sent. Three days overdue. Want me to draft the followup? + +**Success criteria**: under 400 words total, 3-5 open promises, each anchored to a specific memory episode, each ending with a concrete next-step offer. + +### 5. Offer to draft followups + +For each open promise, offer to draft the followup message in the user's voice. Do not draft until the user says yes. When the user says yes, pull a few recent messages from memory that show the user's tone, then write the draft as a suggestion. + +**Success criteria**: the user either says "yes draft those" or "no I've got it". Either is fine. Do not force the draft. + +## Rules + +- Never moralize about broken commitments. The tone is "here is what I saw", not "here is what you should do". +- Never em-dash. +- Never surface more than five open promises. Density beats coverage. +- Never fabricate a recipient. If you cannot tell who the promise was to, say "to someone in " and let the user fill in the gap. +- Always anchor each promise to a real memory episode. +- Always offer the followup draft, but never send it without explicit approval. diff --git a/skills-builtin/ritual/SKILL.md b/skills-builtin/ritual/SKILL.md new file mode 100644 index 0000000..4020db4 --- /dev/null +++ b/skills-builtin/ritual/SKILL.md @@ -0,0 +1,100 @@ +--- +name: ritual +description: Discover recurring behaviors from memory and offer to formalize them as scheduled jobs. +when_to_use: Use when the user says "ritual", "what are my patterns", "turn this into a routine", "make this recurring", "what do I do regularly", "what should I schedule", "automate this for me", or any similar pattern-formalization phrase. Also fires on a monthly cadence if enabled. +allowed-tools: + - mcp__phantom-reflective__phantom_memory_search + - mcp__phantom-reflective__phantom_list_sessions + - mcp__phantom-scheduler__phantom_schedule +context: inline +--- + +# Ritual: latent patterns to scheduled jobs + +## Goal + +Find recurring behaviors in the user's history that emerged naturally over time without being formalized as scheduled jobs, and propose turning them into first-class schedules. The user does not need to remember to do the thing; the agent does it for them and delivers the result where they are. + +The test is "what does the user already do on a cadence that the agent could prepare for them so they do not have to start from scratch each time." Not "what should the user be doing". Only what they already do. + +## Steps + +### 1. Pull the last 60 days of sessions + +Call `mcp__phantom-reflective__phantom_list_sessions` with `days_back: 60`, `limit: 200`. Note the started_at timestamp, channel, and the first user message of each session if you can see it. + +**Success criteria**: you have a list of 50+ sessions from the last two months with timestamps. + +### 2. Look for temporal repetition + +Cluster the sessions by: +- Day of week (Monday, Tuesday, ...). +- Time of day (bucket into morning, midday, afternoon, evening). +- Channel. +- Topic, if you can infer it from the first message. + +A candidate ritual is a cluster where: +- Three or more sessions happened. +- They share day of week OR time of day (ideally both). +- They share topic or channel. +- They are spaced at roughly the same cadence (weekly, biweekly, monthly). + +Example candidates: +- "Every Monday morning around 8:30 in #ops you ask for a standup." +- "Every second Friday in Slack DM you ask me to prepare a weekly review." +- "Every first of the month in #finance you ask for a cost breakdown." + +**Success criteria**: you have identified 1-5 candidate rituals. + +### 3. Verify with memory + +For each candidate ritual, call `mcp__phantom-reflective__phantom_memory_search` with a query matching the topic and `days_back: 60`. Confirm that memory also shows the same pattern. + +Discard any candidate that the session pattern suggests but memory does not support. Discard any where the cadence is off (the user did it three Mondays in a row, then stopped two weeks ago). + +**Success criteria**: you have 1-3 verified rituals with strong evidence. + +### 4. Propose formalization + +Render each verified ritual as a proposal: + +> **The Monday standup ritual.** For six of the last eight Mondays you opened #ops at roughly 8:30 and asked me for a standup. Want me to prepare the standup for you automatically and DM it to you at 8:25am Mondays? You can still ask me for it by hand; this is additive. + +For each proposal, include: +- The cadence you observed, with counts ("six of the last eight"). +- The proposed schedule in specific terms (day and time). +- What the agent would prepare (the work that runs on the schedule). +- Where it would deliver (Slack DM, channel, or email). +- A clear yes-or-no next step. + +**Success criteria**: the user has 1-3 clear proposals they can accept or decline. + +### 5. Create the schedule on approval + +When the user says yes to a ritual, call `mcp__phantom-scheduler__phantom_schedule` with `action: "create"`. Build the `task` field as a complete self-contained prompt for the future run (the scheduled run will not have access to the current conversation). Use a `cron` schedule in the user's timezone if you know it, otherwise `at` or `every`. + +Example call for the Monday standup: + +```json +{ + "action": "create", + "name": "monday-standup", + "description": "Weekly Monday morning standup, delivered before the user asks.", + "schedule": { "kind": "cron", "expr": "25 8 * * 1", "tz": "America/Los_Angeles" }, + "task": "Run the `standup` skill. Pull the last 72 hours of activity from memory, focus on commitments and channels, and deliver as a short morning briefing.", + "delivery": { "channel": "slack", "target": "owner" } +} +``` + +Confirm the schedule was created by showing the user the next run time and how to cancel it. + +**Success criteria**: the schedule exists and the user knows how to manage it. + +## Rules + +- Never propose a ritual the user has not already been doing on their own. That is prescriptive; this skill is descriptive. +- Never create a schedule without explicit user approval. +- Never em-dash. +- Never propose more than three rituals in one pass. The user should leave with a clear picture, not a list they will not read. +- Always include the cadence count ("four of the last six") so the user knows the evidence is real. +- If you have no verified rituals after reading 60 days of history, tell the user honestly and suggest they come back after a few more weeks of use. diff --git a/skills-builtin/show-my-tools/SKILL.md b/skills-builtin/show-my-tools/SKILL.md new file mode 100644 index 0000000..3c17533 --- /dev/null +++ b/skills-builtin/show-my-tools/SKILL.md @@ -0,0 +1,63 @@ +--- +name: show-my-tools +description: List the agent's current skills, memory files, and dashboard URLs. The user-facing discovery path for everything the operator can edit. +when_to_use: Use when the user says "what can you do", "what skills do you have", "show me your skills", "what can I edit", "how do I customize you", "what memory files do you have", "what is in your .claude", "where is the dashboard", or any similar discovery question. +allowed-tools: + - Read + - Glob + - Bash +context: inline +--- + +# Show my tools + +## Goal + +Give the user a clear, accurate view of what is currently loaded: skills, memory files, and dashboard URLs. Honest about what is on disk, not a marketing list. + +## Steps + +### 1. List skills + +Use Glob to find every `SKILL.md` file under `/home/phantom/.claude/skills/`. For each hit, Read the file and extract the YAML frontmatter's `name` and `description`. + +**Success criteria**: you have a list of `(name, description)` pairs for every SKILL.md on disk. + +### 2. List memory files + +Use Glob to find every `.md` file directly under `/home/phantom/.claude/` (depth up to 3), excluding the `skills/` subtree and the `plugins/` and `agents/` subtrees. Do not read their content; just list the paths and sizes. + +**Success criteria**: you have a list of memory file paths with sizes. + +### 3. Render as three sections + +Format the response like this: + +> **Skills.** I have N skills loaded from /home/phantom/.claude/skills/: +> +> - **mirror** - weekly self-audit playback +> - **thread** - the evolution of thinking on a topic +> - **echo** - prior-answer surfacer before I answer substantive questions +> - **overheard** - promises audit from the last 14 days +> - **ritual** - turn latent patterns into scheduled jobs +> - **show-my-tools** - this one +> +> **Memory files.** I have M markdown files under /home/phantom/.claude/: +> +> - **CLAUDE.md** - top-level memory (N bytes) +> - **rules/...** - any rule files you have written +> - **memory/...** - any free-form notes you have written +> +> **Dashboard.** You can see and edit all of the above at `/ui/dashboard/`. Skills tab is for creating, editing, and deleting skills. Memory files tab is for everything else under .claude/. The other tabs (sessions, cost, scheduler, evolution, memory explorer, settings) are coming in later releases. + +If `public_url` is not available, use `http://localhost:/ui/dashboard/` or whatever matches the operator's known URL. + +**Success criteria**: the response shows the real current counts and names, the dashboard URL is accurate, and the user can act on it immediately. + +## Rules + +- Never fabricate a skill or memory file that is not actually on disk. +- Never use em dashes in the response. Regular hyphens are fine. +- Always list the dashboard URL. +- If a skill has invalid YAML frontmatter, show it in the list with a note "(parse error)" so the user can fix it. +- Keep the response under 300 words. diff --git a/skills-builtin/thread/SKILL.md b/skills-builtin/thread/SKILL.md new file mode 100644 index 0000000..80b1777 --- /dev/null +++ b/skills-builtin/thread/SKILL.md @@ -0,0 +1,82 @@ +--- +name: thread +description: Show how the user's thinking on a specific topic has evolved over time. A chronological narrative with turning-point callouts. +when_to_use: Use when the user says "thread ", "how has my thinking on X evolved", "show me the arc on X", "what have I said about X", "take me through X from the start", "where am I with X", or when the user needs to re-ground in a long-running decision. +allowed-tools: + - mcp__phantom-reflective__phantom_memory_search + - mcp__phantom-reflective__phantom_list_sessions + - Read +argument-hint: "[topic]" +arguments: + - topic +context: inline +--- + +# Thread: the evolution of thinking + +## Inputs + +- `$topic`: the specific topic the user wants to trace. Could be a project name, a decision, a person, a product, a question. + +## Goal + +Pull every mention of a specific topic from memory across sessions and channels, order them chronologically, cluster by time period and sub-theme, identify turning points where the user's view changed, and render as a narrative of evolution. + +Not a log. Not a summary. A view of the shape of how the user changed their mind. The user should come away thinking "that is what I was actually doing, and I did not see it that clearly before." + +## Steps + +### 1. Search memory for the topic + +Call `mcp__phantom-reflective__phantom_memory_search` with `query: "$topic"`, `memory_type: "all"`, `limit: 30`. Do NOT pass `days_back`. We want the full history. + +**Success criteria**: you have at least three hits for the topic. If you have zero or one, tell the user honestly and stop ("I do not have enough history on this topic yet to build an arc. It looks like this is the first time you are raising it."). + +### 2. Order and cluster chronologically + +Sort the hits by their `started_at` or `valid_from` timestamp. Cluster them by time period: +- If the hits span less than 14 days, cluster by day. +- If they span 14 to 90 days, cluster by week. +- If they span more than 90 days, cluster by month. + +Within each cluster, look for sub-themes. A single cluster might split into "technical concerns" and "people concerns" if both appear in the same week. + +**Success criteria**: you have 2-6 time clusters with the hits assigned to each. + +### 3. Identify turning points + +Re-read the clusters in order. Mark a turning point when: +- The user's stated view of the topic visibly changed. +- New information landed that the user acknowledged shifted things. +- A decision was explicitly made ("I decided to", "we are going with"). +- A commitment was made or withdrawn. +- An emotional tone shifted (frustration to calm, curiosity to conviction). + +**Success criteria**: you have 1-4 turning points that you can cite to specific memory episodes. + +### 4. Render as a narrative + +Write a single flowing narrative, organized chronologically by cluster. Each cluster becomes a short paragraph starting with the date range. Turning points are called out inline with a leading date. Example: + +> **Late March.** You first brought up the pricing decision after Anna pushed back on the tier structure. The framing was defensive; you kept looking for a reason to keep the current plan. +> +> **April 2.** _Turning point._ The conversation with Vercel's support shifted this. You said "maybe we are optimizing for the wrong user" and the shape of the question changed. +> +> **Last week.** You are now treating the pricing decision as a product decision, not a pricing decision. Four conversations this week circled the user segmentation question. + +Close with two short sections: + +**Where you are now.** One paragraph based on the most recent mentions. What the user currently thinks, in the user's own words if you can quote them accurately. + +**What is unclear.** One or two open questions the arc has not yet resolved. This is honest. If everything is clear, say so. + +**Success criteria**: under 500 words, every cluster and turning point is anchored to at least one memory episode, the "where you are now" paragraph reflects the most recent mentions. + +## Rules + +- Never invent turning points that are not in memory. If there are no turning points, say so and present the arc as a steady evolution. +- Never em-dash. +- Always cite at least one memory episode per cluster. +- Stay under 500 words total. +- Do not summarize every mention. Pick the hits that mark movement and skip the rest. +- If the topic has only recent hits (all from the last three days), tell the user honestly and suggest they come back in a week. From 854579ac6ff817c454780cfb13cd92498494e949 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Mon, 13 Apr 2026 18:47:36 -0700 Subject: [PATCH 6/6] fix: address two Codex P2 findings on PR #56 1. Pass temporal filters to semantic fact recall in the in-process reflective MCP server. phantom_memory_search was building a temporal RecallOptions for episodes when days_back was set, but recallFacts was being called with only { limit }. Reflective skills like mirror that ask for a 7-day window were leaking older facts into the result set. Fix is one word: pass the same opts object to recallFacts. The downstream semantic.recall already honors timeRange via a Qdrant range filter on valid_from. 2. Permit the x-phantom-source provenance marker in the SKILL.md frontmatter schema. The Zod schema was strict and detectSource() in src/skills/storage.ts read frontmatter['x-phantom-source'] without the field being declared, so any built-in skill that set the marker would have been rejected at parse time. Added the field to the schema as an optional enum of "built-in" | "agent" | "user", added the marker to all six built-in SKILL.md files (echo, mirror, overheard, ritual, show-my-tools, thread), and added tests for both the schema acceptance and the source classification. Quality gates: bun test 1044 pass / 0 fail (+4 new tests), bun run lint clean, bun run typecheck clean. --- skills-builtin/echo/SKILL.md | 1 + skills-builtin/mirror/SKILL.md | 1 + skills-builtin/overheard/SKILL.md | 1 + skills-builtin/ritual/SKILL.md | 1 + skills-builtin/show-my-tools/SKILL.md | 1 + skills-builtin/thread/SKILL.md | 1 + src/agent/in-process-reflective-tools.ts | 4 +- src/skills/__tests__/frontmatter.test.ts | 49 ++++++++++++++++++++++++ src/skills/__tests__/storage.test.ts | 13 +++++++ src/skills/frontmatter.ts | 6 +++ 10 files changed, 77 insertions(+), 1 deletion(-) diff --git a/skills-builtin/echo/SKILL.md b/skills-builtin/echo/SKILL.md index e2fcd66..995a7e5 100644 --- a/skills-builtin/echo/SKILL.md +++ b/skills-builtin/echo/SKILL.md @@ -1,5 +1,6 @@ --- name: echo +x-phantom-source: built-in description: Before answering a substantive question, quietly check whether the user has already resolved this question in the past. when_to_use: Use when the user asks a substantive question that might have been asked and resolved before. Substantive questions are things like "how should I do X", "what is the right way to Y", "which approach is better", "what did we decide about Z". Do NOT fire on greetings, small talk, status checks, or operational queries like "what time is it" or "are you online". Before deriving a new answer, run a memory similarity check. If a strong prior match exists, surface it inline and ask whether anything has changed. allowed-tools: diff --git a/skills-builtin/mirror/SKILL.md b/skills-builtin/mirror/SKILL.md index a130adf..2038931 100644 --- a/skills-builtin/mirror/SKILL.md +++ b/skills-builtin/mirror/SKILL.md @@ -1,5 +1,6 @@ --- name: mirror +x-phantom-source: built-in description: Weekly self-audit playback. Surface patterns from the user's past week that they probably cannot see themselves. when_to_use: Use when the user says "mirror", "weekly review", "show me my week", "what did I actually do this week", "reflect on last week", "how did my week go", or any similar reflective request. Also fires automatically on a Friday evening schedule if the user has enabled the mirror ritual. allowed-tools: diff --git a/skills-builtin/overheard/SKILL.md b/skills-builtin/overheard/SKILL.md index 2e39fa1..405c455 100644 --- a/skills-builtin/overheard/SKILL.md +++ b/skills-builtin/overheard/SKILL.md @@ -1,5 +1,6 @@ --- name: overheard +x-phantom-source: built-in description: Find commitments the user made in the last two weeks and did not follow through on. A promises audit. when_to_use: Use when the user says "overheard", "what did I promise", "what am I behind on", "promises audit", "am I dropping balls", "what did I commit to", "what do I owe people", or any similar commitment-check phrase. Also runs automatically once per day at a user-configured time if enabled. allowed-tools: diff --git a/skills-builtin/ritual/SKILL.md b/skills-builtin/ritual/SKILL.md index 4020db4..5491f89 100644 --- a/skills-builtin/ritual/SKILL.md +++ b/skills-builtin/ritual/SKILL.md @@ -1,5 +1,6 @@ --- name: ritual +x-phantom-source: built-in description: Discover recurring behaviors from memory and offer to formalize them as scheduled jobs. when_to_use: Use when the user says "ritual", "what are my patterns", "turn this into a routine", "make this recurring", "what do I do regularly", "what should I schedule", "automate this for me", or any similar pattern-formalization phrase. Also fires on a monthly cadence if enabled. allowed-tools: diff --git a/skills-builtin/show-my-tools/SKILL.md b/skills-builtin/show-my-tools/SKILL.md index 3c17533..494931d 100644 --- a/skills-builtin/show-my-tools/SKILL.md +++ b/skills-builtin/show-my-tools/SKILL.md @@ -1,5 +1,6 @@ --- name: show-my-tools +x-phantom-source: built-in description: List the agent's current skills, memory files, and dashboard URLs. The user-facing discovery path for everything the operator can edit. when_to_use: Use when the user says "what can you do", "what skills do you have", "show me your skills", "what can I edit", "how do I customize you", "what memory files do you have", "what is in your .claude", "where is the dashboard", or any similar discovery question. allowed-tools: diff --git a/skills-builtin/thread/SKILL.md b/skills-builtin/thread/SKILL.md index 80b1777..8d676b7 100644 --- a/skills-builtin/thread/SKILL.md +++ b/skills-builtin/thread/SKILL.md @@ -1,5 +1,6 @@ --- name: thread +x-phantom-source: built-in description: Show how the user's thinking on a specific topic has evolved over time. A chronological narrative with turning-point callouts. when_to_use: Use when the user says "thread ", "how has my thinking on X evolved", "show me the arc on X", "what have I said about X", "take me through X from the start", "where am I with X", or when the user needs to re-ground in a long-running decision. allowed-tools: diff --git a/src/agent/in-process-reflective-tools.ts b/src/agent/in-process-reflective-tools.ts index 2d24740..e9b0f07 100644 --- a/src/agent/in-process-reflective-tools.ts +++ b/src/agent/in-process-reflective-tools.ts @@ -86,7 +86,9 @@ Each episode includes summary, detail, outcome, started_at, tools_used, and less results.episodes = await memory.recallEpisodes(input.query, opts).catch(() => []); } if (input.memory_type === "semantic" || input.memory_type === "all") { - results.facts = await memory.recallFacts(input.query, { limit }).catch(() => []); + // Same opts so days_back also bounds semantic facts; otherwise a + // weekly mirror leaks 6-month-old preferences into the result. + results.facts = await memory.recallFacts(input.query, opts).catch(() => []); } const total = Object.values(results).reduce((sum, arr) => sum + arr.length, 0); diff --git a/src/skills/__tests__/frontmatter.test.ts b/src/skills/__tests__/frontmatter.test.ts index 3d8dd41..4164cc2 100644 --- a/src/skills/__tests__/frontmatter.test.ts +++ b/src/skills/__tests__/frontmatter.test.ts @@ -63,6 +63,55 @@ describe("parseFrontmatter", () => { const result = parseFrontmatter(raw); expect(result.ok).toBe(false); }); + + test("accepts x-phantom-source marker for built-in skills", () => { + const raw = `--- +name: mirror +x-phantom-source: built-in +description: weekly self-audit +when_to_use: Use on Friday evening. +--- + +# Mirror +body +`; + const result = parseFrontmatter(raw); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.parsed.frontmatter["x-phantom-source"]).toBe("built-in"); + }); + + test("accepts x-phantom-source marker for agent-authored skills", () => { + const raw = `--- +name: mirror +x-phantom-source: agent +description: weekly self-audit +when_to_use: Use on Friday evening. +--- + +# Mirror +body +`; + const result = parseFrontmatter(raw); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.parsed.frontmatter["x-phantom-source"]).toBe("agent"); + }); + + test("rejects invalid x-phantom-source values", () => { + const raw = `--- +name: mirror +x-phantom-source: bogus +description: weekly self-audit +when_to_use: Use on Friday evening. +--- + +# Mirror +body +`; + const result = parseFrontmatter(raw); + expect(result.ok).toBe(false); + }); }); describe("serializeSkill", () => { diff --git a/src/skills/__tests__/storage.test.ts b/src/skills/__tests__/storage.test.ts index 47fb3c9..2511235 100644 --- a/src/skills/__tests__/storage.test.ts +++ b/src/skills/__tests__/storage.test.ts @@ -41,6 +41,19 @@ describe("listSkills", () => { const result = listSkills(); expect(result.skills.length).toBe(1); expect(result.skills[0].name).toBe("mirror"); + expect(result.skills[0].source).toBe("user"); + }); + + test("classifies a skill with x-phantom-source: built-in as built-in", () => { + const skillDir = join(tmp, "mirror"); + mkdirSync(skillDir); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: mirror\nx-phantom-source: built-in\ndescription: weekly\nwhen_to_use: Use on Friday.\n---\n\n# Mirror\n", + ); + const result = listSkills(); + expect(result.skills.length).toBe(1); + expect(result.skills[0].source).toBe("built-in"); }); test("skips directories with bad names", () => { diff --git a/src/skills/frontmatter.ts b/src/skills/frontmatter.ts index 268070d..45fe385 100644 --- a/src/skills/frontmatter.ts +++ b/src/skills/frontmatter.ts @@ -32,6 +32,7 @@ export const SKILL_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/; export const MAX_BODY_BYTES = 50 * 1024; // 50 KB export const SkillContextSchema = z.enum(["inline", "fork"]); +export const SkillSourceSchema = z.enum(["built-in", "agent", "user"]); export const SkillFrontmatterSchema = z .object({ @@ -46,6 +47,11 @@ export const SkillFrontmatterSchema = z arguments: z.array(z.string().min(1)).optional(), context: SkillContextSchema.optional(), "disable-model-invocation": z.boolean().optional(), + // Provenance marker. Omitted on user-authored skills (default treats + // missing as "user"). Built-in skills shipped under skills-builtin/ set + // this to "built-in" so the dashboard can group and badge them. + // detectSource() in src/skills/storage.ts reads this field. + "x-phantom-source": SkillSourceSchema.optional(), }) .strict();