From 687ac46dc1c6838802ad1a2d02df4e0a865555dc Mon Sep 17 00:00:00 2001 From: L3tum <9307432+L3tum@users.noreply.github.com> Date: Sun, 31 May 2026 13:54:27 +0200 Subject: [PATCH] feat: Rework memory & reflection & skills --- .pi/extensions/_shared/cost-history.test.ts | 12 + .pi/extensions/_shared/cost-history.ts | 68 + .../_shared/session-history.test.ts | 69 + .pi/extensions/_shared/session-history.ts | 123 + .pi/extensions/_shared/skill-catalog.test.ts | 24 + .pi/extensions/_shared/skill-catalog.ts | 81 + .pi/extensions/branding/index.ts | 64 +- .pi/extensions/breadcrumbs/index.test.ts | 55 + .pi/extensions/breadcrumbs/index.ts | 62 + .pi/extensions/browser/index.ts | 4 +- .pi/extensions/extra-tools/findread.test.ts | 48 + .pi/extensions/extra-tools/index.ts | 21 +- .pi/extensions/memory-context/index.test.ts | 504 --- .pi/extensions/memory-context/index.ts | 1046 ------- .pi/extensions/mode-commands/index.test.ts | 14 + .pi/extensions/mode-commands/index.ts | 27 +- .pi/extensions/permission-gate/index.ts | 15 +- .pi/extensions/pi-insights/LICENSE | 661 ++++ .pi/extensions/pi-insights/README.md | 135 + .pi/extensions/pi-insights/index.ts | 2759 +++++++++++++++++ .pi/extensions/reflect-skills/index.test.ts | 65 + .pi/extensions/reflect-skills/index.ts | 165 + .pi/extensions/skill-inject/index.ts | 185 +- .pi/extensions/skill-inject/selector.test.ts | 65 +- .pi/extensions/web/api.test.ts | 44 + .pi/extensions/web/index.test.ts | 20 + .pi/extensions/web/index.ts | 91 + AGENTS.md | 106 +- CHANGELOG.md | 22 + NOTICE | 9 + README.md | 4 +- docs/architecture.md | 4 +- docs/memory-context-plan.md | 152 - docs/memory-context-quality-plan.md | 238 -- docs/memory-context.md | 73 - package-lock.json | 10 - package.json | 2 - plans/enhancements-roadmap.md | 200 ++ scripts/patch-extension-notifications.mjs | 35 - .../patch-extension-notifications.test.mjs | 4 + .../HTML-REPORT.md | 3 + .../improve-codebase-architecture/LANGUAGE.md | 3 + .../improve-codebase-architecture/SKILL.md | 43 + skills/knowledge/bfs_state_space.md | 1 + skills/knowledge/binary_search.md | 1 + skills/knowledge/code_review.md | 1 + skills/knowledge/dfs_vs_bfs.md | 1 + skills/knowledge/dynamic_programming.md | 1 + skills/knowledge/frontend_design.md | 1 + skills/knowledge/hash_vs_tree.md | 1 + skills/knowledge/io_wrapper.md | 1 + skills/knowledge/recursion_backtracking.md | 1 + skills/knowledge/rule_string_transform.md | 1 + skills/knowledge/sorting_choice.md | 1 + skills/knowledge/tree_rerooting.md | 1 + skills/knowledge/tree_zipper.md | 1 + skills/knowledge/two_pointers.md | 1 + skills/knowledge/workspace_docs.md | 1 + skills/protocols/cite_before_answer.md | 2 + skills/protocols/research_protocol.md | 2 + skills/protocols/task_decomposition.md | 2 + skills/tools/bash.md | 2 + skills/tools/browser_click.md | 2 + skills/tools/browser_extract.md | 2 + skills/tools/browser_navigate.md | 2 + skills/tools/browser_type.md | 2 + skills/tools/codegraph_memory_search_graph.md | 2 + skills/tools/edit.md | 2 + skills/tools/evidence_add.md | 2 + skills/tools/find_read.md | 4 + skills/tools/glob.md | 2 + skills/tools/grep.md | 2 + skills/tools/read.md | 2 + skills/tools/skills.md | 15 +- skills/tools/webfetch.md | 2 + skills/tools/write.md | 2 + 76 files changed, 5085 insertions(+), 2316 deletions(-) create mode 100644 .pi/extensions/_shared/cost-history.test.ts create mode 100644 .pi/extensions/_shared/cost-history.ts create mode 100644 .pi/extensions/_shared/session-history.test.ts create mode 100644 .pi/extensions/_shared/session-history.ts create mode 100644 .pi/extensions/_shared/skill-catalog.test.ts create mode 100644 .pi/extensions/_shared/skill-catalog.ts create mode 100644 .pi/extensions/breadcrumbs/index.test.ts create mode 100644 .pi/extensions/breadcrumbs/index.ts create mode 100644 .pi/extensions/extra-tools/findread.test.ts delete mode 100644 .pi/extensions/memory-context/index.test.ts delete mode 100644 .pi/extensions/memory-context/index.ts create mode 100644 .pi/extensions/mode-commands/index.test.ts create mode 100644 .pi/extensions/pi-insights/LICENSE create mode 100644 .pi/extensions/pi-insights/README.md create mode 100644 .pi/extensions/pi-insights/index.ts create mode 100644 .pi/extensions/reflect-skills/index.test.ts create mode 100644 .pi/extensions/reflect-skills/index.ts create mode 100644 .pi/extensions/web/api.test.ts create mode 100644 .pi/extensions/web/index.test.ts create mode 100644 .pi/extensions/web/index.ts delete mode 100644 docs/memory-context-plan.md delete mode 100644 docs/memory-context-quality-plan.md delete mode 100644 docs/memory-context.md create mode 100644 plans/enhancements-roadmap.md create mode 100644 skills/engineering/improve-codebase-architecture/HTML-REPORT.md create mode 100644 skills/engineering/improve-codebase-architecture/LANGUAGE.md create mode 100644 skills/engineering/improve-codebase-architecture/SKILL.md diff --git a/.pi/extensions/_shared/cost-history.test.ts b/.pi/extensions/_shared/cost-history.test.ts new file mode 100644 index 00000000..b38ca197 --- /dev/null +++ b/.pi/extensions/_shared/cost-history.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { summarizeCosts } from "./cost-history.ts"; + +describe("cost-history", () => { + it("returns a stable summary shape", () => { + const summary = summarizeCosts(); + expect(summary.sessions).toBeGreaterThanOrEqual(0); + expect(summary.messages).toBeGreaterThanOrEqual(0); + expect(summary.totalCost).toBeGreaterThanOrEqual(0); + expect(Array.isArray(summary.topSessions)).toBe(true); + }); +}); diff --git a/.pi/extensions/_shared/cost-history.ts b/.pi/extensions/_shared/cost-history.ts new file mode 100644 index 00000000..86874590 --- /dev/null +++ b/.pi/extensions/_shared/cost-history.ts @@ -0,0 +1,68 @@ +import { discoverSessions } from "./session-history.ts"; + +export interface CostSummary { + sessions: number; + messages: number; + totalCost: number; + providers: Record; + models: Record; + tools: Record; + projects: Record; + daily: Array<{ date: string; cost: number; messages: number }>; + topSessions: Array<{ id: string; cost: number; project?: string; transcript?: string }>; +} + +function numeric(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function costFromTurn(turn: any): number { + return numeric(turn.cost) || numeric(turn.usage?.cost) || numeric(turn.message?.cost) || 0; +} + +export function summarizeCosts(): CostSummary { + const providers: CostSummary["providers"] = {}; + const models: CostSummary["models"] = {}; + const tools: CostSummary["tools"] = {}; + const projects: CostSummary["projects"] = {}; + const dailyMap: Record = {}; + const topSessions: CostSummary["topSessions"] = []; + let messages = 0; + let totalCost = 0; + + for (const session of discoverSessions()) { + let sessionCost = 0; + const project = session.project || "unknown"; + projects[project] ??= { sessions: 0, cost: 0 }; + projects[project].sessions += 1; + for (const turn of session.turns as any[]) { + if (turn.toolName) { + tools[turn.toolName] ??= { calls: 0 }; + tools[turn.toolName].calls += 1; + } + if (turn.role === "tool_result") continue; + messages += 1; + const cost = costFromTurn(turn); + sessionCost += cost; + totalCost += cost; + const day = String(turn.timestamp || session.date || "unknown").slice(0, 10); + dailyMap[day] ??= { cost: 0, messages: 0 }; + dailyMap[day].cost += cost; + dailyMap[day].messages += 1; + const provider = turn.provider || turn.modelProvider || "unknown"; + const model = turn.model || turn.modelName || "unknown"; + providers[provider] ??= { messages: 0, cost: 0 }; + providers[provider].messages += 1; + providers[provider].cost += cost; + models[model] ??= { messages: 0, cost: 0 }; + models[model].messages += 1; + models[model].cost += cost; + } + projects[project].cost += sessionCost; + topSessions.push({ id: session.id, cost: sessionCost, project: session.project, transcript: session.path }); + } + + topSessions.sort((a, b) => b.cost - a.cost); + const daily = Object.entries(dailyMap).map(([date, v]) => ({ date, ...v })).sort((a, b) => a.date.localeCompare(b.date)); + return { sessions: topSessions.length, messages, totalCost, providers, models, tools, projects, daily, topSessions: topSessions.slice(0, 10) }; +} diff --git a/.pi/extensions/_shared/session-history.test.ts b/.pi/extensions/_shared/session-history.test.ts new file mode 100644 index 00000000..3770a994 --- /dev/null +++ b/.pi/extensions/_shared/session-history.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverSessions, lexicalSessionScore, parseSessionFile, searchSessions, searchSessionsWithMode, type SessionOutline } from "./session-history.ts"; + +describe("session-history", () => { + it("parses mixed session jsonl safely", () => { + const dir = mkdtempSync(join(tmpdir(), "lc-session-")); + const file = join(dir, "one.jsonl"); + writeFileSync(file, [ + JSON.stringify({ timestamp: "2026-01-01T00:00:00Z", cwd: "/repo/demo", role: "user", content: "find auth bug" }), + "not json", + JSON.stringify({ role: "assistant", message: { content: [{ text: "checked files" }] } }), + JSON.stringify({ toolName: "read", content: "large tool output" }), + ].join("\n")); + + const parsed = parseSessionFile(file)!; + expect(parsed.cwd).toBe("/repo/demo"); + expect(parsed.project).toBe("demo"); + expect(parsed.turns.map((t) => t.role)).toContain("user"); + expect(parsed.turns.some((t) => t.toolName === "read")).toBe(true); + }); + + it("discovers sessions and ranks lexical matches", () => { + const root = mkdtempSync(join(tmpdir(), "lc-agent-")); + const sessions = join(root, "sessions", "p"); + mkdirSync(sessions, { recursive: true }); + const a = join(sessions, "a.jsonl"); + const b = join(sessions, "b.jsonl"); + writeFileSync(a, JSON.stringify({ timestamp: "2026-01-02", cwd: "/repo/a", role: "user", content: "fix payment route" }) + "\n"); + writeFileSync(b, JSON.stringify({ timestamp: "2026-01-01", cwd: "/repo/b", role: "user", content: "write docs" }) + "\n"); + + const found = discoverSessions(root); + expect(found).toHaveLength(2); + const ranked = searchSessions("payment route", found, "/repo/a", 5); + expect(ranked[0].path).toBe(a); + expect(ranked[0].snippet.length).toBeLessThanOrEqual(300); + expect(ranked[0].mode).toBe("lexical"); + }); + + it("semantic mode returns an explicit fallback result", async () => { + const result = await searchSessionsWithMode("anything", "semantic", [], process.cwd(), 5); + expect(result.mode).toMatch(/fallback/); + expect(result.note).toContain("fallback"); + }); + + it("boosts user prompts, file paths, tool names, and current project matches", () => { + const strong: SessionOutline = { + id: "strong", + path: "/tmp/strong.jsonl", + cwd: "/repo/current", + project: "current", + turns: [ + { role: "user", text: "Fix src/auth/login.ts using grep for auth failure" }, + { role: "tool", toolName: "grep", text: "src/auth/login.ts: failed auth" }, + ], + }; + const weak: SessionOutline = { + id: "weak", + path: "/tmp/weak.jsonl", + cwd: "/repo/other", + project: "other", + turns: [{ role: "assistant", text: "auth mentioned once" }], + }; + expect(lexicalSessionScore("auth grep src/auth/login.ts", strong, "/repo/current")) + .toBeGreaterThan(lexicalSessionScore("auth grep src/auth/login.ts", weak, "/repo/current")); + }); +}); diff --git a/.pi/extensions/_shared/session-history.ts b/.pi/extensions/_shared/session-history.ts new file mode 100644 index 00000000..100cb139 --- /dev/null +++ b/.pi/extensions/_shared/session-history.ts @@ -0,0 +1,123 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, relative } from "node:path"; + +export interface SessionTurn { role: string; text: string; toolName?: string; timestamp?: string; cost?: number; provider?: string; model?: string } +export interface SessionOutline { id: string; path: string; project?: string; cwd?: string; date?: string; turns: SessionTurn[] } + +export function agentDir(): string { + return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"); +} + +function walkJsonl(dir: string, out: string[] = []): string[] { + if (!existsSync(dir)) return out; + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + try { + const st = statSync(path); + if (st.isDirectory()) walkJsonl(path, out); + else if (name.endsWith(".jsonl")) out.push(path); + } catch {} + } + return out; +} + +function eventText(obj: any): string { + const c = obj?.content ?? obj?.message?.content ?? obj?.text ?? obj?.prompt ?? ""; + if (typeof c === "string") return c; + if (Array.isArray(c)) return c.map((x) => typeof x === "string" ? x : x?.text ?? "").join("\n"); + return ""; +} + +export function parseSessionFile(path: string): SessionOutline | undefined { + try { + const turns: SessionTurn[] = []; + let cwd: string | undefined; + let project: string | undefined; + let date: string | undefined; + for (const line of readFileSync(path, "utf-8").split("\n")) { + if (!line.trim()) continue; + let obj: any; + try { obj = JSON.parse(line); } catch { continue; } + cwd ||= obj.cwd || obj.projectCwd || obj.session?.cwd; + project ||= obj.project || obj.projectName || (cwd ? cwd.split(/[\\/]/).pop() : undefined); + date ||= obj.timestamp || obj.time || obj.createdAt; + const role = obj.role || obj.message?.role || obj.type; + const toolName = obj.toolName || obj.name || obj.tool?.name; + const text = eventText(obj).replace(/\s+/g, " ").trim(); + if (role || toolName || text) turns.push({ + role: role || (toolName ? "tool" : "event"), + toolName, + text, + timestamp: obj.timestamp || obj.time, + cost: obj.cost ?? obj.usage?.cost ?? obj.message?.cost, + provider: obj.provider ?? obj.modelProvider ?? obj.message?.provider, + model: obj.model ?? obj.modelName ?? obj.message?.model, + }); + } + return { id: relative(agentDir(), path).replace(/\.jsonl$/, ""), path, cwd, project, date, turns }; + } catch { return undefined; } +} + +export function discoverSessions(base = agentDir()): SessionOutline[] { + const dir = join(base, "sessions"); + return walkJsonl(dir).map(parseSessionFile).filter((x): x is SessionOutline => !!x) + .sort((a, b) => (b.date || "").localeCompare(a.date || "")); +} + +export function outlineText(session: SessionOutline, maxTurns = 8): string { + return session.turns.filter((t) => t.text && t.role !== "tool_result").slice(-maxTurns) + .map((t) => `${t.role}${t.toolName ? `:${t.toolName}` : ""}: ${t.text.slice(0, 300)}`).join("\n"); +} + +function termFrequency(text: string, term: string): number { + if (!term) return 0; + let count = 0; + let i = text.indexOf(term); + while (i !== -1) { + count += 1; + i = text.indexOf(term, i + term.length); + } + return count; +} + +export function lexicalSessionScore(query: string, session: SessionOutline, cwd = process.cwd()): number { + const terms = query.toLowerCase().split(/\W+/).filter(Boolean); + if (terms.length === 0) return session.cwd === cwd ? 3 : 1; + let score = session.cwd === cwd ? 3 : 0; + const project = (session.project ?? "").toLowerCase(); + const sessionCwd = (session.cwd ?? "").toLowerCase(); + for (const term of terms) { + if (project.includes(term)) score += 3; + if (sessionCwd.includes(term)) score += term.includes("/") ? 4 : 2; + for (const turn of session.turns) { + const text = turn.text.toLowerCase(); + const hits = Math.min(termFrequency(text, term), 5); + if (hits === 0) continue; + const roleBoost = turn.role === "user" ? 3 : turn.role === "tool" ? 1.5 : 1; + const pathBoost = /(?:^|[\s"'`])(?:\.?\.?\/)?[\w.-]+(?:\/[\w.-]+)+/.test(turn.text) ? 1.5 : 1; + const toolBoost = turn.toolName?.toLowerCase().includes(term) ? 2 : 1; + score += hits * roleBoost * pathBoost * toolBoost; + } + } + return score; +} + +export function searchSessions(query: string, sessions = discoverSessions(), cwd = process.cwd(), limit = 5): Array { + const terms = query.toLowerCase().split(/\W+/).filter(Boolean); + return sessions.map((s) => { + const score = lexicalSessionScore(query, s, cwd); + const snippet = outlineText(s, 4).slice(0, 300); + return { ...s, score, snippet, mode: "lexical" }; + }).filter((s) => s.score > 0 || terms.length === 0).sort((a, b) => b.score - a.score).slice(0, limit); +} + +export async function searchSessionsWithMode(query: string, mode = "lexical", sessions = discoverSessions(), cwd = process.cwd(), limit = 5): Promise<{ rows: Array; mode: string; note?: string }> { + if (mode !== "semantic") return { rows: searchSessions(query, sessions, cwd, limit), mode: "lexical" }; + try { + await import("@tobilu/qmd" as any); + return { rows: searchSessions(query, sessions, cwd, limit).map((r) => ({ ...r, mode: "semantic-fallback" })), mode: "lexical-fallback", note: "QMD is installed, but session semantic indexing is not built yet; using lexical fallback." }; + } catch { + return { rows: searchSessions(query, sessions, cwd, limit), mode: "lexical-fallback", note: "Semantic session search is unavailable because @tobilu/qmd could not initialize; using lexical fallback." }; + } +} diff --git a/.pi/extensions/_shared/skill-catalog.test.ts b/.pi/extensions/_shared/skill-catalog.test.ts new file mode 100644 index 00000000..27e2af3a --- /dev/null +++ b/.pi/extensions/_shared/skill-catalog.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { listSkillCatalog } from "./skill-catalog.ts"; + +afterEach(() => { delete process.env.LITTLE_CODER_USER_SKILLS_DIR; }); + +describe("skill-catalog", () => { + it("lists repo skills with descriptions and origins", () => { + const skills = listSkillCatalog(); + expect(skills.some((s) => s.origin === "repo" && s.name === "bash-guidance" && s.description)).toBe(true); + }); + + it("includes user-level skills", () => { + const root = mkdtempSync(join(tmpdir(), "lc-user-skills-")); + const dir = join(root, "custom"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), "---\nname: custom-user-skill\ndescription: User skill.\ntype: workflow\ntoken_cost: 50\nkeywords: [custom]\n---\nBody\n"); + process.env.LITTLE_CODER_USER_SKILLS_DIR = root; + const skills = listSkillCatalog(); + expect(skills.some((s) => s.origin === "user" && s.name === "custom-user-skill" && s.description === "User skill.")).toBe(true); + }); +}); diff --git a/.pi/extensions/_shared/skill-catalog.ts b/.pi/extensions/_shared/skill-catalog.ts new file mode 100644 index 00000000..18f83488 --- /dev/null +++ b/.pi/extensions/_shared/skill-catalog.ts @@ -0,0 +1,81 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseSkillFile } from "../skill-inject/frontmatter.ts"; + +export interface SkillCatalogEntry { + name: string; + type: string; + origin: "repo" | "user"; + sourceDir: string; + path: string; + tokenCost: number; + targetTool?: string; + description?: string; + keywords: string[]; +} + +function repoSkillsRoot(): string { + return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "skills"); +} + +function userSkillsRoot(): string { + return process.env.LITTLE_CODER_USER_SKILLS_DIR || join(homedir(), ".pi", "skills"); +} + +function walkMarkdown(dir: string): string[] { + if (!existsSync(dir)) return []; + const out: string[] = []; + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + try { + if (statSync(path).isDirectory()) out.push(...walkMarkdown(path)); + else if (name.endsWith(".md")) out.push(path); + } catch {} + } + return out; +} + +function firstBodyLine(body: string): string | undefined { + return body.split("\n").map((line) => line.replace(/^#+\s*/, "").trim()).find(Boolean)?.slice(0, 140); +} + +function inferType(sourceDir: string, fmType: unknown): string { + if (typeof fmType === "string" && fmType) return fmType; + if (sourceDir === "tools") return "tool"; + if (sourceDir === "knowledge") return "knowledge"; + if (sourceDir === "protocols") return "protocol"; + return sourceDir || "skill"; +} + +export function listSkillCatalog(): SkillCatalogEntry[] { + const roots = [ + { root: repoSkillsRoot(), origin: "repo" as const }, + { root: userSkillsRoot(), origin: "user" as const }, + ]; + const entries: SkillCatalogEntry[] = []; + for (const { root, origin } of roots) { + for (const path of walkMarkdown(root)) { + const parsed = parseSkillFile(readFileSync(path, "utf-8")); + if (!parsed?.body) continue; + const fm = parsed.frontmatter; + const rel = relative(root, path).split(/[\\/]/); + const sourceDir = origin === "user" ? "user" : (rel[0] || basename(dirname(path))); + const targetTool = typeof fm.target_tool === "string" && fm.target_tool ? fm.target_tool : undefined; + const name = (typeof fm.name === "string" && fm.name) || (typeof fm.topic === "string" && fm.topic) || targetTool || basename(path, ".md"); + entries.push({ + name, + type: inferType(sourceDir, fm.type), + origin, + sourceDir, + path, + tokenCost: typeof fm.token_cost === "number" ? fm.token_cost : 150, + targetTool, + description: typeof fm.description === "string" && fm.description ? fm.description : firstBodyLine(parsed.body), + keywords: Array.isArray(fm.keywords) ? (fm.keywords as string[]).map((k) => k.toLowerCase()) : [], + }); + } + } + return entries.sort((a, b) => a.origin.localeCompare(b.origin) || a.sourceDir.localeCompare(b.sourceDir) || a.name.localeCompare(b.name)); +} diff --git a/.pi/extensions/branding/index.ts b/.pi/extensions/branding/index.ts index cce0fd99..52644a19 100644 --- a/.pi/extensions/branding/index.ts +++ b/.pi/extensions/branding/index.ts @@ -1,7 +1,7 @@ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; import { truncateToWidth } from "@earendil-works/pi-tui"; import { WelcomeHeader, discoverLoadedCounts, getRecentSessions } from "pi-powerline-footer/welcome.ts"; -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -47,32 +47,6 @@ function readVersion(): string { } const VERSION = readVersion(); -const MEMORY_DIR = join(process.cwd(), ".pi", "memory"); -const MEMORY_NOTE_DIRS = ["20-context", "40-actions", "50-decisions", "60-observations", "70-runbooks", "80-sessions"]; - -function readMemoryCounts(): { pending: number; longTerm: number } { - let pending = 0; - let longTerm = 0; - try { - const queuePath = join(MEMORY_DIR, "queue.json"); - if (existsSync(queuePath)) { - const parsed = JSON.parse(readFileSync(queuePath, "utf-8")); - pending = Array.isArray(parsed) ? parsed.length : 0; - } - } catch { - pending = 0; - } - try { - for (const dir of MEMORY_NOTE_DIRS) { - const full = join(MEMORY_DIR, dir); - if (existsSync(full)) longTerm += readdirSync(full).filter((file) => file.endsWith(".md")).length; - } - } catch { - longTerm = 0; - } - return { pending, longTerm }; -} - function buildHeader(theme: Theme): string[] { // Brand-book "prompt lockup" (the variant the brand reserves for terminals // and dark surfaces): a honey prompt caret, the wordmark in the foreground, @@ -127,34 +101,21 @@ function buildHeader(theme: Theme): string[] { theme.fg("text", "/lsp-doctor"), theme.fg("muted", " to inspect usable LSP servers"), ].join(""); - const memoryCounts = readMemoryCounts(); const extensionLine9 = [ theme.fg("muted", "Use "), theme.fg("text", "/codebase"), theme.fg("muted", " to inspect codebase-memory"), ].join(""); - const memoryLine = [ - theme.fg("muted", "Memory: "), - theme.fg("text", `${memoryCounts.pending}`), - theme.fg("muted", " pending short-term"), - sep, - theme.fg("text", `${memoryCounts.longTerm}`), - theme.fg("muted", " long-term"), - ].join(""); - const memoryReviewLine = memoryCounts.pending > 0 ? [ - theme.fg("muted", "Use "), - theme.fg("text", "/memory-review"), - theme.fg("muted", " to triage pending memories"), - ].join("") : undefined; - const memoryListLine = [ - theme.fg("muted", "Use "), - theme.fg("text", "/memory-list"), - theme.fg("muted", " to browse long-term memories"), - ].join(""); - const memorySearchLine = [ + const reflectionLine = [ theme.fg("muted", "Use "), - theme.fg("text", "/memory-search"), - theme.fg("muted", " to search long-term memories"), + theme.fg("text", "/reflect"), + theme.fg("muted", ", "), + theme.fg("text", "/reflect-review"), + theme.fg("muted", ", "), + theme.fg("text", "/breadcrumbs"), + theme.fg("muted", ", and "), + theme.fg("text", "/skills"), + theme.fg("muted", " for reusable session learning"), ].join(""); const issueAgentSection = [ theme.bold("Issue agent:"), @@ -228,10 +189,7 @@ function buildHeader(theme: Theme): string[] { extensionLine7, extensionLine8, extensionLine9, - memoryLine, - ...(memoryReviewLine ? [memoryReviewLine] : []), - memoryListLine, - memorySearchLine, + reflectionLine, "", ...issueAgentSection, "", diff --git a/.pi/extensions/breadcrumbs/index.test.ts b/.pi/extensions/breadcrumbs/index.test.ts new file mode 100644 index 00000000..7f0d6ac2 --- /dev/null +++ b/.pi/extensions/breadcrumbs/index.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import breadcrumbs from "./index.ts"; + +afterEach(() => { delete process.env.PI_CODING_AGENT_DIR; }); + +describe("breadcrumbs extension", () => { + it("registers search/read tools and /breadcrumbs command", () => { + const tools: string[] = []; + const commands: string[] = []; + const pi: any = { + registerTool: (tool: { name: string }) => tools.push(tool.name), + registerCommand: (name: string) => commands.push(name), + }; + breadcrumbs(pi); + expect(commands).toContain("breadcrumbs"); + expect(tools).toContain("breadcrumbs_search"); + expect(tools).toContain("breadcrumbs_read"); + }); + + it("read tool exposes bounded defaults and caps", async () => { + let readTool: any; + const pi: any = { + registerCommand: () => {}, + registerTool: (tool: any) => { if (tool.name === "breadcrumbs_read") readTool = tool; }, + }; + breadcrumbs(pi); + const result = await readTool.execute("id", { session: "missing", maxTurns: 999, maxCharacters: 999999 }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("unknown session"); + }); + + it("read excludes tool output by default and includes it when requested", async () => { + const root = mkdtempSync(join(tmpdir(), "lc-breadcrumbs-")); + process.env.PI_CODING_AGENT_DIR = root; + const dir = join(root, "sessions", "demo"); + mkdirSync(dir, { recursive: true }); + const file = join(dir, "one.jsonl"); + writeFileSync(file, [ + JSON.stringify({ timestamp: "2026-01-01", cwd: "/repo/demo", role: "user", content: "hello" }), + JSON.stringify({ timestamp: "2026-01-01", toolName: "read", content: "SECRET TOOL OUTPUT" }), + JSON.stringify({ timestamp: "2026-01-01", role: "assistant", content: "done" }), + ].join("\n")); + let readTool: any; + const pi: any = { registerCommand: () => {}, registerTool: (tool: any) => { if (tool.name === "breadcrumbs_read") readTool = tool; } }; + breadcrumbs(pi); + const hidden = await readTool.execute("id", { session: "demo/one", maxTurns: 20 }); + expect(hidden.content[0].text).toContain("hello"); + expect(hidden.content[0].text).not.toContain("SECRET TOOL OUTPUT"); + const included = await readTool.execute("id", { session: "demo/one", maxTurns: 20, includeToolOutput: true }); + expect(included.content[0].text).toContain("SECRET TOOL OUTPUT"); + }); +}); diff --git a/.pi/extensions/breadcrumbs/index.ts b/.pi/extensions/breadcrumbs/index.ts new file mode 100644 index 00000000..c44a7f24 --- /dev/null +++ b/.pi/extensions/breadcrumbs/index.ts @@ -0,0 +1,62 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { discoverSessions, parseSessionFile, searchSessions } from "../_shared/session-history.ts"; + +function renderSearch(query: string, limit: number, mode = "lexical"): string { + const rows = searchSessions(query, discoverSessions(), process.cwd(), limit); + const modeNote = mode === "semantic" ? "Semantic session search is not initialized; using lexical fallback.\n\n" : ""; + if (rows.length === 0) return `${modeNote}No session breadcrumbs found for ${JSON.stringify(query)}.`; + return modeNote + rows.map((s, i) => [ + `${i + 1}. ${s.id} score=${s.score} mode=${s.mode}`, + `project=${s.project ?? "?"} cwd=${s.cwd ?? "?"}`, + s.snippet, + ].join("\n")).join("\n\n"); +} + +export default function (pi: ExtensionAPI) { + pi.registerCommand("breadcrumbs", { + description: "Search prior session outlines: /breadcrumbs ", + handler: async (args, ctx) => ctx.ui?.notify?.(renderSearch(String(args ?? ""), 5), "info"), + }); + + pi.registerTool({ + name: "breadcrumbs_search", + label: "BreadcrumbsSearch", + description: "Search prior Pi session outlines and snippets. Returns outlines only, not full transcripts.", + parameters: Type.Object({ + query: Type.String({ description: "Search query" }), + limit: Type.Optional(Type.Number({ description: "Max results (default 5)" })), + mode: Type.Optional(Type.String({ description: "Search mode: lexical or semantic. Semantic falls back clearly when unavailable." })), + }), + async execute(_id, { query, limit, mode }) { + return { content: [{ type: "text", text: renderSearch(query, Math.min(limit ?? 5, 20), mode) }], details: { mode: mode === "semantic" ? "lexical-fallback" : "lexical" } }; + }, + }); + + pi.registerTool({ + name: "breadcrumbs_read", + label: "BreadcrumbsRead", + description: "Read a bounded chunk from a prior session id/path returned by breadcrumbs_search.", + parameters: Type.Object({ + session: Type.String({ description: "Session id or JSONL path from search" }), + cursor: Type.Optional(Type.Number({ description: "Turn offset (default 0)" })), + maxTurns: Type.Optional(Type.Number({ description: "Turns to read (default 8, max 20)" })), + maxCharacters: Type.Optional(Type.Number({ description: "Character cap (default 8000, hard cap 16000)" })), + includeToolOutput: Type.Optional(Type.Boolean({ description: "Include tool output bodies (default false)" })), + }), + async execute(_id, { session, cursor, maxTurns, maxCharacters, includeToolOutput }): Promise { + const sessions = discoverSessions(); + const found = sessions.find((s) => s.id === session || s.path === session || s.id.endsWith(session)); + if (!found) return { content: [{ type: "text", text: `Error: unknown session ${session}` }], details: {}, isError: true }; + const parsed = parseSessionFile(found.path) ?? found; + const start = Math.max(0, cursor ?? 0); + const count = Math.min(maxTurns ?? 8, 20); + const cap = Math.min(maxCharacters ?? 8000, 16000); + const visibleTurns = parsed.turns.filter((t) => includeToolOutput || (!t.toolName && t.role !== "tool" && t.role !== "tool_result")); + const turns = visibleTurns.slice(start, start + count); + const text = turns.map((t, i) => `${start + i}: ${t.role}${t.toolName ? `:${t.toolName}` : ""}\n${t.text}`).join("\n\n").slice(0, cap); + const next = start + count < visibleTurns.length ? start + count : null; + return { content: [{ type: "text", text: `${text}\n\n[cursor=${start} next=${next} maxTurns=${count} maxCharacters=${cap}]` }], details: { next } }; + }, + }); +} diff --git a/.pi/extensions/browser/index.ts b/.pi/extensions/browser/index.ts index 7d1508cf..6c6db919 100644 --- a/.pi/extensions/browser/index.ts +++ b/.pi/extensions/browser/index.ts @@ -314,8 +314,8 @@ export default function (pi: ExtensionAPI) { pi.registerTool({ name: "enableBrowserTools", label: "EnableBrowserTools", - description: "Load Browser* tools into the active registry on demand. Call this before BrowserNavigate/BrowserExtract when a task needs interactive browsing.", - promptSnippet: "enableBrowserTools(): load BrowserNavigate/BrowserExtract/BrowserClick and related Browser* tools on demand.", + description: "Load Browser* tools into the active registry on demand. Prefer webfetch/websearch for non-interactive retrieval; enable Browser* only for interactive navigation, click, type, extract, history, or scroll workflows.", + promptSnippet: "enableBrowserTools(): load BrowserNavigate/BrowserExtract/BrowserClick and related Browser* tools only for interactive browsing; use webfetch/websearch first for simple retrieval.", parameters: Type.Object({}), async execute() { return { content: [{ type: "text", text: enable() }], details: {} }; diff --git a/.pi/extensions/extra-tools/findread.test.ts b/.pi/extensions/extra-tools/findread.test.ts new file mode 100644 index 00000000..3847bf5d --- /dev/null +++ b/.pi/extensions/extra-tools/findread.test.ts @@ -0,0 +1,48 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import extraTools from "./index.ts"; + +let dir: string; + +function registeredTool(name: string): any { + const tools = new Map(); + const pi: any = { + getAllTools: () => [], + registerCommand: () => {}, + registerTool: (tool: any) => tools.set(tool.name, tool), + }; + extraTools(pi); + return tools.get(name); +} + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "findread-test-")); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "a.txt"), "hello world"); +}); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe("findRead tool", () => { + it("prefixes effective invocation for matches", async () => { + const tool = registeredTool("findRead"); + const result = await tool.execute("id", { pattern: "**/*.txt", path: dir, maxFiles: 3, maxCharacters: 20 }); + const text = result.content[0].text; + expect(text).toContain("findRead invocation:"); + expect(text).toContain('pattern="**/*.txt"'); + expect(text).toContain(`path=${JSON.stringify(dir)}`); + expect(text).toContain("maxFiles=3"); + expect(text).toContain("maxCharacters=20"); + expect(text).toContain("hello world"); + }); + + it("prefixes effective invocation for no matches", async () => { + const tool = registeredTool("findRead"); + const result = await tool.execute("id", { pattern: "**/*.missing", path: dir }); + const text = result.content[0].text; + expect(text).toContain("findRead invocation:"); + expect(text).toContain("No files matched"); + }); +}); diff --git a/.pi/extensions/extra-tools/index.ts b/.pi/extensions/extra-tools/index.ts index b73c1df9..57a537e1 100644 --- a/.pi/extensions/extra-tools/index.ts +++ b/.pi/extensions/extra-tools/index.ts @@ -226,9 +226,19 @@ export default function (pi: ExtensionAPI) { } as any; }, async execute(_id, { pattern, path, maxFiles, maxCharacters, ignoreDefaultExcludes }): Promise { + const base = path || process.cwd(); + const limit = Math.min(maxFiles ?? 5, 50); + const charLimit = maxCharacters ?? 4000; + const invocation = [ + "findRead invocation:", + `pattern=${JSON.stringify(pattern)}`, + `path=${JSON.stringify(base)}`, + `maxFiles=${limit}`, + `maxCharacters=${charLimit}`, + `ignoreDefaultExcludes=${ignoreDefaultExcludes !== false}`, + "", + ].join("\n"); try { - const base = path || process.cwd(); - const limit = Math.min(maxFiles ?? 5, 50); const outcome = await globFiles(pattern, { base, maxMatches: limit, @@ -237,11 +247,10 @@ export default function (pi: ExtensionAPI) { const matches = outcome.matches; if (matches.length === 0) { - return { content: [{ type: "text", text: renderGlobOutcome(outcome) }], details: { filesRead: 0, totalMatched: 0 } }; + return { content: [{ type: "text", text: invocation + renderGlobOutcome(outcome) }], details: { filesRead: 0, totalMatched: 0 } }; } const capped = matches; - const charLimit = maxCharacters ?? 4000; const truncated: string[] = []; const parts: string[] = []; @@ -276,12 +285,12 @@ export default function (pi: ExtensionAPI) { } return { - content: [{ type: "text", text: parts.join("\n\n") + suffix.join("\n") }], + content: [{ type: "text", text: invocation + parts.join("\n\n") + suffix.join("\n") }], details: { filesRead: capped.length, totalMatched: matches.length }, }; } catch (e) { return { - content: [{ type: "text", text: `Error: ${(e as Error).message}` }], + content: [{ type: "text", text: invocation + `Error: ${(e as Error).message}` }], details: { verified: false, applied: 0, skipped: 0, lines: 0 }, isError: true, }; diff --git a/.pi/extensions/memory-context/index.test.ts b/.pi/extensions/memory-context/index.test.ts deleted file mode 100644 index 5a152613..00000000 --- a/.pi/extensions/memory-context/index.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import memoryContextExtension, { candidateFingerprint, candidateReview, duplicateAcceptedCandidate, filterMemoriesByStatus, formatCandidateBody, isExpired, memoryRankScore, prunableMemories, rankMemories, readQueue, scoreCandidate, staleQueueIndexes, supersededMemoryCandidates, turnCandidate, writeAcceptedMemory, writeQueue, type MemoryNote, type QueueItem } from "./index.ts"; - -const oldMemoryContextDir = process.env.MEMORY_CONTEXT_DIR; - -afterEach(() => { - if (oldMemoryContextDir === undefined) delete process.env.MEMORY_CONTEXT_DIR; - else process.env.MEMORY_CONTEXT_DIR = oldMemoryContextDir; -}); - -function withTempMemory(): string { - const dir = mkdtempSync(join(tmpdir(), "lc-memory-context-")); - process.env.MEMORY_CONTEXT_DIR = dir; - return dir; -} - -function candidate(overrides: Partial): QueueItem { - return { - type: "action", - title: "Updated index.ts", - created_at: "2026-01-01T00:00:00.000Z", - updated_at: "2026-01-01T00:00:00.000Z", - source: "test", - confidence: "high", - tags: ["action", "index.ts"], - evidence: { files_edited: ["index.ts"], files_read: [], tests_run: ["npm test"] }, - body: "## Summary\nUpdated index.ts.\n\n## Validation\n- npm test", - ...overrides, - }; -} - -describe("memory candidate formatting", () => { - it("does not emit the old boilerplate follow-up", () => { - const body = formatCandidateBody({ - prompt: "Fix the issue", - outcome: "Fixed a durable convention and validated it.", - edited: ["index.ts"], - read: ["index.ts"], - tests: ["npm test"], - confidence: "high", - type: "observation", - }); - - expect(body).not.toContain("Review for durability before accepting as long-term memory"); - expect(body).not.toContain("## Follow-up"); - }); - - it("emits concrete follow-up only for unresolved validation", () => { - const body = formatCandidateBody({ - prompt: "Fix the issue", - outcome: "Changed behavior but did not validate it.", - edited: ["index.ts"], - read: ["index.ts"], - tests: [], - confidence: "low", - type: "action", - }); - - expect(body).toContain("## Follow-up"); - expect(body).toContain("Run targeted tests before promoting this memory."); - expect(body).toContain("Verify this against source before accepting."); - }); -}); - -describe("turn candidate creation", () => { - it("does not create candidates for turns without edits or tests", () => { - expect(turnCandidate({ - prompt: "Explain the code", - outcome: "Explained the code.", - edited: [], - read: ["index.ts"], - tests: [], - tools: ["read"], - now: "2026-01-01T00:00:00.000Z", - })).toBeNull(); - }); - - it("creates high-confidence durable candidates for edited and validated decisions", () => { - const item = turnCandidate({ - prompt: "Implement memory salience policy", - outcome: "Decided memory-context must use salience before queueing candidates instead of saving every edit.", - edited: [".pi/extensions/memory-context/index.ts"], - read: [".pi/extensions/memory-context/index.ts"], - tests: ["npm test"], - tools: ["read", "edit", "bash"], - now: "2026-01-01T00:00:00.000Z", - }); - - expect(item?.confidence).toBe("high"); - expect(item?.type).toBe("decision"); - expect(item?.title).toContain("Decision:"); - expect(scoreCandidate(item!).reason).toBeUndefined(); - }); -}); - -describe("memory candidate novelty", () => { - it("fingerprints candidates independently of evidence, validation, and file sections", () => { - const first = candidateFingerprint(candidate({ - body: "## Summary\nDecided memory-context must use salience.\n\n## Evidence\n- Prompt: one\n\n## Validation\n- npm test\n\n## Files\nEdited:\n- a.ts", - })); - const second = candidateFingerprint(candidate({ - body: "## Summary\nDecided memory-context must use salience.\n\n## Evidence\n- Prompt: two\n\n## Validation\n- npm run typecheck\n\n## Files\nEdited:\n- b.ts", - })); - - expect(first).toBe(second); - }); - - it("detects duplicate active accepted memories but ignores inactive history", () => { - const item = candidate({ - type: "decision", - title: "Decision: memory-context must use salience", - body: "## Summary\nDecided memory-context must use salience.", - }); - - expect(duplicateAcceptedCandidate(item, [note({ - type: "decision", - title: "Decision: memory-context must use salience", - body: "## Summary\nDecided memory-context must use salience.", - })])).toBe(true); - expect(duplicateAcceptedCandidate(item, [note({ - type: "decision", - title: "Decision: memory-context must use salience", - status: "superseded", - body: "## Summary\nDecided memory-context must use salience.", - })])).toBe(false); - }); -}); - -describe("memory candidate review", () => { - it("explains low-confidence rejection separately from salience", () => { - const item = candidate({ - confidence: "low", - title: "Decision: memory-context must use salience", - type: "decision", - body: "Decided memory-context must use salience before queueing durable memories.", - }); - - const review = candidateReview(item); - - expect(review.accepted).toBe(false); - expect(review.reason).toBe("confidence below medium"); - }); - - it("accepts high-confidence salient candidates", () => { - const review = candidateReview(candidate({ - confidence: "high", - type: "decision", - title: "Decision: memory-context must use salience", - body: "Decided memory-context must use salience before queueing durable memories. This avoids low-impact memory bloat.", - })); - - expect(review.accepted).toBe(true); - expect(review.reason).toBeUndefined(); - }); -}); - -describe("memory candidate scoring", () => { - it("rejects generic update summaries", () => { - const scored = scoreCandidate(candidate({})); - - expect(scored.reason).toBeTruthy(); - expect(scored.salience).toBeLessThan(6); - }); - - it("accepts durable decisions with evidence", () => { - const scored = scoreCandidate(candidate({ - type: "decision", - title: "Memory-context requires salience before queueing", - tags: ["decision", "memory-context"], - body: "## Summary\nDecided memory-context must reject generic edit/test summaries and only queue durable, specific, actionable memories. This avoids low-impact memory bloat.\n\n## Validation\n- npm test", - })); - - expect(scored.reason).toBeUndefined(); - expect(scored.salience).toBeGreaterThanOrEqual(6); - }); -}); - -function note(overrides: Partial): MemoryNote { - return { - path: "/repo/.pi/memory/50-decisions/old.md", - title: "Decision: memory-context queues all validated edits", - type: "decision", - tags: ["decision", "memory-context"], - confidence: "high", - salience: 8, - status: "active", - useCount: 0, - lastUsedAt: "active-day:1", - expiresAt: "", - body: "Memory-context should queue all validated edits.", - ...overrides, - }; -} - -describe("memory supersession", () => { - it("detects active overlapping memories when a new convention replaces them", () => { - const matches = supersededMemoryCandidates(candidate({ - type: "decision", - title: "Decision: memory-context now uses salience instead of edit activity", - tags: ["decision", "memory-context"], - body: "New convention: memory-context now uses salience instead of queueing all validated edits.", - }), [note({}), note({ path: "/repo/other.md", tags: ["other"], body: "Unrelated decision." })]); - - expect(matches).toHaveLength(1); - expect(matches[0].path).toContain("old.md"); - }); - - it("does not supersede without explicit replacement intent or contradiction", () => { - const matches = supersededMemoryCandidates(candidate({ - type: "decision", - title: "Decision: memory-context salience threshold", - tags: ["decision", "memory-context"], - body: "Decided memory-context salience threshold is 6.", - }), [note({})]); - - expect(matches).toHaveLength(0); - }); - - it("detects direct modal contradictions even without replacement wording", () => { - const matches = supersededMemoryCandidates(candidate({ - type: "decision", - title: "Decision: memory-context must not queue generic edits", - tags: ["decision", "memory-context"], - body: "Memory-context must not queue generic edits.", - }), [note({ - title: "Decision: memory-context should queue generic edits", - body: "Memory-context should queue generic edits after validation.", - })]); - - expect(matches).toHaveLength(1); - }); - - it("detects broader action contradictions such as store vs discard", () => { - const matches = supersededMemoryCandidates(candidate({ - type: "decision", - title: "Decision: memory-context discards generic edits", - tags: ["decision", "memory-context"], - body: "Memory-context discards generic edits after scoring.", - }), [note({ - title: "Decision: memory-context stores generic edits", - body: "Memory-context stores generic edits after validation.", - })]); - - expect(matches).toHaveLength(1); - }); -}); - -describe("memory retrieval ranking", () => { - it("ranks durable decisions above generic action notes with similar lexical terms", () => { - const query = "memory-context salience queue behavior"; - const decision = memoryRankScore(query, note({ - type: "decision", - title: "Decision: memory-context requires salience before queueing", - salience: 8, - body: "Memory-context uses salience before queueing candidates.", - })); - const action = memoryRankScore(query, note({ - type: "action", - title: "Updated index.ts", - salience: 8, - body: "Updated memory-context salience queue behavior in index.ts.", - })); - - expect(decision).toBeGreaterThan(action); - }); - - it("boosts frequently used memories without overcoming category quality by itself", () => { - const query = "memory-context salience"; - const unused = memoryRankScore(query, note({ useCount: 0, lastUsedAt: "" })); - const used = memoryRankScore(query, note({ useCount: 8, lastUsedAt: "active-day:1" })); - - expect(used).toBeGreaterThan(unused); - }); -}); - -describe("memory active-day expiration", () => { - it("does not treat active-day expirations as wall-clock dates", () => { - expect(isExpired(note({ lastUsedAt: "", expiresAt: "active-days:30" }))).toBe(false); - }); -}); - -function fakePi() { - const handlers = new Map Promise>(); - const commands = new Map void } }) => Promise>(); - const pi = { - registerCommand: (name: string, command: { handler: (args: string, ctx: { hasUI: boolean; ui: { notify: (message: string, level?: string) => void } }) => Promise }) => commands.set(name, command.handler), - on: (name: string, handler: (event: unknown, ctx?: unknown) => Promise) => handlers.set(name, handler), - }; - memoryContextExtension(pi as never); - return { handlers, commands }; -} - -function captureCtx() { - const messages: string[] = []; - return { messages, ctx: { hasUI: true, ui: { notify: (message: string) => messages.push(message) } } }; -} - -describe("memory hook integration", () => { - it("captures tool activity and queues salient turn-end candidates", async () => { - const dir = withTempMemory(); - const { handlers } = fakePi(); - await handlers.get("before_agent_start")?.({ prompt: "Implement memory salience decision", systemPrompt: "" }, { ui: { notify: () => {} } }); - await handlers.get("tool_call")?.({ toolName: "edit", input: { path: ".pi/extensions/memory-context/index.ts" } }); - await handlers.get("tool_call")?.({ toolName: "bash", input: { command: "npm test" } }); - await handlers.get("turn_end")?.({ - message: { - content: [{ type: "text", text: "Decided memory-context must use salience before queueing durable memories instead of saving every edit." }], - }, - }); - - const queued = readQueue(); - expect(queued).toHaveLength(1); - expect(queued[0].type).toBe("decision"); - expect(queued[0].salience).toBeGreaterThanOrEqual(6); - - rmSync(dir, { recursive: true, force: true }); - }); - - it("does not queue low-value hook candidates", async () => { - const dir = withTempMemory(); - const { handlers } = fakePi(); - await handlers.get("before_agent_start")?.({ prompt: "Update file", systemPrompt: "" }, { ui: { notify: () => {} } }); - await handlers.get("tool_call")?.({ toolName: "edit", input: { path: "index.ts" } }); - await handlers.get("tool_call")?.({ toolName: "bash", input: { command: "npm test" } }); - await handlers.get("turn_end")?.({ message: { content: [{ type: "text", text: "Updated index.ts." }] } }); - - expect(readQueue()).toHaveLength(0); - - rmSync(dir, { recursive: true, force: true }); - }); -}); - -describe("memory command integration", () => { - it("accepts queued candidates into markdown through /memory-review", async () => { - const dir = withTempMemory(); - const { commands } = fakePi(); - const { ctx, messages } = captureCtx(); - const item = candidate({ - type: "decision", - title: "Decision: memory-context must use salience", - confidence: "high", - body: "Decided memory-context must use salience before queueing durable memories.", - }); - writeQueue([{ ...item, salience: scoreCandidate(item).salience }]); - - await commands.get("memory-review")?.("accept 1", ctx); - - expect(readQueue()).toHaveLength(0); - expect(messages[messages.length - 1]).toContain("Accepted 1 memory candidate"); - expect(existsSync(join(dir, "50-decisions"))).toBe(true); - - rmSync(dir, { recursive: true, force: true }); - }); - - it("previews category-pruned memories through /memory-prune --dry-run", async () => { - const dir = withTempMemory(); - const { commands } = fakePi(); - const { ctx, messages } = captureCtx(); - writeAcceptedMemory(candidate({ type: "action", title: "Action: low value", salience: 1, body: "Observed a low-value action." })); - - await commands.get("memory-prune")?.("--dry-run --category action", ctx); - - expect(messages[messages.length - 1]).toContain("category: 40-actions"); - expect(messages[messages.length - 1]).toContain("Would expire 1 accepted memory"); - - rmSync(dir, { recursive: true, force: true }); - }); - - it("clears rejection log through /memory-rejections clear", async () => { - const dir = withTempMemory(); - const { commands } = fakePi(); - const { ctx, messages } = captureCtx(); - - await commands.get("memory-rejections")?.("clear", ctx); - - expect(messages[messages.length - 1]).toContain("Cleared rejected memory candidate log"); - expect(readFileSync(join(dir, "rejections.json"), "utf-8")).toBe("[]\n"); - - rmSync(dir, { recursive: true, force: true }); - }); -}); - -describe("memory filesystem integration", () => { - it("writes queue/state/rejection scaffolding under MEMORY_CONTEXT_DIR", () => { - const dir = withTempMemory(); - writeQueue([candidate({ title: "Decision: memory-context must use salience", type: "decision", body: "Decided memory-context must use salience before queueing memories." })]); - - expect(readQueue()).toHaveLength(1); - expect(existsSync(join(dir, "queue.json"))).toBe(true); - expect(existsSync(join(dir, "state.json"))).toBe(true); - expect(existsSync(join(dir, "rejections.json"))).toBe(true); - - rmSync(dir, { recursive: true, force: true }); - }); - - it("promotes accepted memories into markdown with lifecycle frontmatter", () => { - const dir = withTempMemory(); - const path = writeAcceptedMemory(candidate({ - type: "decision", - title: "Decision: memory-context must use salience", - confidence: "high", - salience: 8, - body: "Decided memory-context must use salience before queueing durable memories.", - })); - const text = readFileSync(path, "utf-8"); - - expect(path).toContain(join(dir, "50-decisions")); - expect(text).toContain('status: "active"'); - expect(text).toContain("salience: 8"); - expect(text).toContain("use_count: 0"); - - rmSync(dir, { recursive: true, force: true }); - }); -}); - -describe("memory prune eval", () => { - it("prunes unused low-salience action memories but preserves used ones", () => { - const unused = note({ path: "/repo/unused.md", type: "action", salience: 1, useCount: 0 }); - const used = note({ path: "/repo/used.md", type: "action", salience: 1, useCount: 2 }); - const decision = note({ path: "/repo/decision.md", type: "decision", salience: 1, useCount: 0 }); - - expect(prunableMemories([unused, used, decision]).map((item) => item.path)).toEqual(["/repo/unused.md"]); - }); - - it("filters prunable memories by category", () => { - const action = note({ path: "/repo/action.md", type: "action", salience: 1, useCount: 0 }); - const session = note({ path: "/repo/session.md", type: "session", salience: 1, useCount: 0 }); - - expect(prunableMemories([action, session], "40-actions").map((item) => item.path)).toEqual(["/repo/action.md"]); - }); - - it("finds stale queued candidates by wall-clock age", () => { - const old = candidate({ created_at: "2026-01-01T00:00:00.000Z" }); - const fresh = candidate({ created_at: "2026-01-20T00:00:00.000Z" }); - const now = Date.parse("2026-01-20T00:00:00.000Z"); - - expect(staleQueueIndexes([old, fresh], now)).toEqual([0]); - }); -}); - -describe("memory list filtering", () => { - it("filters memories by active, superseded, expired, or all status", () => { - const active = note({ path: "/repo/active.md", status: "active" }); - const superseded = note({ path: "/repo/superseded.md", status: "superseded" }); - const expired = note({ path: "/repo/expired.md", status: "expired" }); - const notes = [active, superseded, expired]; - - expect(filterMemoriesByStatus(notes, "active").map((item) => item.path)).toEqual(["/repo/active.md"]); - expect(filterMemoriesByStatus(notes, "superseded").map((item) => item.path)).toEqual(["/repo/superseded.md"]); - expect(filterMemoriesByStatus(notes, "expired").map((item) => item.path)).toEqual(["/repo/expired.md"]); - expect(filterMemoriesByStatus(notes, "all")).toEqual(notes); - }); -}); - -describe("memory read eval", () => { - it("retrieves active durable memories and excludes superseded memories", () => { - const results = rankMemories("memory-context salience queue", [ - note({ - path: "/repo/.pi/memory/50-decisions/new.md", - title: "Decision: memory-context requires salience before queueing", - body: "Memory-context queues only salient durable memories.", - }), - note({ - path: "/repo/.pi/memory/50-decisions/old.md", - title: "Decision: memory-context queues all edits", - status: "superseded", - body: "Memory-context queues all edits.", - }), - note({ - path: "/repo/.pi/memory/40-actions/action.md", - type: "action", - tags: ["action", "other"], - title: "Updated index.ts", - body: "Unrelated action.", - }), - ], 5); - - expect(results.map((result) => result.path)).toEqual(["/repo/.pi/memory/50-decisions/new.md"]); - }); - - it("does not inject weak incidental matches when strong memories exist", () => { - const results = rankMemories("memory-context salience", [ - note({ - path: "/repo/.pi/memory/50-decisions/strong.md", - title: "Decision: memory-context salience policy", - salience: 8, - body: "Memory-context salience controls durable memory writes.", - }), - note({ - path: "/repo/.pi/memory/40-actions/weak.md", - type: "action", - tags: ["action"], - title: "Action with reusable outcome", - salience: 1, - body: "Mentioned memory-context once.", - }), - ], 5); - - expect(results.map((result) => result.path)).toEqual(["/repo/.pi/memory/50-decisions/strong.md"]); - }); -}); diff --git a/.pi/extensions/memory-context/index.ts b/.pi/extensions/memory-context/index.ts deleted file mode 100644 index 989d0180..00000000 --- a/.pi/extensions/memory-context/index.ts +++ /dev/null @@ -1,1046 +0,0 @@ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { basename, join, relative } from "node:path"; -import { promisify } from "node:util"; -import { containsSecret } from "../security/index.ts"; - -const execFileAsync = promisify(execFile); -function memoryDir(): string { - return process.env.MEMORY_CONTEXT_DIR || join(process.cwd(), ".pi", "memory"); -} - -const NOTE_DIRS = ["20-context", "40-actions", "50-decisions", "60-observations", "70-runbooks", "80-sessions"]; -const MAX_INJECT_CHARS = 5000; - -function queuePath(): string { - return join(memoryDir(), "queue.json"); -} - -function rejectionsPath(): string { - return join(memoryDir(), "rejections.json"); -} - -function statePath(): string { - return join(memoryDir(), "state.json"); -} - -export interface MemoryNote { - path: string; - title: string; - type: string; - tags: string[]; - confidence: string; - salience: number; - status: string; - useCount: number; - lastUsedAt: string; - expiresAt: string; - body: string; -} - -export interface QueueItem { - type: string; - title: string; - created_at: string; - updated_at: string; - source: string; - confidence: string; - tags: string[]; - evidence: Record; - body: string; - salience?: number; - rejection_reason?: string; - match_count?: number; - last_matched_at?: string; -} - -const AUTO_PROMOTE_MATCHES = 3; -const AUTO_PROMOTE_SCORE = 4; - -const turn = { - prompt: "", - tools: [] as string[], - filesRead: new Set(), - filesEdited: new Set(), - tests: [] as string[], -}; - -function ensureMemory(): void { - const dir = memoryDir(); - mkdirSync(dir, { recursive: true }); - for (const noteDir of NOTE_DIRS) mkdirSync(join(dir, noteDir), { recursive: true }); - if (!existsSync(queuePath())) writeFileSync(queuePath(), "[]\n"); - if (!existsSync(rejectionsPath())) writeFileSync(rejectionsPath(), "[]\n"); - if (!existsSync(statePath())) writeFileSync(statePath(), `${JSON.stringify({ active_day: 0, last_access_date: "" }, null, 2)}\n`); -} - -function words(text: string): string[] { - return [...new Set(text.toLowerCase().match(/[a-z0-9_.\/-]{3,}/g) ?? [])].slice(0, 80); -} - -function parseFrontmatter(text: string): { meta: Record; body: string } { - if (!text.startsWith("---\n")) return { meta: {}, body: text }; - const end = text.indexOf("\n---\n", 4); - if (end < 0) return { meta: {}, body: text }; - const meta: Record = {}; - for (const line of text.slice(4, end).split("\n")) { - const m = line.match(/^([a-z_]+):\s*(.*)$/i); - if (m) meta[m[1]] = m[2].replace(/^['\"]|['\"]$/g, ""); - } - return { meta, body: text.slice(end + 5).trim() }; -} - -function allNotes(): MemoryNote[] { - ensureMemory(); - const out: MemoryNote[] = []; - for (const dir of NOTE_DIRS) { - const full = join(memoryDir(), dir); - for (const file of readdirSync(full)) { - if (!file.endsWith(".md")) continue; - const path = join(full, file); - const parsed = parseFrontmatter(readFileSync(path, "utf-8")); - out.push({ - path, - title: parsed.meta.title || basename(file, ".md"), - type: parsed.meta.type || dir, - tags: (parsed.meta.tags || "").split(/[, ]+/).filter(Boolean), - confidence: parsed.meta.confidence || "unknown", - salience: Number(parsed.meta.salience ?? 0) || 0, - status: parsed.meta.status || "active", - useCount: Number(parsed.meta.use_count ?? 0) || 0, - lastUsedAt: parsed.meta.last_used_at || "", - expiresAt: parsed.meta.expires_at || "", - body: parsed.body, - }); - } - } - return out; -} - -function lexicalScore(query: string, haystack: string): number { - const terms = words(query); - if (terms.length === 0) return 0; - const hay = haystack.toLowerCase(); - let score = 0; - for (const term of terms) if (hay.includes(term)) score += term.includes("/") || term.includes(".") ? 3 : 1; - return score; -} - -function categoryBoost(type: string): number { - if (type === "decision" || type === "50-decisions") return 6; - if (type === "runbook" || type === "70-runbooks") return 5; - if (type === "observation" || type === "60-observations") return 4; - if (type === "context" || type === "20-context") return 2; - if (type === "action" || type === "40-actions") return -2; - if (type === "session" || type === "80-sessions") return -3; - return 0; -} - -function genericTitlePenalty(title: string): number { - return /^(updated|validated project behavior|captured durable|context for|decision affecting|observation about|runbook for)\b/i.test(title.trim()) ? 5 : 0; -} - -function usageBoost(note: MemoryNote): number { - return Math.min(Math.floor(Math.log2(note.useCount + 1)), 3); -} - -function recencyBoost(note: MemoryNote): number { - const lastUsedDay = activeDayValue(note.lastUsedAt); - if (lastUsedDay <= 0) return 0; - const age = memoryState().active_day - lastUsedDay; - if (age <= 1) return 2; - if (age <= 7) return 1; - return 0; -} - -export function memoryRankScore(query: string, note: MemoryNote): number { - const lexical = lexicalScore(query, `${note.title}\n${note.tags.join(" ")}\n${note.body}`); - if (lexical <= 0) return 0; - const confidenceBoost = note.confidence === "high" ? 2 : note.confidence === "medium" ? 1 : 0; - return lexical + Math.min(note.salience, 10) + confidenceBoost + categoryBoost(note.type) + usageBoost(note) + recencyBoost(note) - genericTitlePenalty(note.title); -} - -export function isExpired(note: MemoryNote): boolean { - if (!note.expiresAt) return false; - const activeExpiry = note.expiresAt.match(/^active-days:(\d+)$/); - if (activeExpiry) { - const lastUsedDay = activeDayValue(note.lastUsedAt); - if (lastUsedDay <= 0) return false; - return memoryState().active_day - lastUsedDay >= Number(activeExpiry[1]); - } - const expires = Date.parse(note.expiresAt); - return Number.isFinite(expires) && expires <= Date.now(); -} - -export function rankMemories(query: string, notes: MemoryNote[], limit = 5): MemoryNote[] { - const scored = notes - .filter((note) => note.status === "active" && !isExpired(note)) - .map((note) => ({ note, score: memoryRankScore(query, note) })) - .filter((x) => x.score > 0) - .sort((a, b) => b.score - a.score); - const strong = scored.filter((x) => x.score >= 6 || (x.note.salience >= 6 && categoryBoost(x.note.type) > 0)); - const selected = strong.length > 0 ? strong : scored; - return selected.slice(0, limit).map((x) => x.note); -} - -function lexicalSearch(query: string, limit = 5): MemoryNote[] { - return rankMemories(query, allNotes(), limit); -} - -function touchMemories(notes: MemoryNote[]): void { - if (notes.length === 0) return; - const activeDay = markMemoryAccess(); - for (const note of notes) upsertFrontmatter(note.path, { use_count: note.useCount + 1, last_used_at: `active-day:${activeDay}` }); -} - -function looksCodebaseIntent(text: string): boolean { - return /\b(file|function|class|symbol|architecture|test|error|refactor|implement|bug|repo|codebase|module|extension|package|issue|PR)\b/i.test(text) - || /[\w.-]+\.(ts|js|py|md|json|tsx|jsx)\b/.test(text) - || text.includes("@"); -} - -async function resolveQmd(): Promise<{ mode: string; bin?: string }> { - const candidates = [process.env.MEMORY_QMD_BIN, process.env.QMD_PATH, join(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? "qmd.cmd" : "qmd"), "qmd"].filter(Boolean) as string[]; - for (const c of candidates) { - try { - await execFileAsync(c, ["--version"], { timeout: 1000 }); - return { mode: "qmd", bin: c }; - } catch (e: any) { - if (e?.code !== "ENOENT" && c !== "qmd") return { mode: "qmd", bin: c }; - } - } - return { mode: "grep fallback" }; -} - -function buildMemoryBlock(notes: MemoryNote[], mode: string): string { - if (notes.length === 0) return ""; - let out = `\n\n## Local Memory Context\nRetrieval mode: ${mode}. Treat these as hints; inspect source before editing or relying on stale facts.\n`; - for (const n of notes) { - const rel = relative(process.cwd(), n.path); - const body = n.body.replace(/\s+/g, " ").slice(0, 450); - out += `- [${n.type}] ${n.title} (${rel}, confidence: ${n.confidence}): ${body}\n`; - } - return out.slice(0, MAX_INJECT_CHARS); -} - -export function readQueue(): QueueItem[] { - ensureMemory(); - try { - const parsed = JSON.parse(readFileSync(queuePath(), "utf-8")); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -export function writeQueue(items: QueueItem[]): void { - writeFileSync(queuePath(), `${JSON.stringify(items, null, 2)}\n`); -} - -interface RejectionItem { - rejected_at: string; - reason: string; - type: string; - title: string; - confidence: string; - salience: number; -} - -function readRejections(): RejectionItem[] { - ensureMemory(); - try { - const parsed = JSON.parse(readFileSync(rejectionsPath(), "utf-8")); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function writeRejections(items: RejectionItem[]): void { - writeFileSync(rejectionsPath(), `${JSON.stringify(items.slice(-100), null, 2)}\n`); -} - -function recordRejection(item: QueueItem, reason: string, salience: number): void { - writeRejections([...readRejections(), { - rejected_at: new Date().toISOString(), - reason, - type: item.type, - title: item.title, - confidence: item.confidence, - salience, - }]); -} - -function noteDirForType(type: string): string { - const normalized = type.toLowerCase(); - if (NOTE_DIRS.includes(normalized)) return normalized; - if (normalized === "action") return "40-actions"; - if (normalized === "decision") return "50-decisions"; - if (normalized === "observation") return "60-observations"; - if (normalized === "runbook") return "70-runbooks"; - if (normalized === "session") return "80-sessions"; - return "20-context"; -} - -function yamlString(value: string): string { - return JSON.stringify(value ?? ""); -} - -function memoryState(): { active_day: number; last_access_date: string } { - ensureMemory(); - try { - const parsed = JSON.parse(readFileSync(statePath(), "utf-8")); - return { active_day: Number(parsed.active_day ?? 0) || 0, last_access_date: String(parsed.last_access_date ?? "") }; - } catch { - return { active_day: 0, last_access_date: "" }; - } -} - -function markMemoryAccess(): number { - const state = memoryState(); - const today = new Date().toISOString().slice(0, 10); - if (state.last_access_date !== today) { - state.active_day += 1; - state.last_access_date = today; - writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`); - } - return state.active_day; -} - -function activeDayValue(value: string): number { - const m = value.match(/^active-day:(\d+)$/); - return m ? Number(m[1]) : 0; -} - -function activeTtl(name: string, fallback: number): number { - const value = Number(process.env[name]); - return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; -} - -function expiryForAccepted(item: QueueItem): string { - const normalized = noteDirForType(item.type); - if (normalized !== "40-actions" && normalized !== "80-sessions") return ""; - const salience = item.salience ?? 0; - if (salience >= 8) return ""; - if (salience >= 6) return `active-days:${activeTtl("MEMORY_CONTEXT_MEDIUM_TTL_ACTIVE_DAYS", 90)}`; - return `active-days:${activeTtl("MEMORY_CONTEXT_LOW_TTL_ACTIVE_DAYS", 30)}`; -} - -function upsertFrontmatter(path: string, updates: Record): void { - const text = readFileSync(path, "utf-8"); - const end = text.startsWith("---\n") ? text.indexOf("\n---\n", 4) : -1; - if (end < 0) return; - const existing = text.slice(4, end).split("\n"); - const keys = new Set(Object.keys(updates)); - const lines = existing.map((line) => { - const m = line.match(/^([a-z_]+):/i); - if (!m || !keys.has(m[1])) return line; - keys.delete(m[1]); - const value = updates[m[1]]; - return `${m[1]}: ${typeof value === "number" ? value : yamlString(value)}`; - }); - for (const key of keys) { - const value = updates[key]; - lines.push(`${key}: ${typeof value === "number" ? value : yamlString(value)}`); - } - writeFileSync(path, `---\n${lines.join("\n")}\n---\n${text.slice(end + 5)}`); -} - -function supersessionIntent(item: QueueItem): boolean { - return /\b(replace[sd]?|supersede[sd]?|instead of|no longer|now use|new convention|changed convention|deprecated|obsolete)\b/i.test(`${item.title}\n${item.body}`); -} - -function contradictionIntent(item: QueueItem, note: MemoryNote): boolean { - const newer = `${item.title}\n${item.body}`.toLowerCase(); - const older = `${note.title}\n${note.body}`.toLowerCase(); - const negative = /\b(must not|should not|do not|don't|never|avoid|disable|reject|deny|skip|ignore|forbid|exclude)\b/; - const positive = /\b(must|should|always|use|enable|accept|allow|queue|save|include|require)\b/; - const newerNegative = negative.test(newer); - const olderPositive = positive.test(older) && !negative.test(older); - const newerPositive = positive.test(newer) && !newerNegative; - const olderNegative = negative.test(older); - if ((newerNegative && olderPositive) || (newerPositive && olderNegative)) return true; - const pairs: Array<[RegExp, RegExp]> = [ - [/\b(queue|queues|save|saves|persist|persists|store|stores)\b/, /\b(drop|drops|discard|discards|reject|rejects|ignore|ignores|skip|skips)\b/], - [/\b(enable|allow|include|use)\b/, /\b(disable|deny|exclude|avoid)\b/], - [/\b(auto-?promote|promote)\b/, /\b(manual review|require review|do not promote|never promote)\b/], - ]; - return pairs.some(([a, b]) => (a.test(newer) && b.test(older)) || (b.test(newer) && a.test(older))); -} - -function overlappingMemory(item: QueueItem, note: MemoryNote): boolean { - if (note.status !== "active" || item.type !== note.type) return false; - const itemTags = new Set((item.tags ?? []).map((tag) => tag.toLowerCase()).filter((tag) => !["action", "context", "decision", "observation", "runbook", "session"].includes(tag))); - const noteTags = note.tags.map((tag) => tag.toLowerCase()); - if (noteTags.some((tag) => itemTags.has(tag))) return true; - const evidence = item.evidence ?? {}; - const paths = [ - ...((evidence.files_edited as string[] | undefined) ?? []), - ...((evidence.files_read as string[] | undefined) ?? []), - ].map((p) => basename(p).toLowerCase()); - const noteText = `${note.title}\n${note.tags.join(" ")}\n${note.body}`.toLowerCase(); - return paths.some((p) => p.length >= 3 && noteText.includes(p)); -} - -export function supersededMemoryCandidates(item: QueueItem, notes: MemoryNote[]): MemoryNote[] { - return notes.filter((note) => overlappingMemory(item, note) && (supersessionIntent(item) || contradictionIntent(item, note))).slice(0, 5); -} - -export function writeAcceptedMemory(item: QueueItem): string { - ensureMemory(); - const superseded = supersededMemoryCandidates(item, allNotes()); - const dir = join(memoryDir(), noteDirForType(item.type)); - let path = join(dir, `${randomUUID()}.md`); - while (existsSync(path)) path = join(dir, `${randomUUID()}.md`); - const expiresAt = expiryForAccepted(item); - const supersedes = superseded.map((note) => relative(process.cwd(), note.path)).join(", "); - const frontmatter = [ - "---", - `title: ${yamlString(item.title)}`, - `type: ${yamlString(item.type)}`, - `tags: ${yamlString((item.tags ?? []).join(", "))}`, - `confidence: ${yamlString(item.confidence || "unknown")}`, - `salience: ${Number(item.salience ?? 0) || 0}`, - `status: ${yamlString("active")}`, - `use_count: 0`, - `last_used_at: ${yamlString(`active-day:${markMemoryAccess()}`)}`, - `expires_at: ${yamlString(expiresAt)}`, - `supersedes: ${yamlString(supersedes)}`, - `source: ${yamlString(item.source || "memory-review")}`, - `created_at: ${yamlString(item.created_at || new Date().toISOString())}`, - `updated_at: ${yamlString(new Date().toISOString())}`, - "---", - "", - ].join("\n"); - writeFileSync(path, `${frontmatter}${item.body.trim()}\n`); - const now = new Date().toISOString(); - for (const note of superseded) upsertFrontmatter(note.path, { status: "superseded", updated_at: now }); - return path; -} - -function parseSelection(args: string, total: number): number[] { - const trimmed = args.trim(); - if (!trimmed || trimmed === "all") return Array.from({ length: total }, (_, i) => i); - const selected = new Set(); - for (const part of trimmed.split(/[\s,]+/).filter(Boolean)) { - const range = part.match(/^(\d+)-(\d+)$/); - if (range) { - const start = Number(range[1]); - const end = Number(range[2]); - for (let n = Math.min(start, end); n <= Math.max(start, end); n += 1) if (n >= 1 && n <= total) selected.add(n - 1); - continue; - } - const n = Number(part); - if (Number.isInteger(n) && n >= 1 && n <= total) selected.add(n - 1); - } - return [...selected].sort((a, b) => a - b); -} - -export function scoreCandidate(item: QueueItem): { salience: number; reason?: string } { - const body = String(item.body ?? ""); - const title = String(item.title ?? ""); - const evidence = item.evidence ?? {}; - const files = [ - ...((evidence.files_edited as string[] | undefined) ?? []), - ...((evidence.files_read as string[] | undefined) ?? []), - ]; - const tests = (evidence.tests_run as string[] | undefined) ?? []; - const text = `${title}\n${item.tags?.join(" ") ?? ""}\n${body}`.toLowerCase(); - if (/^updated\s+[^\n]+$/i.test(title.trim()) && !/decision|decided|root cause|gotcha|preference|convention|must|should|avoid|use `/i.test(body)) { - return { salience: 0, reason: "generic update summary" }; - } - if (/^(validated project behavior|captured durable context)$/i.test(title.trim())) { - return { salience: 0, reason: "generic title" }; - } - if (/review for durability before accepting as long-term memory/i.test(body) && body.replace(/review for durability before accepting as long-term memory/ig, "").length < 400) { - return { salience: 0, reason: "boilerplate-only candidate" }; - } - let score = 0; - if (/\b(decided|decision|prefer|instead|convention|policy|must|should|never|always)\b/i.test(text)) score += 3; - if (/\b(root cause|because|gotcha|observed|found|bug|fix|avoid|fallback)\b/i.test(text)) score += 2; - if (/\b(runbook|steps?|command|usage|how to)\b/i.test(text)) score += 2; - if (files.some((p) => /[/.]/.test(p))) score += 1; - if (tests.length > 0) score += 1; - if (item.confidence === "high") score += 2; - else if (item.confidence === "medium") score += 1; - if ((item.type === "decision" || item.type === "runbook" || item.type === "observation") && !genericTitlePenalty(title)) score += 2; - if (genericTitlePenalty(title)) score -= 3; - if (item.type === "action" || item.type === "session") score -= 1; - return score >= 6 ? { salience: Math.min(score, 10) } : { salience: Math.max(score, 0), reason: `low salience (${score}/10)` }; -} - -export function candidateReview(item: QueueItem): { accepted: boolean; salience: number; reason?: string; unsafe: boolean } { - const confidence = String(item.confidence ?? "").toLowerCase(); - const body = String(item.body ?? "").trim(); - const scored = scoreCandidate({ ...item, confidence, body }); - const candidate = { ...item, confidence, salience: scored.salience, rejection_reason: scored.reason, body: body.length > 2400 ? body.slice(0, 2400) : body }; - if (containsSecret(candidate)) return { accepted: false, salience: scored.salience, reason: "unsafe content", unsafe: true }; - if (confidence !== "high" && confidence !== "medium") return { accepted: false, salience: scored.salience, reason: "confidence below medium", unsafe: false }; - if (!body) return { accepted: false, salience: scored.salience, reason: "empty body", unsafe: false }; - if (scored.reason) return { accepted: false, salience: scored.salience, reason: scored.reason, unsafe: false }; - return { accepted: true, salience: scored.salience, unsafe: false }; -} - -function validateCandidate(item: QueueItem): { candidate: QueueItem | null; unsafe: boolean } { - const confidence = String(item.confidence ?? "").toLowerCase(); - const body = String(item.body ?? "").trim(); - const review = candidateReview({ ...item, confidence, body }); - const candidate = { ...item, confidence, salience: review.salience, rejection_reason: review.reason, body: body.length > 2400 ? body.slice(0, 2400) : body }; - if (!review.accepted) return { candidate: null, unsafe: review.unsafe }; - return { candidate, unsafe: false }; -} - -export function candidateFingerprint(item: Pick): string { - const body = String(item.body ?? "") - .split(/(?=^## )/m) - .filter((section) => !/^## (Evidence|Validation|Files)\b/i.test(section.trim())) - .join("\n") - .toLowerCase() - .replace(/\s+/g, " ") - .trim(); - const title = String(item.title ?? "").toLowerCase().replace(/\s+/g, " ").trim(); - return `${item.type}\n${title}\n${body}`; -} - -function duplicateQueuedCandidate(item: QueueItem, queue: QueueItem[]): boolean { - const fingerprint = candidateFingerprint(item); - return queue.some((queued) => candidateFingerprint(queued) === fingerprint); -} - -export function duplicateAcceptedCandidate(item: QueueItem, notes = allNotes()): boolean { - const fingerprint = candidateFingerprint(item); - return notes.some((note) => note.status === "active" && candidateFingerprint({ type: note.type, title: note.title, body: note.body }) === fingerprint); -} - -function queueCandidate(item: QueueItem): void { - if (process.env.MEMORY_LEARNING === "off") return; - const { candidate, unsafe } = validateCandidate(item); - if (!candidate) { - const review = candidateReview(item); - recordRejection(item, unsafe ? "unsafe content" : review.reason ?? "invalid candidate", review.salience); - return; - } - const queue = readQueue(); - if (duplicateQueuedCandidate(candidate, queue) || duplicateAcceptedCandidate(candidate)) { - recordRejection(candidate, "duplicate candidate", candidate.salience ?? 0); - return; - } - queue.push({ ...candidate, match_count: candidate.match_count ?? 0 }); - writeQueue(queue.slice(-200)); -} - -function durableMatchText(item: QueueItem): string { - const evidence = item.evidence ?? {}; - const fileTerms = [ - ...((evidence.files_edited as string[] | undefined) ?? []), - ...((evidence.files_read as string[] | undefined) ?? []), - ].map((p) => basename(p)).join(" "); - const body = item.body - .split("\n") - .filter((line) => !/^#{1,3}\s+(summary|evidence|validation|files|follow-up)\b/i.test(line.trim())) - .filter((line) => !/^[-*]\s+(confidence|prompt|review for durability)\b/i.test(line.trim())) - .filter((line) => !/^(prompt|outcome|validation):/i.test(line.trim())) - .join("\n"); - return `${item.title}\n${item.tags?.join(" ") ?? ""}\n${fileTerms}\n${body}`; -} - -function hasSpecificMatch(query: string, item: QueueItem): boolean { - const queryLower = query.toLowerCase(); - const hay = durableMatchText(item).toLowerCase(); - const evidence = item.evidence ?? {}; - const paths = [ - ...((evidence.files_edited as string[] | undefined) ?? []), - ...((evidence.files_read as string[] | undefined) ?? []), - ]; - const genericTags = new Set(["action", "context", "decision", "observation", "runbook", "session"]); - const specificTerms = [...(item.tags ?? []).filter((tag) => !genericTags.has(tag.toLowerCase())), ...paths.flatMap((p) => [p, basename(p)])] - .map((term) => term.toLowerCase()) - .filter((term) => term.length >= 3); - if (specificTerms.some((term) => queryLower.includes(term))) return true; - return words(query).some((term) => (term.includes("/") || term.includes(".")) && hay.includes(term)); -} - -function trackShortTermMatches(query: string): string[] { - const queue = readQueue(); - if (queue.length === 0) return []; - const now = new Date().toISOString(); - const promoted: string[] = []; - const remaining: QueueItem[] = []; - let changed = false; - for (const item of queue) { - const score = lexicalScore(query, durableMatchText(item)); - if (score >= AUTO_PROMOTE_SCORE && hasSpecificMatch(query, item)) { - item.match_count = (item.match_count ?? 0) + 1; - item.last_matched_at = now; - changed = true; - } - if ((item.match_count ?? 0) >= AUTO_PROMOTE_MATCHES) { - const { candidate, unsafe } = validateCandidate(item); - if (candidate) { - if (duplicateAcceptedCandidate(candidate)) { - recordRejection(candidate, "duplicate candidate", candidate.salience ?? 0); - } else { - const path = writeAcceptedMemory({ ...candidate, source: `${candidate.source || "memory-context"}; auto-promoted after ${candidate.match_count} matches` }); - promoted.push(path); - } - } else if (!unsafe) { - remaining.push({ ...item, match_count: AUTO_PROMOTE_MATCHES - 1 }); - } - changed = true; - } else { - remaining.push(item); - } - } - if (promoted.length > 0) dedupeMemories(false); - if (changed || promoted.length > 0) writeQueue(remaining); - return promoted; -} - -function finalText(message: any): string { - const content = Array.isArray(message?.content) ? message.content : []; - return content.filter((c: any) => c?.type === "text").map((c: any) => c.text ?? "").join("\n").trim(); -} - -function commandText(input: any): string { - return String(input?.command ?? input?.cmd ?? ""); -} - -function isTestCommand(cmd: string): boolean { - return /\b(npm test|vitest|pytest|cargo test|go test|pnpm test|yarn test|npm run test|npm run typecheck|tsc\b)/.test(cmd); -} - -export function candidateType(prompt: string, outcome: string, edited: string[], tests: string[]): string { - const text = `${prompt}\n${outcome}`.toLowerCase(); - if (/\b(runbook|playbook|procedure|how to|steps?|usage|command)\b/.test(text)) return "runbook"; - if (/\b(decided|decision|choose|chose|prefer|instead|authoritative|policy|convention|should be)\b/.test(text)) return "decision"; - if (/\b(observed|observation|found|root cause|because|why|note|gotcha)\b/.test(text)) return "observation"; - if (edited.length > 0 || tests.length > 0) return "action"; - return "context"; -} - -function titleFragment(text: string): string { - const cleaned = text - .replace(/[`*_#>]/g, "") - .replace(/\s+/g, " ") - .split(/[.!?]\s/)[0] - .trim(); - return cleaned.length > 90 ? `${cleaned.slice(0, 87)}...` : cleaned; -} - -export function candidateTitle(type: string, edited: string[], prompt = "", outcome = ""): string { - const files = edited.map((p) => basename(p)).slice(0, 2).join(", "); - const source = titleFragment(outcome) || titleFragment(prompt); - if (source && !/^(done|implemented|fixed|updated|validated|changed)$/i.test(source)) { - const prefix = type === "decision" ? "Decision" : type === "observation" ? "Observation" : type === "runbook" ? "Runbook" : type === "action" ? "Action" : "Context"; - return files ? `${prefix}: ${source} (${files})` : `${prefix}: ${source}`; - } - if (type === "action") return files ? `Action for ${files}` : "Action with reusable outcome"; - if (type === "decision") return files ? `Decision for ${files}` : "Durable decision"; - if (type === "observation") return files ? `Observation for ${files}` : "Durable observation"; - if (type === "runbook") return files ? `Runbook for ${files}` : "Reusable runbook"; - return files ? `Context for ${files}` : "Durable context"; -} - -export function candidateFollowUp(args: { edited: string[]; tests: string[]; confidence: string; type?: string }): string[] { - const out: string[] = []; - if (args.edited.length > 0 && args.tests.length === 0) out.push("Run targeted tests before promoting this memory."); - if (args.confidence === "low") out.push("Verify this against source before accepting."); - if (args.type === "decision" && args.edited.every((p) => !/docs?\//.test(p))) out.push("Consider documenting this decision in project docs if it is policy-level."); - return out; -} - -export function turnCandidate(args: { prompt: string; outcome: string; edited: string[]; read: string[]; tests: string[]; tools: string[]; now?: string }): QueueItem | null { - if (!args.outcome || (args.edited.length === 0 && args.tests.length === 0)) return null; - const now = args.now ?? new Date().toISOString(); - const confidence = args.edited.length > 0 && args.tests.length > 0 ? "high" : args.tests.length > 0 ? "medium" : "low"; - const type = candidateType(args.prompt, args.outcome, args.edited, args.tests); - return { - type, - title: candidateTitle(type, args.edited, args.prompt, args.outcome), - created_at: now, - updated_at: now, - source: "memory-context deterministic turn_end", - confidence, - tags: [type, ...args.edited.map((p) => basename(p)).slice(0, 5)], - evidence: { prompt: args.prompt.slice(0, 500), tools: [...new Set(args.tools)], files_edited: args.edited, files_read: args.read, tests_run: args.tests }, - body: formatCandidateBody({ prompt: args.prompt, outcome: args.outcome, edited: args.edited, read: args.read, tests: args.tests, confidence, type }), - }; -} - -export function formatCandidateBody(args: { prompt: string; outcome: string; edited: string[]; read: string[]; tests: string[]; confidence: string; type?: string }): string { - const edited = args.edited.length ? args.edited.map((p) => `- ${p}`).join("\n") : "- none"; - const read = args.read.length ? args.read.slice(0, 10).map((p) => `- ${p}`).join("\n") : "- none recorded"; - const validation = args.tests.length ? args.tests.map((cmd) => `- ${cmd}`).join("\n") : "- not run"; - const summary = args.outcome.replace(/\s+/g, " ").slice(0, 700); - const prompt = args.prompt.replace(/\s+/g, " ").slice(0, 350); - const sections = [ - `## Summary\n${summary}`, - `## Evidence\n- Prompt: ${prompt}\n- Confidence: ${args.confidence}`, - `## Validation\n${validation}`, - `## Files\nEdited:\n${edited}\n\nRead:\n${read}`, - ]; - const followUp = candidateFollowUp(args); - if (followUp.length > 0) sections.push(`## Follow-up\n${followUp.map((line) => `- ${line}`).join("\n")}`); - return sections.join("\n\n"); -} - -function dedupeKey(note: MemoryNote): string { - const normalizedBody = note.body.toLowerCase().replace(/\s+/g, " ").trim(); - const normalizedTitle = note.title.toLowerCase().replace(/\s+/g, " ").trim(); - return `${note.type}\n${normalizedTitle}\n${normalizedBody}`; -} - -function dedupeMemories(dryRun: boolean): string[] { - const seen = new Set(); - const removed: string[] = []; - for (const note of allNotes().sort((a, b) => a.path.localeCompare(b.path))) { - const key = dedupeKey(note); - if (!seen.has(key)) { - seen.add(key); - continue; - } - removed.push(note.path); - if (!dryRun) unlinkSync(note.path); - } - return removed; -} - -export function staleQueueIndexes(items: QueueItem[], now = Date.now()): number[] { - const maxAgeMs = 14 * 24 * 60 * 60 * 1000; - const out: number[] = []; - items.forEach((item, index) => { - const created = Date.parse(item.created_at); - if (Number.isFinite(created) && now - created > maxAgeMs) out.push(index); - }); - return out; -} - -export function prunableMemories(notes = allNotes(), category?: string): MemoryNote[] { - return notes.filter((note) => { - if (category && note.type !== category && noteDirForType(note.type) !== category) return false; - return note.status === "active" && (isExpired(note) || ((note.type === "action" || note.type === "session") && note.salience > 0 && note.salience <= 2 && note.useCount === 0)); - }); -} - -function pruneMemories(dryRun: boolean, category?: string): { expired: MemoryNote[]; staleQueue: number } { - const expired = prunableMemories(allNotes(), category); - if (!dryRun) { - const now = new Date().toISOString(); - for (const note of expired) upsertFrontmatter(note.path, { status: "expired", updated_at: now }); - const queue = readQueue(); - const stale = new Set(staleQueueIndexes(queue)); - if (stale.size > 0) writeQueue(queue.filter((_item, index) => !stale.has(index))); - return { expired, staleQueue: stale.size }; - } - return { expired, staleQueue: staleQueueIndexes(readQueue()).length }; -} - -function categoryArg(args: string | undefined): string | undefined { - const raw = (args ?? "").match(/--category\s+([a-z0-9_-]+)/i)?.[1]; - if (!raw) return undefined; - const normalized = raw.toLowerCase(); - return NOTE_DIRS.includes(normalized) ? normalized : noteDirForType(normalized); -} - -function sortedNotes(): MemoryNote[] { - return allNotes().sort((a, b) => a.path.localeCompare(b.path)); -} - -export function filterMemoriesByStatus(notes: MemoryNote[], status?: string): MemoryNote[] { - if (!status || status === "all") return notes; - return notes.filter((note) => note.status === status || (status === "expired" && isExpired(note))); -} - -function resolveMemoryRef(ref: string, notes: MemoryNote[]): MemoryNote | null { - const n = Number(ref); - if (Number.isInteger(n) && n >= 1 && n <= notes.length) return notes[n - 1]; - const byPath = notes.find((note) => note.path === ref || relative(process.cwd(), note.path) === ref || note.path.endsWith(ref)); - return byPath ?? null; -} - -function supersedeMemory(newer: MemoryNote, older: MemoryNote): void { - const now = new Date().toISOString(); - upsertFrontmatter(newer.path, { supersedes: relative(process.cwd(), older.path), updated_at: now }); - upsertFrontmatter(older.path, { status: "superseded", updated_at: now }); -} - -export default function (pi: ExtensionAPI) { - pi.registerCommand("memory-review", { - description: "Show queued local memory candidates; use /memory-review explain|accept|deny [all|1,3|2-4]; accept supports --force for duplicates", - handler: async (args, ctx) => { - const argText = args?.trim() ?? ""; - const dedupedBefore = dedupeMemories(false); - const queue = readQueue(); - if (argText.startsWith("explain")) { - const selected = parseSelection(argText.slice("explain".length), queue.length); - if (selected.length === 0) { - if (ctx.hasUI) ctx.ui.notify("Usage: /memory-review explain ", "warning"); - return; - } - const notes = allNotes(); - const lines = selected.map((index) => { - const item = queue[index]; - const review = candidateReview(item); - const duplicate = duplicateAcceptedCandidate(item, notes); - const supersedes = supersededMemoryCandidates(item, notes); - return [ - `${index + 1}. [${item.type}] ${item.title}`, - `status: ${review.accepted && !duplicate ? "accepted by current filter" : "would reject"}`, - `salience: ${review.salience}/10`, - `reason: ${duplicate ? "duplicate candidate" : review.reason ?? "passes current salience filter"}`, - `confidence: ${item.confidence}`, - `fingerprint: ${candidateFingerprint(item).slice(0, 180)}`, - `would supersede: ${supersedes.length ? supersedes.map((note) => relative(process.cwd(), note.path)).join(", ") : "none"}`, - ].join("\n"); - }).join("\n\n"); - if (ctx.hasUI) ctx.ui.notify(lines, "info"); - return; - } - if (argText.startsWith("accept")) { - const force = /(^|\s)--force(\s|$)/.test(argText); - const selected = parseSelection(argText.slice("accept".length).replace(/(^|\s)--force(\s|$)/g, " "), queue.length); - if (selected.length === 0) { - if (ctx.hasUI) ctx.ui.notify("No queued memory candidates matched that selection.", "warning"); - return; - } - const removeSet = new Set(); - const written: string[] = []; - let rejected = 0; - let unsafeRejected = 0; - for (const index of selected) { - const { candidate, unsafe } = validateCandidate(queue[index]); - if (!candidate) { - rejected += 1; - if (unsafe) { - unsafeRejected += 1; - removeSet.add(index); - } - continue; - } - if (!force && duplicateAcceptedCandidate(candidate)) { - rejected += 1; - recordRejection(candidate, "duplicate candidate", candidate.salience ?? 0); - removeSet.add(index); - continue; - } - written.push(writeAcceptedMemory(candidate)); - removeSet.add(index); - } - writeQueue(queue.filter((_item, index) => !removeSet.has(index))); - const dedupedAfter = dedupeMemories(false); - const rejectedText = rejected ? `; rejected ${rejected} unsafe/invalid candidate(s)${unsafeRejected ? ` and removed ${unsafeRejected} unsafe` : "; invalid candidates remain queued for review/deny"}` : ""; - if (ctx.hasUI) ctx.ui.notify(`Accepted ${written.length} memory candidate(s)${rejectedText}:\n${written.map((path) => relative(process.cwd(), path)).join("\n")}${dedupedAfter.length ? `\nDeduplicated ${dedupedAfter.length} long-term memor${dedupedAfter.length === 1 ? "y" : "ies"}.` : ""}`, "info"); - return; - } - if (argText.startsWith("deny")) { - const selected = parseSelection(argText.slice("deny".length), queue.length); - if (selected.length === 0) { - if (ctx.hasUI) ctx.ui.notify("No queued memory candidates matched that selection.", "warning"); - return; - } - const selectedSet = new Set(selected); - writeQueue(queue.filter((_item, index) => !selectedSet.has(index))); - if (ctx.hasUI) ctx.ui.notify(`Denied ${selected.length} memory candidate(s).`, "info"); - return; - } - const lines = queue.length === 0 ? "No queued memory candidates." : queue.map((item, i) => { - const currentReview = candidateReview(item); - const matches = item.match_count ? `, matches: ${item.match_count}/${AUTO_PROMOTE_MATCHES}` : ""; - const salience = `, salience: ${item.salience ?? currentReview.salience}/10`; - const review = currentReview.accepted ? "\n Review: passes current salience filter" : `\n Review: ${currentReview.reason ?? "invalid candidate"}`; - return `${i + 1}. [${item.type}] ${item.title} (${item.confidence}${salience}${matches})${review}\n${item.body.trim().split("\n").map((line) => ` ${line}`).join("\n")}`; - }).join("\n\n"); - const prefix = dedupedBefore.length ? `Deduplicated ${dedupedBefore.length} long-term memor${dedupedBefore.length === 1 ? "y" : "ies"}.\n\n` : ""; - if (ctx.hasUI) ctx.ui.notify(`${prefix}${lines}`, "info"); - }, - }); - - pi.registerCommand("memory-doctor", { - description: "Show local memory health, including whether QMD is available", - handler: async (args, ctx) => { - ensureMemory(); - const verbose = /(^|\s)--verbose(\s|$)/.test(args ?? ""); - const qmd = await resolveQmd(); - const notes = allNotes(); - const queue = readQueue(); - const rejections = readRejections(); - const dirs = NOTE_DIRS.map((dir) => { - const full = join(memoryDir(), dir); - return `${dir}: ${readdirSync(full).filter((file) => file.endsWith(".md")).length} accepted`; - }).join("\n"); - const active = notes.filter((note) => note.status === "active" && !isExpired(note)).length; - const expired = notes.filter((note) => note.status === "expired" || isExpired(note)).length; - const inactive = notes.length - active; - const averageSalience = notes.length ? (notes.reduce((sum, note) => sum + note.salience, 0) / notes.length).toFixed(1) : "0.0"; - const staleQueue = staleQueueIndexes(queue).length; - const pruneCount = prunableMemories(notes).length; - const rejectionCounts = rejections.reduce((counts, item) => counts.set(item.reason, (counts.get(item.reason) ?? 0) + 1), new Map()); - const topRejections = [...rejectionCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([reason, count]) => `${reason}: ${count}`).join(", ") || "none"; - const genericOffenders = notes.filter((note) => note.status === "active" && genericTitlePenalty(note.title) > 0).slice(0, 5); - const genericLine = genericOffenders.length === 0 ? "none" : genericOffenders.map((note) => relative(process.cwd(), note.path)).join(", "); - const lines = [ - `memory dir: ${relative(process.cwd(), memoryDir())}`, - `QMD: ${qmd.mode === "qmd" ? `loaded (${qmd.bin ?? "qmd"})` : "not loaded; using grep fallback"}`, - `queue: ${queue.length} pending candidate(s), ${staleQueue} stale`, - `accepted memories: ${notes.length} (${active} active, ${inactive} inactive, ${expired} expired)`, - `average salience: ${averageSalience}/10`, - `prunable accepted memories: ${pruneCount}`, - `recent rejected candidates: ${rejections.length}; top reasons: ${topRejections}`, - `generic-title offenders: ${genericLine}`, - dirs, - verbose ? `\nverbose\nlowest salience active:\n${notes.filter((note) => note.status === "active").sort((a, b) => a.salience - b.salience).slice(0, 5).map((note) => `- ${relative(process.cwd(), note.path)} (${note.salience}/10, uses: ${note.useCount})`).join("\n") || "none"}` : "", - ].filter(Boolean).join("\n"); - if (ctx.hasUI) ctx.ui.notify(lines, qmd.mode === "qmd" ? "info" : "warning"); - }, - }); - - pi.registerCommand("memory-rejections", { - description: "Show recently rejected local memory candidates; use /memory-rejections clear to reset", - handler: async (args, ctx) => { - if ((args ?? "").trim() === "clear") { - writeRejections([]); - if (ctx.hasUI) ctx.ui.notify("Cleared rejected memory candidate log.", "info"); - return; - } - const rejections = readRejections().slice(-20).reverse(); - const lines = rejections.length === 0 ? "No recent rejected memory candidates." : rejections.map((item, i) => `${i + 1}. [${item.type}] ${item.title} (${item.confidence}, salience: ${item.salience}/10)\n ${item.reason}\n ${item.rejected_at}`).join("\n\n"); - if (ctx.hasUI) ctx.ui.notify(lines, "info"); - }, - }); - - pi.registerCommand("memory-prune", { - description: "Expire stale/low-value memories and remove stale queued candidates; use /memory-prune --dry-run [--category action|session] to preview", - handler: async (args, ctx) => { - const dryRun = /(^|\s)--dry-run(\s|$)/.test(args ?? ""); - const category = categoryArg(args); - const result = pruneMemories(dryRun, category); - const verb = dryRun ? "Would expire" : "Expired"; - const memoryLines = result.expired.length === 0 - ? "No accepted memories matched prune rules." - : `${verb} ${result.expired.length} accepted memor${result.expired.length === 1 ? "y" : "ies"}:\n${result.expired.map((note) => `- ${relative(process.cwd(), note.path)} [${note.type}] ${note.title}`).join("\n")}`; - const queueLine = dryRun - ? `Would remove ${result.staleQueue} stale queued candidate(s).` - : `Removed ${result.staleQueue} stale queued candidate(s).`; - const categoryLine = category ? `category: ${category}\n` : ""; - if (ctx.hasUI) ctx.ui.notify(`${categoryLine}${memoryLines}\n${queueLine}`, "info"); - }, - }); - - pi.registerCommand("memory-dedupe", { - description: "Remove duplicate accepted local memories; use /memory-dedupe --dry-run to preview", - handler: async (args, ctx) => { - const dryRun = /(^|\s)--dry-run(\s|$)/.test(args ?? ""); - const removed = dedupeMemories(dryRun); - const verb = dryRun ? "Would remove" : "Removed"; - const lines = removed.length === 0 - ? "No duplicate accepted memories found." - : `${verb} ${removed.length} duplicate accepted memor${removed.length === 1 ? "y" : "ies"}:\n${removed.map((path) => relative(process.cwd(), path)).join("\n")}`; - if (ctx.hasUI) ctx.ui.notify(lines, "info"); - }, - }); - - pi.registerCommand("memory-list", { - description: "List accepted local memories; use /memory-list --status active|expired|superseded", - handler: async (args, ctx) => { - const status = (args ?? "").match(/--status\s+(active|expired|superseded|all)\b/)?.[1]; - const notes = filterMemoriesByStatus(sortedNotes(), status); - const lines = notes.length === 0 ? "No accepted memories matched." : notes.map((note, i) => `${i + 1}. ${relative(process.cwd(), note.path)}\n [${note.type}] ${note.title} (${note.confidence}, status: ${isExpired(note) && note.status === "active" ? "expired" : note.status}, salience: ${note.salience}/10, uses: ${note.useCount})`).join("\n"); - if (ctx.hasUI) ctx.ui.notify(lines, "info"); - }, - }); - - pi.registerCommand("memory-supersede", { - description: "Mark an older memory superseded by a newer memory; usage: /memory-supersede ", - handler: async (args, ctx) => { - const [newRef, oldRef] = (args ?? "").trim().split(/\s+/).filter(Boolean); - if (!newRef || !oldRef) { - if (ctx.hasUI) ctx.ui.notify("Usage: /memory-supersede ", "warning"); - return; - } - const notes = sortedNotes(); - const newer = resolveMemoryRef(newRef, notes); - const older = resolveMemoryRef(oldRef, notes); - if (!newer || !older) { - if (ctx.hasUI) ctx.ui.notify("Could not resolve one or both memory references. Use /memory-list for indexes.", "warning"); - return; - } - if (newer.path === older.path) { - if (ctx.hasUI) ctx.ui.notify("A memory cannot supersede itself.", "warning"); - return; - } - supersedeMemory(newer, older); - if (ctx.hasUI) ctx.ui.notify(`${relative(process.cwd(), newer.path)} now supersedes ${relative(process.cwd(), older.path)}.`, "info"); - }, - }); - - pi.registerCommand("memory-search", { - description: "Search accepted local memories", - handler: async (args, ctx) => { - const query = args?.trim() ?? ""; - if (!query) { - if (ctx.hasUI) ctx.ui.notify("Usage: /memory-search ", "warning"); - return; - } - const notes = lexicalSearch(query, 10); - touchMemories(notes); - const lines = notes.length === 0 ? "No matching memories." : notes.map((note) => `${relative(process.cwd(), note.path)}\n[${note.type}] ${note.title} (${note.confidence}, salience: ${note.salience}/10)\n${note.body.replace(/\s+/g, " ").slice(0, 300)}`).join("\n\n"); - if (ctx.hasUI) ctx.ui.notify(lines, "info"); - }, - }); - - pi.on("before_agent_start", async (event, ctx) => { - ensureMemory(); - turn.prompt = String((event as any).prompt ?? ""); - turn.tools = []; - turn.filesRead.clear(); - turn.filesEdited.clear(); - turn.tests = []; - const mode = (await resolveQmd()).mode; - const promoted = trackShortTermMatches(turn.prompt); - const notes = lexicalSearch(turn.prompt, 5); - touchMemories(notes); - const memory = buildMemoryBlock(notes, mode); - const codebase = looksCodebaseIntent(turn.prompt) ? "\n## Codebase Prefetch\nCodebase intent detected. Use code_search first for repo understanding; prefetch is bounded and may be empty/stale.\n" : ""; - if (!memory && !codebase) return; - try { - const parts: string[] = []; - if (memory) parts.push(`+${notes.length} memories`); - if (promoted.length) parts.push(`auto-promoted ${promoted.length}`); - if (codebase) parts.push("+codebase-prefetch"); - ctx.ui.notify(`memory-context: ${parts.join(" ")}`, "info"); - } catch { - // best-effort - } - return { systemPrompt: `${(event as any).systemPrompt ?? ""}${memory}${codebase}` }; - }); - - pi.on("tool_call", async (event) => { - const name = String((event as any).toolName ?? (event as any).name ?? ""); - const input = (event as any).input ?? (event as any).arguments ?? {}; - if (name) turn.tools.push(name); - const p = input.path || input.file_path; - if (typeof p === "string") { - if (/read|findRead|glob|grep/.test(name)) turn.filesRead.add(p); - if (/write|edit/.test(name)) turn.filesEdited.add(p); - } - if (name === "bash") { - const cmd = commandText(input); - if (isTestCommand(cmd)) turn.tests.push(cmd.slice(0, 200)); - } - }); - - pi.on("turn_end", async (event) => { - const candidate = turnCandidate({ - prompt: turn.prompt, - outcome: finalText((event as any).message), - edited: [...turn.filesEdited], - read: [...turn.filesRead], - tests: turn.tests, - tools: turn.tools, - }); - if (!candidate || turn.tools.length === 0) return; - queueCandidate(candidate); - }); -} diff --git a/.pi/extensions/mode-commands/index.test.ts b/.pi/extensions/mode-commands/index.test.ts new file mode 100644 index 00000000..4ec37db9 --- /dev/null +++ b/.pi/extensions/mode-commands/index.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import modeCommands from "./index.ts"; + +describe("mode-commands command registration", () => { + it("does not register prompt-only /plan", () => { + const commands: string[] = []; + const pi: any = { registerCommand: (name: string) => commands.push(name) }; + modeCommands(pi); + expect(commands).not.toContain("plan"); + expect(commands).toContain("plan-prompt"); + expect(commands).toContain("execute"); + expect(commands).toContain("review"); + }); +}); diff --git a/.pi/extensions/mode-commands/index.ts b/.pi/extensions/mode-commands/index.ts index 40e8b7bf..e28be2fa 100644 --- a/.pi/extensions/mode-commands/index.ts +++ b/.pi/extensions/mode-commands/index.ts @@ -18,41 +18,48 @@ function latestPlan(cwd: string): string | undefined { return newest ? readFileSync(newest.path, "utf-8") : undefined; } -function appendPrompt(ctx: any, prompt: string): void { - ctx.ui?.notify?.(prompt, "info"); - if (typeof ctx.sendUserMessage === "function") ctx.sendUserMessage(prompt); +let activeModePrompt: string | undefined; + +function switchSystemPrompt(ctx: any, prompt: string): void { + activeModePrompt = prompt; + ctx.ui?.notify?.("Mode system prompt updated for subsequent turns.", "info"); } export default function (pi: ExtensionAPI) { - pi.registerCommand("plan", { - description: "Toggle planning mode", + if (typeof (pi as any).on === "function") { + pi.on("before_agent_start", async () => { + if (activeModePrompt) return { systemPrompt: activeModePrompt }; + }); + } + pi.registerCommand("plan-prompt", { + description: "Show the legacy planning prompt without taking over /plan", handler: async (_args, ctx) => { if (process.env.LITTLE_CODER_SUBAGENT || process.env.PI_SUBAGENT_DEPTH) { - ctx.ui?.notify?.("/plan is interactive-only and is disabled in subagent mode.", "warning"); + ctx.ui?.notify?.("/plan-prompt is interactive-only and is disabled in subagent mode.", "warning"); return; } - appendPrompt(ctx, planModePrompt("interactive")); + switchSystemPrompt(ctx, planModePrompt("interactive")); }, }); pi.registerCommand("execute", { description: "Enter execution mode for the latest plan", handler: async (_args, ctx) => { - appendPrompt(ctx, executionModePrompt(latestPlan(ctx.cwd ?? process.cwd()))); + switchSystemPrompt(ctx, executionModePrompt(latestPlan(ctx.cwd ?? process.cwd()))); }, }); pi.registerCommand("review", { description: "Enter read-only review mode", handler: async (_args, ctx) => { - appendPrompt(ctx, reviewModePrompt()); + switchSystemPrompt(ctx, reviewModePrompt()); }, }); pi.registerCommand("autoresearch", { description: "Enter autoresearch mode", handler: async (_args, ctx) => { - appendPrompt(ctx, autoresearchModePrompt()); + switchSystemPrompt(ctx, autoresearchModePrompt()); }, }); } diff --git a/.pi/extensions/permission-gate/index.ts b/.pi/extensions/permission-gate/index.ts index 2d4f5b66..104688cd 100644 --- a/.pi/extensions/permission-gate/index.ts +++ b/.pi/extensions/permission-gate/index.ts @@ -168,6 +168,11 @@ function isTrustedToolTempGlob(base: string, pattern: string): boolean { return /^pi-bash-.*\.log$/i.test(pattern) && !hasParentTraversal(pattern); } +function isWithinUserSkillsRoot(target: string): boolean { + const root = process.env.LITTLE_CODER_USER_SKILLS_DIR || join(homedir(), ".pi", "skills"); + return isWithinWorkspace(normalize(resolve(root)), normalize(resolve(target))); +} + export function getExternalWorkspaceAccess( toolName: string, input: Record | undefined, @@ -179,7 +184,7 @@ export function getExternalWorkspaceAccess( const path = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : undefined; if (!path) return null; const resolved = resolveWorkspacePath(path, cwd); - if (isWithinDefaultAllowedTmp(resolved) || isTrustedToolTempFilePath(resolved)) return null; + if (isWithinDefaultAllowedTmp(resolved) || isTrustedToolTempFilePath(resolved) || isWithinUserSkillsRoot(resolved)) return null; return isWithinWorkspace(cwd, resolved) ? null : { summary: resolved }; } @@ -187,7 +192,7 @@ export function getExternalWorkspaceAccess( const path = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : undefined; if (!path) return null; const resolved = resolveWorkspacePath(path, cwd); - if (isWithinDefaultAllowedTmp(resolved)) return null; + if (isWithinDefaultAllowedTmp(resolved) || isWithinUserSkillsRoot(resolved)) return null; return isWithinWorkspace(cwd, resolved) ? null : { summary: resolved }; } @@ -195,14 +200,14 @@ export function getExternalWorkspaceAccess( const path = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : undefined; if (!path) return null; const resolved = normalizeWritePath(path, cwd).path; - if (isWithinDefaultAllowedTmp(resolved)) return null; + if (isWithinDefaultAllowedTmp(resolved) || isWithinUserSkillsRoot(resolved)) return null; return isWithinWorkspace(cwd, resolved) ? null : { summary: resolved }; } if (toolName === "grep") { const baseInput = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : "."; const base = resolveWorkspacePath(baseInput, cwd); - if (isWithinDefaultAllowedTmp(base)) return null; + if (isWithinDefaultAllowedTmp(base) || isWithinUserSkillsRoot(base)) return null; return isWithinWorkspace(cwd, base) ? null : { summary: base }; } @@ -210,7 +215,7 @@ export function getExternalWorkspaceAccess( const baseInput = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : "."; const base = resolveWorkspacePath(baseInput, cwd); const pattern = typeof input.pattern === "string" ? input.pattern : ""; - if (isWithinDefaultAllowedTmp(base) || isTrustedToolTempGlob(base, pattern)) return null; + if (isWithinDefaultAllowedTmp(base) || isTrustedToolTempGlob(base, pattern) || isWithinUserSkillsRoot(base)) return null; if (!isWithinWorkspace(cwd, base)) return { summary: base }; if (pattern && hasParentTraversal(pattern)) { return { summary: `${base} (pattern escapes base: ${pattern})` }; diff --git a/.pi/extensions/pi-insights/LICENSE b/.pi/extensions/pi-insights/LICENSE new file mode 100644 index 00000000..be3f7b28 --- /dev/null +++ b/.pi/extensions/pi-insights/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/.pi/extensions/pi-insights/README.md b/.pi/extensions/pi-insights/README.md new file mode 100644 index 00000000..37174ee7 --- /dev/null +++ b/.pi/extensions/pi-insights/README.md @@ -0,0 +1,135 @@ + + + +![Pi Insights header showing weekly changes and navigation](assets/main.png) + +# Pi Insights + +Personal usage analytics for the [Pi coding agent](https://github.com/earendil-works/pi). Scans your session history, extracts deterministic stats and LLM-powered facets, then generates a self-contained HTML report covering your workflows, friction points, and suggestions for improvement. + +Built by the [Observal](https://github.com/BlazeUp-AI/Observal) team while developing our agent observability platform. We needed to understand how we actually use Pi across hundreds of sessions, what patterns emerge, and where we waste time or money. This extension is the result. + +## Install + +**From npm** (recommended): + +```bash +pi install npm:@observal/pi-insights +``` + +**From source:** + +```bash +git clone https://github.com/BlazeUp-AI/pi-insights.git +pi install ./pi-insights +``` + +**Try without installing:** + +```bash +pi -e npm:@observal/pi-insights +``` + +## Usage + +Run the command inside any Pi session: + +``` +/pi-insights +``` + +The report opens in your browser automatically. + +### Flags + +| Flag | Description | +|------|-------------| +| `--refresh` / `-r` | Invalidate all cached LLM facet extractions and re-run them | +| `--no-open` | Generate the report without opening it in the browser | +| `--since d` | Only analyze sessions from the last N days (e.g. `--since 7d`) | +| `--md` | Output a Markdown report instead of opening the HTML version | + +### Examples + +```bash +# Normal run (uses caches, fast on re-runs) +/pi-insights + +# Force re-extraction of all session facets +/pi-insights --refresh + +# Generate without auto-opening +/pi-insights --no-open + +# Only analyze the last 7 days +/pi-insights --since 7d + +# Export as Markdown (for Slack, docs, etc.) +/pi-insights --md +``` + +## What the Report Shows + +### Session stats at a glance + +Tokens, cost, lines changed, commits, tool errors, parallel sessions, and more. + +![Stats grid showing sessions, messages, tokens, cost, lines, commits](assets/stats.png) + +### Context-aware suggestions with copyable prompts + +Suggests features, skills, and config additions tailored to your actual workflow. References your real projects and tools. + +![Features to try section with lifecycle hooks and skills suggestions](assets/features.png) + +### "Stop Doing" section + +Tells you what patterns are costing you time or money, with concrete alternatives. + +![Consider Stopping section with three anti-patterns and green alternatives](assets/bad_patterns.png) + +### Model spend analysis + +Identifies overspend (Opus on simple tasks) and underspend (Sonnet failing on complex work), with a recommendation and estimated savings. + +![Model efficiency showing overspend, underspend, and recommendation](assets/save_money.png) + +## What Makes This Different + +Most Pi insight extensions dump flat aggregates into an LLM prompt and get the same generic report every time. This one is temporal-aware: + +- **Week-over-week diffs**: see what actually changed, not a static portrait +- **Decay-weighted charts**: recent sessions have more influence on friction/satisfaction/outcome charts (10-day half-life) +- **Trajectory detection**: are your costs/errors improving, worsening, or stable? +- **Anomaly detection**: spikes in cost or errors are surfaced with context +- **Resolved vs ongoing friction**: only surfaces problems you still have, not ones you fixed +- **Context-aware suggestions**: reads your existing AGENTS.md, installed skills, extensions, and packages. Will not suggest what you already have. +- **Negative suggestions**: tells you what to stop doing, not just what to add + +## How It Works + +The pipeline runs in five phases: + +1. **Scan** all Pi session log files +2. **Extract stats** deterministically from each session (tool counts, tokens, languages, git activity, response times) +3. **LLM facet extraction** per session to classify goals, outcomes, satisfaction, and friction +4. **Aggregate with decay weighting**, compute diffs, detect anomalies and transitions, gather user context +5. **Generate insights** using 8 parallel LLM prompts (with temporal and user context injected) plus a synthesis prompt, then **render** a self-contained HTML report + +Results are cached in `~/.pi/agent/usage-data/`: + +| Path | Contents | +|------|----------| +| `session-meta/.json` | Deterministic stats, cached permanently | +| `facets/.json` | LLM-extracted facets, cached permanently (clear with `--refresh`) | +| `report.html` | Last generated report | +| `report.md` | Last markdown export (when using `--md`) | + +## Requirements + +- [Pi](https://github.com/earendil-works/pi) v0.74.0 or later +- An active model configured in Pi (used for both facet extraction and insight generation) + +## License + +AGPL-3.0-only diff --git a/.pi/extensions/pi-insights/index.ts b/.pi/extensions/pi-insights/index.ts new file mode 100644 index 00000000..2a6ebf63 --- /dev/null +++ b/.pi/extensions/pi-insights/index.ts @@ -0,0 +1,2759 @@ +// SPDX-FileCopyrightText: 2026 Hari Srinivasan +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * /insights — Pi Usage Insights + * + * Scans all Pi session logs, extracts deterministic stats, runs LLM + * facet extraction per session (cached), fires 7 parallel insight prompts + * + 1 synthesis, and writes a self-contained HTML report. + * + * Usage: + * /insights — run with caches (fast on re-runs) + * /insights --refresh — invalidate all LLM facet caches, re-extract + * /insights --no-open — don't open the report in the browser + * + * Data dir: ~/.pi/agent/usage-data/ + * session-meta/.json deterministic stats, cached permanently + * facets/.json LLM-extracted facets, cached permanently + * report.html last generated report + */ + +import { complete } from "@earendil-works/pi-ai"; +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { execFile as execFileCb } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises"; +import { homedir, platform } from "node:os"; +import { extname, join } from "node:path"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCb); + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const DATA_DIR = join(homedir(), ".pi", "agent", "usage-data"); +const FACETS_DIR = join(DATA_DIR, "facets"); +const META_DIR = join(DATA_DIR, "session-meta"); +const REPORT_PATH = join(DATA_DIR, "report.html"); +const REPORT_MD_PATH = join(DATA_DIR, "report.md"); +const REPORT_PORT = 5463; +const REPORT_URL = `http://localhost:${REPORT_PORT}`; + +let reportServer: Server | null = null; + +const MAX_SESSIONS_TO_LOAD = 200; +const MAX_FACET_EXTRACTIONS = 50; +const FACET_CONCURRENCY = 50; +const META_BATCH_SIZE = 50; +const LOAD_BATCH_SIZE = 10; +const OVERLAP_WINDOW_MS = 30 * 60_000; + +const EXTENSION_TO_LANGUAGE: Record = { + ".ts": "TypeScript", + ".tsx": "TypeScript", + ".js": "JavaScript", + ".jsx": "JavaScript", + ".py": "Python", + ".rb": "Ruby", + ".go": "Go", + ".rs": "Rust", + ".java": "Java", + ".md": "Markdown", + ".json": "JSON", + ".yaml": "YAML", + ".yml": "YAML", + ".sh": "Shell", + ".css": "CSS", + ".html": "HTML", + ".c": "C", + ".cpp": "C++", + ".cs": "C#", + ".kt": "Kotlin", + ".swift": "Swift", +}; + +const LABEL_MAP: Record = { + debug_investigate: "Debug / Investigate", + implement_feature: "Implement Feature", + fix_bug: "Fix Bug", + write_script_tool: "Write Script / Tool", + refactor_code: "Refactor Code", + configure_system: "Configure System", + create_pr_commit: "Create PR / Commit", + analyze_data: "Analyze Data", + understand_codebase: "Understand Codebase", + write_tests: "Write Tests", + write_docs: "Write Docs", + deploy_infra: "Deploy / Infra", + warmup_minimal: "Cache Warmup", + fast_accurate_search: "Fast / Accurate Search", + correct_code_edits: "Correct Code Edits", + good_explanations: "Good Explanations", + proactive_help: "Proactive Help", + multi_file_changes: "Multi-file Changes", + handled_complexity: "Multi-file Changes", + good_debugging: "Good Debugging", + misunderstood_request: "Misunderstood Request", + wrong_approach: "Wrong Approach", + buggy_code: "Buggy Code", + user_rejected_action: "User Rejected Action", + assistant_got_blocked: "Assistant Got Blocked", + user_stopped_early: "User Stopped Early", + wrong_file_or_location: "Wrong File / Location", + excessive_changes: "Excessive Changes", + slow_or_verbose: "Slow / Verbose", + tool_failed: "Tool Failed", + user_unclear: "User Unclear", + external_issue: "External Issue", + frustrated: "Frustrated", + dissatisfied: "Dissatisfied", + likely_satisfied: "Likely Satisfied", + satisfied: "Satisfied", + happy: "Happy", + unsure: "Unsure", + neutral: "Neutral", + delighted: "Delighted", + single_task: "Single Task", + multi_task: "Multi Task", + iterative_refinement: "Iterative Refinement", + exploration: "Exploration", + quick_question: "Quick Question", + fully_achieved: "Fully Achieved", + mostly_achieved: "Mostly Achieved", + partially_achieved: "Partially Achieved", + not_achieved: "Not Achieved", + unclear_from_transcript: "Unclear", + unhelpful: "Unhelpful", + slightly_helpful: "Slightly Helpful", + moderately_helpful: "Moderately Helpful", + very_helpful: "Very Helpful", + essential: "Essential", +}; + +const SATISFACTION_ORDER = [ + "frustrated", + "dissatisfied", + "likely_satisfied", + "satisfied", + "happy", + "unsure", + "neutral", + "delighted", +]; +const OUTCOME_ORDER = [ + "not_achieved", + "partially_achieved", + "mostly_achieved", + "fully_achieved", + "unclear_from_transcript", +]; + +function displayLabel(key: string): string { + return ( + LABEL_MAP[key] ?? + key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) + ); +} + +async function startReportServer(): Promise { + if (reportServer?.listening) return REPORT_URL; + + reportServer = createServer(async (req, res) => { + const path = new URL(req.url ?? "/", REPORT_URL).pathname; + if (path !== "/" && path !== "/report.html") { + res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + res.end("Not found"); + return; + } + + try { + const html = await readFile(REPORT_PATH, "utf8"); + res.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + res.end(html); + } catch { + res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + res.end("Pi Insights report has not been generated yet. Run /insights first."); + } + }); + + await new Promise((resolve, reject) => { + const onError = (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") resolve(); + else reject(err); + }; + reportServer!.once("error", onError); + reportServer!.listen(REPORT_PORT, "127.0.0.1", () => { + reportServer!.off("error", onError); + resolve(); + }); + }); + + return REPORT_URL; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type SessionMeta = { + session_id: string; + session_path: string; + project_path: string; + start_time: string; + duration_minutes: number; + user_message_count: number; + assistant_message_count: number; + tool_counts: Record; + languages: Record; + git_commits: number; + git_pushes: number; + input_tokens: number; + output_tokens: number; + total_cost: number; + first_prompt: string; + user_interruptions: number; + user_response_times: number[]; + tool_errors: number; + tool_error_categories: Record; + uses_subagent: boolean; + uses_mcp: boolean; + lines_added: number; + lines_removed: number; + files_modified: number; + message_hours: number[]; + user_message_timestamps: string[]; + model_usage: Record; +}; + +type SessionFacets = { + session_id: string; + underlying_goal: string; + goal_categories: Record; + outcome: string; + user_satisfaction_counts: Record; + assistant_helpfulness: string; + session_type: string; + friction_counts: Record; + friction_detail: string; + primary_success: string; + brief_summary: string; + user_instructions_to_assistant?: string[]; +}; + +type AggregatedData = { + total_sessions: number; + sessions_with_facets: number; + date_range: { start: string; end: string }; + total_messages: number; + total_duration_hours: number; + total_input_tokens: number; + total_output_tokens: number; + total_cost: number; + tool_counts: Record; + languages: Record; + git_commits: number; + git_pushes: number; + projects: Record; + goal_categories: Record; + outcomes: Record; + satisfaction: Record; + helpfulness: Record; + session_types: Record; + friction: Record; + success: Record; + session_summaries: Array<{ + id: string; + date: string; + summary: string; + outcome: string; + helpfulness: string; + }>; + friction_details: string[]; + user_instructions: string[]; + total_interruptions: number; + total_tool_errors: number; + tool_error_categories: Record; + user_response_times: number[]; + median_response_time: number; + avg_response_time: number; + sessions_using_subagent: number; + sessions_using_mcp: number; + total_lines_added: number; + total_lines_removed: number; + total_files_modified: number; + days_active: number; + message_hours: number[]; + multi_clauding: { + overlap_events: number; + sessions_involved: number; + user_messages_during: number; + }; + model_usage: Record; + model_efficiency: Array<{ + model: string; + session_id: string; + date: string; + cost: number; + outcome: string; + session_type: string; + goal: string; + flag: "overspend" | "underspend" | "ok"; + reason: string; + }>; + estimated_waste: number; +}; + +type UserContext = { + existing_agents_md_rules: string[]; + installed_skills: string[]; + installed_extensions: string[]; + installed_packages: string[]; + default_model: string; +}; + +type TemporalData = { + diff_headlines: string[]; + this_week: { sessions: number; avg_cost: number; errors_per_session: number; primary_model: string } | null; + last_week: { sessions: number; avg_cost: number; errors_per_session: number; primary_model: string } | null; + trajectory: { cost: string; errors: string; note: string }; + anomalies: Array<{ date: string; cost: string; errors: number; reason: string; prompt: string }>; + major_transition: { when: string; what: string; impact: string } | null; + resolved_friction: string[]; + ongoing_friction: Array<{ type: string; recent_count: number; total_count: number }>; + staleness_pct: number; +}; + +// ─── Cache Utilities ────────────────────────────────────────────────────────── + +async function ensureDirs(): Promise { + await mkdir(META_DIR, { recursive: true }); + await mkdir(FACETS_DIR, { recursive: true }); +} + +async function loadCachedMeta(sessionId: string): Promise { + try { + const raw = await readFile(join(META_DIR, `${sessionId}.json`), "utf-8"); + return JSON.parse(raw) as SessionMeta; + } catch { + return null; + } +} + +async function saveMeta(meta: SessionMeta): Promise { + await writeFile( + join(META_DIR, `${meta.session_id}.json`), + JSON.stringify(meta, null, 2), + { encoding: "utf-8", mode: 0o600 }, + ); +} + +async function loadCachedFacets( + sessionId: string, +): Promise { + try { + const raw = await readFile(join(FACETS_DIR, `${sessionId}.json`), "utf-8"); + const parsed = JSON.parse(raw) as SessionFacets; + // Basic schema check + if (!parsed.session_id || !parsed.brief_summary || !parsed.outcome) + return null; + return parsed; + } catch { + return null; + } +} + +async function saveFacets(facets: SessionFacets): Promise { + await writeFile( + join(FACETS_DIR, `${facets.session_id}.json`), + JSON.stringify(facets, null, 2), + { encoding: "utf-8", mode: 0o600 }, + ); +} + +async function deleteCachedFacets(sessionId: string): Promise { + try { + await unlink(join(FACETS_DIR, `${sessionId}.json`)); + } catch { + /* ok */ + } +} + +async function gatherUserContext(): Promise { + const agentDir = join(homedir(), ".pi", "agent"); + const ctx: UserContext = { existing_agents_md_rules: [], installed_skills: [], installed_extensions: [], installed_packages: [], default_model: "" }; + + try { + const agentsMd = await readFile(join(agentDir, "AGENTS.md"), "utf-8"); + for (const line of agentsMd.split("\n")) { + const t = line.trim(); + if (t.length > 20 && t.length < 200 && /\b(always|never|do not|don't|must|require|forbid)\b/i.test(t)) { + ctx.existing_agents_md_rules.push(t.slice(0, 150)); + } + } + ctx.existing_agents_md_rules = ctx.existing_agents_md_rules.slice(0, 20); + } catch {} + + try { + const settings = JSON.parse(await readFile(join(agentDir, "settings.json"), "utf-8")); + ctx.default_model = settings.defaultModel || ""; + ctx.installed_packages = (settings.packages || []).map((p: string) => p.replace(/.*\//, "")); + } catch {} + + try { + const entries = await readdir(join(agentDir, "skills"), { withFileTypes: true }); + ctx.installed_skills = entries.filter((e: { isDirectory(): boolean; name: string }) => e.isDirectory()).map((e: { name: string }) => e.name); + } catch {} + + try { + const entries = await readdir(join(agentDir, "extensions")); + ctx.installed_extensions = entries.filter((f: string) => f.endsWith(".ts") || f.endsWith(".js")).map((f: string) => f.replace(/\.[^.]+$/, "")); + } catch {} + + return ctx; +} + +function modeStr(arr: string[]): string { + const counts: Record = {}; + for (const v of arr) if (v) counts[v] = (counts[v] || 0) + 1; + return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || ""; +} + +function computeTemporalData(metas: SessionMeta[], facetsMap: Map): TemporalData { + const sorted = [...metas].sort((a, b) => a.start_time.localeCompare(b.start_time)); + if (!sorted.length) return { diff_headlines: [], this_week: null, last_week: null, trajectory: { cost: "stable", errors: "stable", note: "" }, anomalies: [], major_transition: null, resolved_friction: [], ongoing_friction: [], staleness_pct: 0 }; + + const now = new Date(sorted[sorted.length - 1]!.start_time).getTime(); + const oneWeek = 7 * 86400000; + + // Diff: this week vs last week + const thisWeekSessions = sorted.filter(m => now - new Date(m.start_time).getTime() < oneWeek); + const lastWeekSessions = sorted.filter(m => { const age = now - new Date(m.start_time).getTime(); return age >= oneWeek && age < 2 * oneWeek; }); + + function periodSummary(sessions: SessionMeta[]) { + if (!sessions.length) return null; + const models: Record = {}; + let cost = 0, errors = 0; + for (const m of sessions) { cost += m.total_cost; errors += m.tool_errors; for (const [model, s] of Object.entries(m.model_usage)) models[model] = (models[model] || 0) + s.message_count; } + return { sessions: sessions.length, avg_cost: cost / sessions.length, errors_per_session: errors / sessions.length, primary_model: Object.entries(models).sort((a, b) => b[1] - a[1])[0]?.[0]?.replace(/.*\./, "") || "unknown" }; + } + + const tw = periodSummary(thisWeekSessions); + const lw = periodSummary(lastWeekSessions); + const diff_headlines: string[] = []; + if (tw && lw && lw.avg_cost > 0) { + const costD = Math.round((tw.avg_cost - lw.avg_cost) / lw.avg_cost * 100); + if (Math.abs(costD) > 15) diff_headlines.push(`Cost ${costD > 0 ? "up" : "down"} ${Math.abs(costD)}% ($${lw.avg_cost.toFixed(1)} \u2192 $${tw.avg_cost.toFixed(1)}/session)`); + const errD = Math.round((tw.errors_per_session - lw.errors_per_session) / (lw.errors_per_session || 1) * 100); + if (Math.abs(errD) > 20) diff_headlines.push(`Errors ${errD > 0 ? "up" : "down"} ${Math.abs(errD)}% (${lw.errors_per_session.toFixed(0)} \u2192 ${tw.errors_per_session.toFixed(0)}/session)`); + if (tw.primary_model !== lw.primary_model) diff_headlines.push(`Model shifted: ${lw.primary_model} \u2192 ${tw.primary_model}`); + } + + // Trajectory + const recent10 = sorted.slice(-10); + const older = sorted.slice(0, -10); + const recentCost = recent10.reduce((s, m) => s + m.total_cost, 0) / recent10.length; + const olderCost = older.length ? older.reduce((s, m) => s + m.total_cost, 0) / older.length : recentCost; + const recentErrors = recent10.reduce((s, m) => s + m.tool_errors, 0) / recent10.length; + const olderErrors = older.length ? older.reduce((s, m) => s + m.tool_errors, 0) / older.length : recentErrors; + const trajectory = { + cost: recentCost > olderCost * 1.2 ? "increasing" : recentCost < olderCost * 0.8 ? "decreasing" : "stable", + errors: recentErrors > olderErrors * 1.2 ? "increasing" : recentErrors < olderErrors * 0.8 ? "decreasing" : "stable", + note: older.length ? `Recent 10 vs earlier ${older.length}: cost ${recentCost > olderCost ? "up" : "down"} ${Math.abs(Math.round((recentCost - olderCost) / (olderCost || 1) * 100))}%, errors ${recentErrors > olderErrors ? "up" : "down"} ${Math.abs(Math.round((recentErrors - olderErrors) / (olderErrors || 1) * 100))}%` : "Not enough history", + }; + + // Anomalies + const anomalies: TemporalData["anomalies"] = []; + for (let i = 5; i < sorted.length; i++) { + const m = sorted[i]!; + const window = sorted.slice(Math.max(0, i - 10), i); + const avgCost = window.reduce((s, x) => s + x.total_cost, 0) / window.length; + const avgErrors = window.reduce((s, x) => s + x.tool_errors, 0) / window.length; + const reasons: string[] = []; + if (m.total_cost > avgCost * 3 && m.total_cost > 10) reasons.push(`cost spike: $${m.total_cost.toFixed(0)} vs $${avgCost.toFixed(0)} avg`); + if (m.tool_errors > avgErrors * 3 && m.tool_errors > 10) reasons.push(`error spike: ${m.tool_errors} vs ${avgErrors.toFixed(0)} avg`); + if (reasons.length) anomalies.push({ date: m.start_time.slice(0, 10), cost: `$${m.total_cost.toFixed(2)}`, errors: m.tool_errors, reason: reasons.join("; "), prompt: m.first_prompt.slice(0, 80) }); + } + anomalies.sort((a, b) => parseFloat(b.cost.slice(1)) - parseFloat(a.cost.slice(1))); + + // Major transition + let major_transition: TemporalData["major_transition"] = null; + for (let i = sorted.length - 1; i >= 10; i--) { + const after = sorted.slice(i, Math.min(i + 10, sorted.length)); + const before = sorted.slice(Math.max(0, i - 10), i); + if (before.length < 5 || after.length < 5) continue; + const beforeModel = modeStr(before.map(m => Object.entries(m.model_usage).sort((a, b) => b[1].cost - a[1].cost)[0]?.[0] || "")); + const afterModel = modeStr(after.map(m => Object.entries(m.model_usage).sort((a, b) => b[1].cost - a[1].cost)[0]?.[0] || "")); + if (beforeModel && afterModel && beforeModel !== afterModel) { + const beforeCost = before.reduce((s, m) => s + m.total_cost, 0) / before.length; + const afterCost = after.reduce((s, m) => s + m.total_cost, 0) / after.length; + const beforeErrors = before.reduce((s, m) => s + m.tool_errors, 0) / before.length; + const afterErrors = after.reduce((s, m) => s + m.tool_errors, 0) / after.length; + major_transition = { + when: sorted[i]!.start_time.slice(0, 10), + what: `Shifted from ${beforeModel.replace(/.*\./, "")} to ${afterModel.replace(/.*\./, "")}`, + impact: `Cost ${afterCost > beforeCost ? "up" : "down"} ${Math.abs(Math.round((afterCost - beforeCost) / (beforeCost || 1) * 100))}%, errors ${afterErrors > beforeErrors ? "up" : "down"} ${Math.abs(Math.round((afterErrors - beforeErrors) / (beforeErrors || 1) * 100))}%`, + }; + break; + } + } + + // Resolved vs ongoing friction + const recentCutoff = now - 14 * 86400000; + const recentMetas = sorted.filter(m => new Date(m.start_time).getTime() >= recentCutoff); + const olderMetas = sorted.filter(m => new Date(m.start_time).getTime() < recentCutoff); + const recentFriction: Record = {}; + const olderFriction: Record = {}; + for (const m of recentMetas) { const f = facetsMap.get(m.session_id); if (f) for (const [k, v] of Object.entries(f.friction_counts)) if (v > 0) recentFriction[k] = (recentFriction[k] || 0) + v; } + for (const m of olderMetas) { const f = facetsMap.get(m.session_id); if (f) for (const [k, v] of Object.entries(f.friction_counts)) if (v > 0) olderFriction[k] = (olderFriction[k] || 0) + v; } + const resolved_friction: string[] = []; + const ongoing_friction: TemporalData["ongoing_friction"] = []; + for (const [type] of Object.entries(olderFriction)) { if (!recentFriction[type]) resolved_friction.push(type); } + for (const [type, count] of Object.entries(recentFriction)) { if (count > 0) ongoing_friction.push({ type, recent_count: count, total_count: count + (olderFriction[type] || 0) }); } + ongoing_friction.sort((a, b) => b.recent_count - a.recent_count); + + // Staleness + const flatCost = sorted.reduce((s, m) => s + m.total_cost, 0) / sorted.length; + const staleness_pct = flatCost > 0 ? Math.abs((recentCost - flatCost) / flatCost * 100) : 0; + + return { diff_headlines, this_week: tw, last_week: lw, trajectory, anomalies: anomalies.slice(0, 5), major_transition, resolved_friction: resolved_friction.slice(0, 5), ongoing_friction: ongoing_friction.slice(0, 8), staleness_pct }; +} + +// ─── Session Parsing ────────────────────────────────────────────────────────── + +type AnyEntry = Record; +type AnyMessage = Record; +type ContentBlock = { + type: string; + text?: string; + name?: string; + arguments?: Record; + [k: string]: unknown; +}; + +function getLanguageFromPath(filePath: string): string | null { + return EXTENSION_TO_LANGUAGE[extname(filePath).toLowerCase()] ?? null; +} + +function extractTextFromContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return (content as ContentBlock[]) + .filter((b) => b.type === "text" && typeof b.text === "string") + .map((b) => b.text as string) + .join(" "); +} + +function isHumanMessage(msg: AnyMessage): boolean { + const content = msg.content; + if (typeof content === "string" && (content as string).trim()) return true; + if (Array.isArray(content)) { + return (content as ContentBlock[]).some( + (b) => + b.type === "text" && + typeof b.text === "string" && + (b.text as string).trim().length > 0, + ); + } + return false; +} + +function countNewlines(s: string): number { + return (s.match(/\n/g) ?? []).length; +} + +/** Detect sessions that were spawned by the insights pipeline itself */ +function isMetaSession(entries: AnyEntry[]): boolean { + let userMsgCount = 0; + for (const entry of entries) { + if (entry.type !== "message") continue; + const msg = entry.message as AnyMessage | undefined; + if (!msg) continue; + if (msg.role === "user" && isHumanMessage(msg)) { + const text = extractTextFromContent(msg.content); + if ( + text.includes("RESPOND WITH ONLY A VALID JSON OBJECT") || + text.includes("record_facets") || + text.includes("extract structured facets") || + text.includes("At a Glance") + ) + return true; + userMsgCount++; + if (userMsgCount >= 3) break; + } + } + return false; +} + +function extractSessionStats(entries: AnyEntry[], sessionPath: string) { + const toolCounts: Record = {}; + const languages: Record = {}; + const toolErrorCategories: Record = {}; + const filesModified = new Set(); + const userResponseTimes: number[] = []; + const messageHours: number[] = []; + const userMessageTimestamps: string[] = []; + + let gitCommits = 0; + let gitPushes = 0; + let inputTokens = 0; + let outputTokens = 0; + let totalCost = 0; + let userInterruptions = 0; + let toolErrors = 0; + let usesSubagent = false; + let usesMcp = false; + let linesAdded = 0; + let linesRemoved = 0; + let userMessageCount = 0; + let assistantMessageCount = 0; + let firstPrompt = ""; + + const modelUsage: Record = {}; + + let lastAssistantTs: number | null = null; + + // Deduplicate tool call IDs to avoid double-counting branched entries + const seenToolCallIds = new Set(); + + for (const entry of entries) { + if (entry.type !== "message") continue; + const msg = entry.message as AnyMessage | undefined; + if (!msg) continue; + + const msgTs = typeof msg.timestamp === "number" ? msg.timestamp : null; + + // ── assistant message ── + if (msg.role === "assistant") { + assistantMessageCount++; + if (msgTs) lastAssistantTs = msgTs; + + // Model tracking + const modelName = (msg.model as string) ?? "unknown"; + + // Tokens + cost + const usage = msg.usage as Record | undefined; + if (usage) { + const msgInput = (usage.input as number) ?? 0; + const msgOutput = (usage.output as number) ?? 0; + const cost = usage.cost as Record | undefined; + const msgCost = cost?.total ?? 0; + + inputTokens += msgInput; + outputTokens += msgOutput; + if (msgCost) totalCost += msgCost; + + if (!modelUsage[modelName]) modelUsage[modelName] = { input_tokens: 0, output_tokens: 0, cost: 0, message_count: 0 }; + modelUsage[modelName]!.input_tokens += msgInput; + modelUsage[modelName]!.output_tokens += msgOutput; + modelUsage[modelName]!.cost += msgCost; + modelUsage[modelName]!.message_count++; + } + + // Tool calls inside content + const content = msg.content; + if (Array.isArray(content)) { + for (const block of content as ContentBlock[]) { + if (block.type !== "toolCall") continue; + const toolName = (block.name as string) ?? ""; + const toolId = (block.id as string) ?? Math.random().toString(36); + + if (seenToolCallIds.has(toolId)) continue; + seenToolCallIds.add(toolId); + + toolCounts[toolName] = (toolCounts[toolName] ?? 0) + 1; + + if (toolName === "subagent") usesSubagent = true; + if (toolName.startsWith("mcp__")) usesMcp = true; + + const args = (block.arguments as Record) ?? {}; + const filePath = + (args.path as string) ?? (args.file_path as string) ?? ""; + + if (filePath) { + const lang = getLanguageFromPath(filePath); + if (lang) languages[lang] = (languages[lang] ?? 0) + 1; + } + + if (toolName === "write" && filePath) { + filesModified.add(filePath); + const content_ = (args.content as string) ?? ""; + linesAdded += countNewlines(content_) + 1; + } + + if (toolName === "edit" && filePath) { + filesModified.add(filePath); + const edits = + (args.edits as Array<{ + oldText?: string; + newText?: string; + old_string?: string; + new_string?: string; + }>) ?? []; + for (const e of edits) { + const oldText = e.oldText ?? e.old_string ?? ""; + const newText = e.newText ?? e.new_string ?? ""; + linesAdded += countNewlines(newText) + 1; + linesRemoved += countNewlines(oldText) + 1; + } + } + + if (toolName === "bash") { + const cmd = (args.command as string) ?? ""; + if (cmd.includes("git commit")) gitCommits++; + if (cmd.includes("git push")) gitPushes++; + } + } + } + } + + // ── user message (human) ── + if (msg.role === "user" && isHumanMessage(msg)) { + userMessageCount++; + const text = extractTextFromContent(msg.content); + + if (!firstPrompt && text.trim()) firstPrompt = text.trim().slice(0, 300); + + if (text.includes("[Request interrupted by user")) userInterruptions++; + + if (msgTs) { + const d = new Date(msgTs); + messageHours.push(d.getHours()); + userMessageTimestamps.push(d.toISOString()); + + if (lastAssistantTs !== null) { + const gapSec = (msgTs - lastAssistantTs) / 1000; + if (gapSec > 2 && gapSec < 3600) userResponseTimes.push(gapSec); + } + } + } + + // ── tool result ── + if (msg.role === "toolResult") { + const isError = (msg.isError as boolean) === true; + if (isError) { + toolErrors++; + const resultText = extractTextFromContent(msg.content).toLowerCase(); + let cat = "Other"; + if (resultText.includes("exit code")) cat = "Command Failed"; + else if ( + resultText.includes("rejected") || + resultText.includes("doesn't want") + ) + cat = "User Rejected"; + else if ( + resultText.includes("string to replace not found") || + resultText.includes("no changes") + ) + cat = "Edit Failed"; + else if (resultText.includes("modified since read")) + cat = "File Changed"; + else if ( + resultText.includes("exceeds maximum") || + resultText.includes("too large") + ) + cat = "File Too Large"; + else if ( + resultText.includes("file not found") || + resultText.includes("does not exist") + ) + cat = "File Not Found"; + toolErrorCategories[cat] = (toolErrorCategories[cat] ?? 0) + 1; + } + } + } + + return { + toolCounts, + languages, + toolErrorCategories, + filesModified: filesModified.size, + userResponseTimes, + messageHours, + userMessageTimestamps, + gitCommits, + gitPushes, + inputTokens, + outputTokens, + totalCost, + userInterruptions, + toolErrors, + usesSubagent, + usesMcp, + linesAdded, + linesRemoved, + userMessageCount, + assistantMessageCount, + firstPrompt, + modelUsage, + }; +} + +function buildSessionMeta( + info: { + id: string; + path: string; + cwd: string; + created: Date; + modified: Date; + }, + entries: AnyEntry[], +): SessionMeta { + const stats = extractSessionStats(entries, info.path); + return { + session_id: info.id, + session_path: info.path, + project_path: info.cwd, + start_time: info.created.toISOString(), + duration_minutes: Math.round( + (info.modified.getTime() - info.created.getTime()) / 1000 / 60, + ), + user_message_count: stats.userMessageCount, + assistant_message_count: stats.assistantMessageCount, + tool_counts: stats.toolCounts, + languages: stats.languages, + git_commits: stats.gitCommits, + git_pushes: stats.gitPushes, + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + total_cost: stats.totalCost, + first_prompt: stats.firstPrompt, + user_interruptions: stats.userInterruptions, + user_response_times: stats.userResponseTimes, + tool_errors: stats.toolErrors, + tool_error_categories: stats.toolErrorCategories, + uses_subagent: stats.usesSubagent, + uses_mcp: stats.usesMcp, + lines_added: stats.linesAdded, + lines_removed: stats.linesRemoved, + files_modified: stats.filesModified, + message_hours: stats.messageHours, + user_message_timestamps: stats.userMessageTimestamps, + model_usage: stats.modelUsage, + }; +} + +function formatTranscript(entries: AnyEntry[], meta: SessionMeta): string { + const lines: string[] = [ + `Session: ${meta.session_id.slice(0, 8)}`, + `Date: ${meta.start_time}`, + `Project: ${meta.project_path}`, + `Duration: ${meta.duration_minutes} min`, + "", + ]; + + for (const entry of entries) { + if (entry.type !== "message") continue; + const msg = entry.message as AnyMessage | undefined; + if (!msg) continue; + + if (msg.role === "user" && isHumanMessage(msg)) { + const text = extractTextFromContent(msg.content).slice(0, 500); + if (text.trim()) lines.push(`[User]: ${text}`); + } else if (msg.role === "assistant") { + const content = msg.content; + if (Array.isArray(content)) { + for (const block of content as ContentBlock[]) { + if (block.type === "text" && block.text) { + lines.push(`[Assistant]: ${(block.text as string).slice(0, 300)}`); + } else if (block.type === "toolCall" && block.name) { + lines.push(`[Tool: ${block.name as string}]`); + } + } + } + } + } + + return lines.join("\n"); +} + +// ─── Parallel Session Detection ─────────────────────────────────────────────── + +function detectMultiClauding( + sessions: Array<{ session_id: string; user_message_timestamps: string[] }>, +) { + const all: Array<{ ts: number; sid: string }> = []; + for (const s of sessions) { + for (const iso of s.user_message_timestamps) { + const ts = new Date(iso).getTime(); + if (!isNaN(ts)) all.push({ ts, sid: s.session_id }); + } + } + all.sort((a, b) => a.ts - b.ts); + + const pairs = new Set(); + const duringMsgs = new Set(); + let windowStart = 0; + const sessionLastIdx = new Map(); + + for (let i = 0; i < all.length; i++) { + const msg = all[i]!; + while ( + windowStart < i && + msg.ts - all[windowStart]!.ts > OVERLAP_WINDOW_MS + ) { + const exp = all[windowStart]!; + if (sessionLastIdx.get(exp.sid) === windowStart) + sessionLastIdx.delete(exp.sid); + windowStart++; + } + const prevIdx = sessionLastIdx.get(msg.sid); + if (prevIdx !== undefined) { + for (let j = prevIdx + 1; j < i; j++) { + const between = all[j]!; + if (between.sid !== msg.sid) { + const pair = [msg.sid, between.sid].sort().join(":"); + pairs.add(pair); + duringMsgs.add(`${all[prevIdx]!.ts}:${msg.sid}`); + duringMsgs.add(`${between.ts}:${between.sid}`); + duringMsgs.add(`${msg.ts}:${msg.sid}`); + break; + } + } + } + sessionLastIdx.set(msg.sid, i); + } + + const involvedSessions = new Set(); + for (const pair of pairs) { + const [a, b] = pair.split(":"); + if (a) involvedSessions.add(a); + if (b) involvedSessions.add(b); + } + + return { + overlap_events: pairs.size, + sessions_involved: involvedSessions.size, + user_messages_during: duringMsgs.size, + }; +} + +// ─── Aggregation ────────────────────────────────────────────────────────────── + +function median(arr: number[]): number { + if (!arr.length) return 0; + const s = [...arr].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2; +} + +function mergeRecord( + target: Record, + source: Record, +) { + for (const [k, v] of Object.entries(source)) { + target[k] = (target[k] ?? 0) + v; + } +} + +function top8(rec: Record): [string, number][] { + return Object.entries(rec) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); +} + +function aggregateData( + metas: SessionMeta[], + facetsMap: Map, +): AggregatedData { + const agg: AggregatedData = { + total_sessions: metas.length, + sessions_with_facets: 0, + date_range: { start: "", end: "" }, + total_messages: 0, + total_duration_hours: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cost: 0, + tool_counts: {}, + languages: {}, + git_commits: 0, + git_pushes: 0, + projects: {}, + goal_categories: {}, + outcomes: {}, + satisfaction: {}, + helpfulness: {}, + session_types: {}, + friction: {}, + success: {}, + session_summaries: [], + friction_details: [], + user_instructions: [], + total_interruptions: 0, + total_tool_errors: 0, + tool_error_categories: {}, + user_response_times: [], + median_response_time: 0, + avg_response_time: 0, + sessions_using_subagent: 0, + sessions_using_mcp: 0, + total_lines_added: 0, + total_lines_removed: 0, + total_files_modified: 0, + days_active: 0, + message_hours: [], + multi_clauding: { + overlap_events: 0, + sessions_involved: 0, + user_messages_during: 0, + }, + model_usage: {}, + model_efficiency: [], + estimated_waste: 0, + }; + + const dates: string[] = []; + const activeDays = new Set(); + + // Decay weighting: half-life of 10 days for facet-derived charts + const latestTs = metas.reduce((max, m) => { + const t = new Date(m.start_time).getTime(); + return t > max ? t : max; + }, 0); + const HALF_LIFE_MS = 10 * 86400000; + const LAMBDA = Math.log(2) / HALF_LIFE_MS; + + function decayWeight(meta: SessionMeta): number { + const age = latestTs - new Date(meta.start_time).getTime(); + return Math.exp(-LAMBDA * age); + } + + function mergeWeighted(target: Record, source: Record, weight: number) { + for (const [k, v] of Object.entries(source)) { + target[k] = (target[k] ?? 0) + v * weight; + } + } + + for (const meta of metas) { + agg.total_messages += meta.user_message_count; + agg.total_duration_hours += meta.duration_minutes / 60; + agg.total_input_tokens += meta.input_tokens; + agg.total_output_tokens += meta.output_tokens; + agg.total_cost += meta.total_cost; + mergeRecord(agg.tool_counts, meta.tool_counts); + mergeRecord(agg.languages, meta.languages); + mergeRecord(agg.tool_error_categories, meta.tool_error_categories); + agg.git_commits += meta.git_commits; + agg.git_pushes += meta.git_pushes; + agg.total_interruptions += meta.user_interruptions; + agg.total_tool_errors += meta.tool_errors; + agg.total_lines_added += meta.lines_added; + agg.total_lines_removed += meta.lines_removed; + agg.total_files_modified += meta.files_modified; + agg.user_response_times.push(...meta.user_response_times); + agg.message_hours.push(...meta.message_hours); + if (meta.uses_subagent) agg.sessions_using_subagent++; + if (meta.uses_mcp) agg.sessions_using_mcp++; + + // Aggregate per-model usage + for (const [model, usage] of Object.entries(meta.model_usage ?? {})) { + if (!agg.model_usage[model]) agg.model_usage[model] = { input_tokens: 0, output_tokens: 0, cost: 0, message_count: 0, sessions: 0 }; + agg.model_usage[model]!.input_tokens += usage.input_tokens; + agg.model_usage[model]!.output_tokens += usage.output_tokens; + agg.model_usage[model]!.cost += usage.cost; + agg.model_usage[model]!.message_count += usage.message_count; + agg.model_usage[model]!.sessions++; + } + + if (meta.start_time) { + dates.push(meta.start_time); + activeDays.add(meta.start_time.slice(0, 10)); + } + + if (meta.project_path) { + const proj = meta.project_path.replace(/.*\//, "") || meta.project_path; + agg.projects[proj] = (agg.projects[proj] ?? 0) + 1; + } + + const facets = facetsMap.get(meta.session_id); + if (facets) { + agg.sessions_with_facets++; + const w = decayWeight(meta); + mergeWeighted(agg.goal_categories, facets.goal_categories, w); + if (facets.outcome) + agg.outcomes[facets.outcome] = (agg.outcomes[facets.outcome] ?? 0) + w; + mergeWeighted(agg.satisfaction, facets.user_satisfaction_counts, w); + if (facets.assistant_helpfulness) + agg.helpfulness[facets.assistant_helpfulness] = + (agg.helpfulness[facets.assistant_helpfulness] ?? 0) + w; + if (facets.session_type) + agg.session_types[facets.session_type] = + (agg.session_types[facets.session_type] ?? 0) + w; + mergeWeighted(agg.friction, facets.friction_counts, w); + if (facets.primary_success && facets.primary_success !== "none") { + agg.success[facets.primary_success] = + (agg.success[facets.primary_success] ?? 0) + w; + } + agg.session_summaries.push({ + id: meta.session_id.slice(0, 8), + date: meta.start_time.slice(0, 10), + summary: facets.brief_summary, + outcome: facets.outcome, + helpfulness: facets.assistant_helpfulness, + }); + if (facets.friction_detail?.trim()) + agg.friction_details.push(facets.friction_detail.trim()); + if (facets.user_instructions_to_assistant) { + agg.user_instructions.push(...facets.user_instructions_to_assistant); + } + } + } + + dates.sort(); + agg.date_range = { + start: dates[0]?.slice(0, 10) ?? "", + end: dates[dates.length - 1]?.slice(0, 10) ?? "", + }; + agg.days_active = activeDays.size; + agg.median_response_time = median(agg.user_response_times); + agg.avg_response_time = agg.user_response_times.length + ? agg.user_response_times.reduce((a, b) => a + b, 0) / + agg.user_response_times.length + : 0; + + // Trim to caps + agg.session_summaries = agg.session_summaries.slice(-50); + agg.friction_details = agg.friction_details.slice(0, 20); + agg.user_instructions = agg.user_instructions.slice(0, 15); + + agg.multi_clauding = detectMultiClauding( + metas.map((m) => ({ + session_id: m.session_id, + user_message_timestamps: m.user_message_timestamps, + })), + ); + + // Model efficiency analysis + const MODEL_TIERS: Record = {}; + const classifyModel = (name: string): "high" | "mid" | "low" => { + if (MODEL_TIERS[name]) return MODEL_TIERS[name]!; + const n = name.toLowerCase(); + if (n.includes("opus") || n.includes("o1") || n.includes("o3")) { + MODEL_TIERS[name] = "high"; + } else if (n.includes("haiku") || n.includes("flash") || n.includes("mini") || n.includes("gpt-4o-mini")) { + MODEL_TIERS[name] = "low"; + } else { + MODEL_TIERS[name] = "mid"; + } + return MODEL_TIERS[name]!; + }; + + const COMPLEX_TYPES = new Set(["multi_task", "iterative_refinement"]); + const SIMPLE_TYPES = new Set(["quick_question", "single_task"]); + + let estimatedWaste = 0; + + for (const meta of metas) { + const facets = facetsMap.get(meta.session_id); + if (!facets) continue; + + // Determine primary model (highest cost or most messages) + const models = Object.entries(meta.model_usage ?? {}); + if (!models.length) continue; + const primaryModel = models.sort((a, b) => b[1].cost - a[1].cost)[0]!; + const [modelName, modelStats] = primaryModel; + const tier = classifyModel(modelName); + + const isSimple = SIMPLE_TYPES.has(facets.session_type) + || (meta.user_message_count <= 3 && meta.duration_minutes < 5); + const isComplex = COMPLEX_TYPES.has(facets.session_type) + || meta.user_message_count > 8 + || meta.files_modified > 5; + const poorOutcome = facets.outcome === "not_achieved" || facets.outcome === "partially_achieved"; + const goodOutcome = facets.outcome === "fully_achieved" || facets.outcome === "mostly_achieved"; + + let flag: "overspend" | "underspend" | "ok" = "ok"; + let reason = ""; + + // Overspend: expensive model on simple task + if (tier === "high" && isSimple && goodOutcome) { + flag = "overspend"; + reason = `Used ${modelName} for a simple ${facets.session_type} that completed successfully. A cheaper model would likely suffice.`; + estimatedWaste += modelStats.cost * 0.8; + } + // Underspend: cheap model on complex task with poor outcome + else if (tier === "low" && isComplex && poorOutcome) { + flag = "underspend"; + reason = `Used ${modelName} for a complex ${facets.session_type} that ended with ${facets.outcome}. A stronger model may have succeeded.`; + estimatedWaste += modelStats.cost; + } + // Overspend: expensive model on ANY task with poor outcome (wasted tokens) + else if (tier === "high" && poorOutcome && modelStats.cost > 0.10) { + flag = "overspend"; + reason = `Spent $${modelStats.cost.toFixed(2)} on ${modelName} but outcome was ${facets.outcome}. Tokens were burned without reaching the goal.`; + estimatedWaste += modelStats.cost * 0.5; + } + + if (flag !== "ok") { + agg.model_efficiency.push({ + model: modelName, + session_id: meta.session_id, + date: meta.start_time.slice(0, 10), + cost: modelStats.cost, + outcome: facets.outcome, + session_type: facets.session_type, + goal: facets.underlying_goal?.slice(0, 80) ?? "", + flag, + reason, + }); + } + } + + agg.estimated_waste = estimatedWaste; + agg.model_efficiency.sort((a, b) => b.cost - a.cost); + agg.model_efficiency = agg.model_efficiency.slice(0, 20); + + return agg; +} + +// ─── LLM Calling ───────────────────────────────────────────────────────────── + +async function callModel( + ctx: ExtensionCommandContext, + prompt: string, + _maxTokens?: number, +): Promise { + const model = ctx.model; + if (!model) throw new Error("No active model"); + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model as never); + if (!auth.ok) throw new Error(auth.error); + const apiKey = auth.apiKey ?? ""; + const headers = auth.headers; + + const response = await complete( + model as never, + { + messages: [ + { + role: "user", + content: [{ type: "text", text: prompt }], + timestamp: Date.now(), + }, + ], + }, + { apiKey, headers }, + ); + + return response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); +} + +function parseJsonFromResponse(text: string): unknown { + const match = text.match(/\{[\s\S]*\}/); + if (!match) return null; + try { + return JSON.parse(match[0]); + } catch { + return null; + } +} + +// ─── Prompts ────────────────────────────────────────────────────────────────── + +const CHUNK_SUMMARIZE_PROMPT = `Summarize this portion of a session transcript. Focus on: +1. What the user asked for +2. What the assistant did (tools used, files modified) +3. Any friction or issues +4. The outcome + +Keep it concise - 3-5 sentences. Preserve specific details like file names, error messages, and user feedback. + +TRANSCRIPT CHUNK: +`; + +const FACET_EXTRACT_PROMPT = `Analyze this session and extract structured facets. + +CRITICAL GUIDELINES: + +1. goal_categories: Count ONLY what the USER explicitly asked for. + - DO NOT count autonomous exploration the assistant decided to do + - ONLY count when user says "can you...", "please...", "I need...", "let's..." + +2. user_satisfaction_counts: Base ONLY on explicit user signals. + - "Yay!", "great!", "perfect!" → happy + - "thanks", "looks good", "that works" → satisfied + - "ok, now let's..." (continuing without complaint) → likely_satisfied + - "that's not right", "try again" → dissatisfied + - "this is broken", "I give up" → frustrated + +3. friction_counts: Be specific about what went wrong. + - misunderstood_request: assistant interpreted the request incorrectly + - wrong_approach: right goal, wrong solution method + - buggy_code: code didn't work correctly + - user_rejected_action: user said no/stop to a proposed action + - excessive_changes: over-engineered or changed too much + +4. user_instructions_to_assistant: direct instructions the user gave, e.g. "always show diffs before editing". Include only reusable instructions (not one-off requests). + +5. If very short or just a warmup, use warmup_minimal for goal_category + +SESSION: +`; + +function buildSharedDataBlock(agg: AggregatedData, temporal: TemporalData, userCtx: UserContext): string { + return ( + JSON.stringify( + { + sessions: agg.total_sessions, + analyzed: agg.sessions_with_facets, + date_range: agg.date_range, + messages: agg.total_messages, + hours: Math.round(agg.total_duration_hours), + commits: agg.git_commits, + cost_usd: agg.total_cost.toFixed(2), + top_tools: top8(agg.tool_counts), + top_goals: top8(agg.goal_categories), + outcomes: agg.outcomes, + satisfaction: agg.satisfaction, + friction: agg.friction, + success: agg.success, + languages: agg.languages, + lines_added: agg.total_lines_added, + lines_removed: agg.total_lines_removed, + files_modified: agg.total_files_modified, + multi_clauding: agg.multi_clauding, + subagent_sessions: agg.sessions_using_subagent, + mcp_sessions: agg.sessions_using_mcp, + model_usage: agg.model_usage, + model_efficiency_flags: agg.model_efficiency.length, + estimated_waste_usd: agg.estimated_waste.toFixed(2), + }, + null, + 2, + ) + + ` + +SESSION SUMMARIES: +${agg.session_summaries.map((s) => `- ${s.summary} (${s.outcome}, ${s.helpfulness})`).join("\n")} + +FRICTION DETAILS: +${agg.friction_details.map((d) => `- ${d}`).join("\n")} + +USER INSTRUCTIONS TO ASSISTANT: +${agg.user_instructions.map((i) => `- ${i}`).join("\n")}` + + `\n\nTEMPORAL CONTEXT:\n${temporal.diff_headlines.length ? "What changed this week: " + temporal.diff_headlines.join("; ") : "No significant weekly changes."}\nTrajectory: ${temporal.trajectory.note}\n${temporal.major_transition ? "Major transition on " + temporal.major_transition.when + ": " + temporal.major_transition.what + " (" + temporal.major_transition.impact + ")" : ""}\n${temporal.anomalies.length ? "Notable outlier sessions: " + temporal.anomalies.map(a => a.date + " " + a.cost + " - " + a.reason).join("; ") : ""}\nResolved friction (DO NOT suggest fixes): ${temporal.resolved_friction.map(f => displayLabel(f)).join(", ") || "none"}\nOngoing friction (FOCUS here): ${temporal.ongoing_friction.map(f => displayLabel(f.type) + " (" + f.recent_count + " in last 14d)").join(", ") || "none"}\n\nUSER EXISTING SETUP (DO NOT suggest what's already present):\nDefault model: ${userCtx.default_model || "not set"}\nPackages: ${userCtx.installed_packages.join(", ") || "none"}\nSkills: ${userCtx.installed_skills.join(", ") || "none"}\nExtensions: ${userCtx.installed_extensions.join(", ") || "none"}\nExisting AGENTS.md rules: ${userCtx.existing_agents_md_rules.slice(0, 10).join(" | ") || "none"}` + ); +} + +const PI_FEATURES_REFERENCE = `## PI FEATURES REFERENCE: +1. Extensions — TypeScript modules in ~/.pi/agent/extensions/ that register custom tools, commands, shortcuts, and react to lifecycle events + - Good for: automating repetitive actions, gating dangerous operations, custom UI, external integrations + +2. Skills — Markdown prompt templates in ~/.pi/agent/skills/ invoked with /skill:name + - Good for: repeatable workflows like code review, commit message generation, debugging guides + +3. Subagents (via pi-subagents extension) — spawn focused agents for parallel/exploratory work + - Good for: large codebase exploration, parallel tasks, multi-step investigations + +4. Lifecycle hooks (via extensions) — react to tool_call, tool_result, before_agent_start events + - Good for: auto-formatting, type checks, permission gates, auto-commit checkpoints + +5. AGENTS.md / SYSTEM.md — project-specific context files loaded automatically + - Good for: team conventions, architecture notes, coding standards the assistant always follows + +6. Settings (settings.json) — default model, packages, custom providers + - Good for: standardizing across projects, pinning a model, enabling packages`; + +function buildSectionPrompts(data: string, temporal: TemporalData, userCtx: UserContext) { + return { + project_areas: `Analyze this usage data and identify project areas. + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "areas": [ + { + "name": "area name", + "session_count": N, + "description": "2-3 sentences about what was worked on and how Pi was used" + } + ] +} + +Include 4-5 areas. Skip internal tooling sessions. + +DATA: +${data}`, + + interaction_style: `Analyze this usage data and describe the user's interaction style with Pi. + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "narrative": "2-3 paragraphs analyzing HOW the user interacts. Use second person 'you'. Describe patterns: do they iterate quickly or write detailed specs upfront? Do they interrupt often or let it run? Include specific examples. Use **bold** for key insights.", + "key_pattern": "one sentence summary of the most distinctive interaction style" +} + +DATA: +${data}`, + + what_works: `Analyze this usage data and identify what's working well for this user with Pi. +Use second person ("you"). + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "intro": "1 sentence of context", + "impressive_workflows": [ + { + "title": "short title (3-6 words)", + "description": "2-3 sentences describing the workflow. Use 'you' not 'the user'." + } + ] +} + +Include 3 impressive workflows. + +DATA: +${data}`, + + friction_analysis: `Analyze this usage data and identify friction points for this user. +Use second person ("you"). + +TEMPORAL CONTEXT: +- Resolved friction (no longer occurring): ${temporal.resolved_friction.map(f => displayLabel(f)).join(", ") || "none detected"} +- Ongoing friction (still happening): ${temporal.ongoing_friction.map(f => displayLabel(f.type) + " (" + f.recent_count + " in last 14 days)").join(", ") || "none detected"} + +Focus on ONGOING friction. Mention resolved items briefly as wins. + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "intro": "1 sentence summarizing friction trajectory (improving/worsening/stable)", + "resolved": [ + { + "category": "friction that stopped", + "note": "brief note on resolution" + } + ], + "ongoing": [ + { + "category": "concrete category name", + "description": "1-2 sentences. Use 'you' not 'the user'.", + "examples": ["specific example with consequence", "another example"], + "severity": "high|medium|low" + } + ] +} + +Max 2 resolved, 3 ongoing. + +DATA: +${data}`, + + suggestions: `Analyze this usage data and suggest improvements for working with Pi. + +${PI_FEATURES_REFERENCE} + +CRITICAL: The user's existing setup is in the data below. DO NOT suggest: +- Rules already in their AGENTS.md +- Skills/extensions/packages they already have installed +- Fixes for "resolved friction" (listed in TEMPORAL CONTEXT) +FOCUS on ongoing friction. Include at least one NEGATIVE suggestion (something to stop/remove). +Tailor copyable prompts to their actual model (${userCtx.default_model || "unknown"}) and projects. + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "config_additions": [ + { + "addition": "a specific rule NOT already in their AGENTS.md", + "why": "1 sentence referencing actual ongoing friction", + "where": "AGENTS.md | settings.json | ~/.pi/agent/extensions/ | ~/.pi/agent/skills/" + } + ], + "features_to_try": [ + { + "feature": "feature name from PI FEATURES REFERENCE", + "one_liner": "what it does", + "why_for_you": "why this helps YOUR ongoing friction patterns", + "example": "actual command or config referencing their real projects" + } + ], + "usage_patterns": [ + { + "title": "short title", + "suggestion": "1-2 sentence summary", + "detail": "3-4 sentences referencing actual projects and patterns", + "copyable_prompt": "specific prompt using their model, projects, tools" + } + ], + "stop_doing": [ + { + "what": "something to stop or remove", + "why": "evidence from sessions", + "alternative": "what to do instead" + } + ] +} + +DATA: +${data}`, + + on_the_horizon: `Analyze this usage data and identify future opportunities as models become more capable. + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "intro": "1 sentence about the trajectory of AI-assisted development", + "opportunities": [ + { + "title": "short title (4-8 words)", + "whats_possible": "2-3 ambitious sentences about autonomous Pi workflows", + "how_to_try": "1-2 sentences on how to start experimenting with this", + "copyable_prompt": "detailed prompt to try right now" + } + ] +} + +Include 3 opportunities. Think ambitiously — autonomous workflows, parallel subagents, self-correcting pipelines, iterating against test suites. + +DATA: +${data}`, + + fun_ending: `Analyze this usage data and find one memorable moment from the sessions. + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "headline": "a memorable QUALITATIVE moment from the transcripts — not a statistic. something human, funny, or genuinely surprising.", + "detail": "brief context about when or where this happened" +} + +Find something interesting or amusing. Avoid generic observations. + +DATA: +${data}`, + + model_efficiency: `Analyze this model usage data and identify efficiency issues. + +Model tiers: +- HIGH cost: Opus, o1, o3 (best quality, most expensive) +- MID cost: Sonnet, GPT-4o, Gemini Pro (good balance) +- LOW cost: Haiku, Flash, Mini (cheap, less capable) + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "summary": "2-3 sentences summarizing model usage efficiency. Use 'you'. Be direct about waste.", + "overspend_pattern": "1-2 sentences about when expensive models are used unnecessarily, or empty string if none", + "underspend_pattern": "1-2 sentences about when cheap models fail on complex tasks, or empty string if none", + "recommendation": "1-2 sentences with a specific model selection strategy for this user", + "potential_savings_note": "1 sentence about how much could be saved with better model selection" +} + +DATA: +${data}`, + }; +} + +function buildSynthesisPrompt( + data: string, + sections: Record, +): string { + return `You're writing an "At a Glance" section for a Pi usage insights report. The goal is to help the user understand their patterns and improve how they work with AI assistance. + +Use this 4-part structure: + +1. What's working + What is the user's distinctive style and what impactful things have they done? Keep it high level. Don't be flattering or fluffy. Don't focus on which tools they use. + +2. What's hindering you + Split into two parts: + (a) assistant-side failures — misunderstandings, wrong approaches, buggy output + (b) user-side friction — insufficient context, environment issues, setup problems + Be honest and constructive. Aim for patterns, not one-off incidents. + +3. Quick wins to try + Specific Pi features or workflow changes they could adopt immediately. Avoid generic advice — suggest concrete things. + +4. Ambitious workflows + As models become significantly more capable, what workflows that feel out of reach today will become practical? + +Keep each part to 2-3 sentences. Coaching tone, not report tone. Don't cite specific numbers or raw category names. + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "whats_working": "...", + "whats_hindering": "...", + "quick_wins": "...", + "ambitious_workflows": "..." +} + +DATA: +${data} + +## Project Areas +${JSON.stringify((sections.project_areas as { areas?: unknown })?.areas ?? [], null, 2)} + +## Impressive Workflows +${JSON.stringify((sections.what_works as { impressive_workflows?: unknown })?.impressive_workflows ?? [], null, 2)} + +## Friction Categories +${JSON.stringify((sections.friction_analysis as { categories?: unknown })?.categories ?? [], null, 2)} + +## Features to Try +${JSON.stringify((sections.suggestions as { features_to_try?: unknown })?.features_to_try ?? [], null, 2)} + +## Usage Patterns +${JSON.stringify((sections.suggestions as { usage_patterns?: unknown })?.usage_patterns ?? [], null, 2)} + +## On the Horizon +${JSON.stringify((sections.on_the_horizon as { opportunities?: unknown })?.opportunities ?? [], null, 2)}`; +} + +// ─── HTML Generation ────────────────────────────────────────────────────────── + +function esc(s: unknown): string { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderMarkdown(text: string): string { + return text + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\n\n/g, "

") + .replace(/\n/g, "
") + .replace(/^- /gm, "• "); +} + +function wrapP(text: string): string { + return `

${renderMarkdown(esc(text))}

`; +} + +function barChart( + data: Record, + opts: { order?: string[]; limit?: number } = {}, +): string { + let entries: [string, number][]; + if (opts.order) { + entries = opts.order + .filter((k) => k in data && data[k]! > 0) + .map((k) => [k, data[k]!]); + } else { + entries = Object.entries(data).sort((a, b) => b[1] - a[1]); + if (opts.limit) entries = entries.slice(0, opts.limit); + } + if (!entries.length) return "

No data

"; + const max = Math.max(...entries.map(([, v]) => v)); + return entries + .map(([key, val]) => { + const pct = max > 0 ? (val / max) * 100 : 0; + return `
+
${esc(displayLabel(key))}
+
+
${Math.round(val)}
+
`; + }) + .join("\n"); +} + +function timeOfDayChart(hours: number[]): string { + const buckets: Record = {}; + for (let h = 0; h < 24; h++) buckets[String(h).padStart(2, "0") + ":00"] = 0; + for (const h of hours) { + const key = String(h).padStart(2, "0") + ":00"; + buckets[key] = (buckets[key] ?? 0) + 1; + } + const max = Math.max(...Object.values(buckets)); + return Object.entries(buckets) + .map(([label, val]) => { + const pct = max > 0 ? (val / max) * 100 : 0; + return `
+
${esc(label)}
+
+
${val || ""}
+
`; + }) + .join("\n"); +} + +function responseTimeChart(times: number[]): string { + const buckets: Record = { + "2–10s": 0, + "10–30s": 0, + "30s–1m": 0, + "1–2m": 0, + "2–5m": 0, + "5–15m": 0, + ">15m": 0, + }; + for (const t of times) { + if (t < 10) buckets["2–10s"]!++; + else if (t < 30) buckets["10–30s"]!++; + else if (t < 60) buckets["30s–1m"]!++; + else if (t < 120) buckets["1–2m"]!++; + else if (t < 300) buckets["2–5m"]!++; + else if (t < 900) buckets["5–15m"]!++; + else buckets[">15m"]!++; + } + const max = Math.max(...Object.values(buckets)); + return Object.entries(buckets) + .map(([label, val]) => { + const pct = max > 0 ? (val / max) * 100 : 0; + return `
+
${esc(label)}
+
+
${val || ""}
+
`; + }) + .join("\n"); +} + +function statCard(label: string, value: string, sub?: string): string { + return `
+
${esc(value)}
+
${esc(label)}
+ ${sub ? `
${esc(sub)}
` : ""} +
`; +} + +function fmtHours(h: number): string { + if (h < 1) return `${Math.round(h * 60)}m`; + return `${h.toFixed(1)}h`; +} + +function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${Math.round(n / 1_000)}k`; + return String(n); +} + +function fmtCost(n: number): string { + if (n < 0.01) return `<$0.01`; + return `$${n.toFixed(2)}`; +} + +function generateMarkdown( + agg: AggregatedData, + sections: Record, + synthesis: Record, + temporal: TemporalData, +): string { + const lines: string[] = []; + lines.push("# Pi Insights"); + lines.push(`> ${agg.date_range.start} to ${agg.date_range.end} | ${agg.total_sessions} sessions | Generated ${new Date().toLocaleDateString()}`); + lines.push(""); + + if (temporal.diff_headlines.length) { + lines.push("## \u{1F4C8} What Changed This Week"); + for (const h of temporal.diff_headlines) lines.push(`- ${h}`); + if (temporal.major_transition) lines.push(`- **Major shift (${temporal.major_transition.when}):** ${temporal.major_transition.what}. ${temporal.major_transition.impact}`); + lines.push(""); + } + + lines.push("## \u26A1 Summary"); + if (synthesis.whats_working) lines.push(`**What's working:** ${synthesis.whats_working}`); + if (synthesis.whats_hindering) lines.push(`\n**What's hindering you:** ${synthesis.whats_hindering}`); + if (synthesis.quick_wins) lines.push(`\n**Quick wins:** ${synthesis.quick_wins}`); + if (synthesis.ambitious_workflows) lines.push(`\n**Ambitious workflows:** ${synthesis.ambitious_workflows}`); + lines.push(""); + + lines.push("## \u{1F4CA} By the Numbers"); + lines.push(`| Metric | Value |`); + lines.push(`|--------|-------|`); + lines.push(`| Sessions | ${agg.total_sessions} (${agg.days_active} active days) |`); + lines.push(`| Messages | ${agg.total_messages} |`); + lines.push(`| Total Cost | $${agg.total_cost.toFixed(2)} |`); + lines.push(`| Tokens In | ${fmtTokens(agg.total_input_tokens)} |`); + lines.push(`| Tokens Out | ${fmtTokens(agg.total_output_tokens)} |`); + lines.push(`| Lines Added | ${agg.total_lines_added} |`); + lines.push(`| Git Commits | ${agg.git_commits} |`); + lines.push(`| Tool Errors | ${agg.total_tool_errors} |`); + lines.push(""); + + const areas = (sections.project_areas as { areas?: Array<{ name: string; session_count: number; description: string }> })?.areas ?? []; + if (areas.length) { + lines.push("## \u{1F5C2}\uFE0F Where You Worked"); + for (const a of areas) lines.push(`- **${a.name}** (${a.session_count} sessions): ${a.description}`); + lines.push(""); + } + + const iStyle = sections.interaction_style as { narrative?: string; key_pattern?: string } | undefined; + if (iStyle?.narrative) { + lines.push("## \u{1F3AF} How You Work"); + lines.push(iStyle.narrative); + if (iStyle.key_pattern) lines.push(`\n> ${iStyle.key_pattern}`); + lines.push(""); + } + + const whatWorks = sections.what_works as { impressive_workflows?: Array<{ title: string; description: string }> } | undefined; + if (whatWorks?.impressive_workflows?.length) { + lines.push("## \u2728 Wins"); + for (const w of whatWorks.impressive_workflows) lines.push(`- **${w.title}**: ${w.description}`); + lines.push(""); + } + + const frictionSec = sections.friction_analysis as { intro?: string; resolved?: Array<{ category: string; note: string }>; ongoing?: Array<{ category: string; description: string; examples: string[] }>; categories?: Array<{ category: string; description: string; examples: string[] }> } | undefined; + if (frictionSec) { + lines.push("## \u26A0\uFE0F Where Things Broke"); + if (frictionSec.intro) lines.push(frictionSec.intro); + if (frictionSec.resolved?.length) { + lines.push("\n**Resolved:**"); + for (const r of frictionSec.resolved) lines.push(`- \u2705 ${r.category}: ${r.note}`); + } + const ongoing = frictionSec.ongoing ?? frictionSec.categories ?? []; + if (ongoing.length) { + lines.push("\n**Ongoing:**"); + for (const o of ongoing) { + lines.push(`- **${o.category}**: ${o.description}`); + for (const ex of o.examples ?? []) lines.push(` - ${ex}`); + } + } + lines.push(""); + } + + const suggSec = sections.suggestions as { config_additions?: Array<{ addition: string; why: string; where: string }>; features_to_try?: Array<{ feature: string; why_for_you: string; example: string }>; usage_patterns?: Array<{ title: string; detail: string; copyable_prompt: string }>; stop_doing?: Array<{ what: string; why: string; alternative: string }> } | undefined; + if (suggSec) { + lines.push("## \u{1F4A1} Next Steps"); + if (suggSec.config_additions?.length) { + lines.push("**Config additions:**"); + for (const c of suggSec.config_additions) lines.push(`- \`${c.where}\`: ${c.addition} (${c.why})`); + } + if (suggSec.features_to_try?.length) { + lines.push("\n**Features to try:**"); + for (const f of suggSec.features_to_try) lines.push(`- **${f.feature}**: ${f.why_for_you}\n \`\`\`\n ${f.example}\n \`\`\``); + } + if (suggSec.usage_patterns?.length) { + lines.push("\n**Usage patterns:**"); + for (const p of suggSec.usage_patterns) lines.push(`- **${p.title}**: ${p.detail}\n \`\`\`\n ${p.copyable_prompt}\n \`\`\``); + } + if (suggSec.stop_doing?.length) { + lines.push("\n**\u{1F6D1} Stop doing:**"); + for (const s of suggSec.stop_doing) lines.push(`- **${s.what}**: ${s.why}. Instead: ${s.alternative}`); + } + lines.push(""); + } + + const horizonSec = sections.on_the_horizon as { opportunities?: Array<{ title: string; whats_possible: string; copyable_prompt: string }> } | undefined; + if (horizonSec?.opportunities?.length) { + lines.push("## \u{1F680} Future Workflows"); + for (const o of horizonSec.opportunities) lines.push(`- **${o.title}**: ${o.whats_possible}\n \`\`\`\n ${o.copyable_prompt}\n \`\`\``); + lines.push(""); + } + + lines.push("## \u{1F4B8} Model Spend"); + lines.push(`| Model | Cost | Messages |`); + lines.push(`|-------|------|----------|`); + for (const [model, usage] of Object.entries(agg.model_usage).sort((a, b) => b[1].cost - a[1].cost).slice(0, 8)) { + lines.push(`| ${model.replace(/.*\./, "")} | $${usage.cost.toFixed(2)} | ${usage.message_count} |`); + } + if (agg.estimated_waste > 0) lines.push(`\n**Estimated waste from model mismatch:** $${agg.estimated_waste.toFixed(2)}`); + lines.push(""); + + return lines.join("\n"); +} + +function generateHTML( + agg: AggregatedData, + sections: Record, + synthesis: Record, + temporal: TemporalData, +): string { + const areas = + ( + sections.project_areas as { + areas?: Array<{ + name: string; + session_count: number; + description: string; + }>; + } + )?.areas ?? []; + const iStyle = sections.interaction_style as + | { narrative?: string; key_pattern?: string } + | undefined; + const whatWorks = sections.what_works as + | { + intro?: string; + impressive_workflows?: Array<{ title: string; description: string }>; + } + | undefined; + const frictionSec = sections.friction_analysis as + | { + intro?: string; + categories?: Array<{ category: string; description: string; examples: string[] }>; + resolved?: Array<{ category: string; note: string }>; + ongoing?: Array<{ category: string; description: string; examples: string[]; severity?: string }>; + } + | undefined; + const suggSec = sections.suggestions as + | { + config_additions?: Array<{ addition: string; why: string; where: string }>; + features_to_try?: Array<{ feature: string; one_liner: string; why_for_you: string; example: string }>; + usage_patterns?: Array<{ title: string; suggestion: string; detail: string; copyable_prompt: string }>; + stop_doing?: Array<{ what: string; why: string; alternative: string }>; + } + | undefined; + const horizonSec = sections.on_the_horizon as + | { + intro?: string; + opportunities?: Array<{ + title: string; + whats_possible: string; + how_to_try: string; + copyable_prompt: string; + }>; + } + | undefined; + const funSec = sections.fun_ending as + | { headline?: string; detail?: string } + | undefined; + const modelEffSec = sections.model_efficiency as + | { summary?: string; overspend_pattern?: string; underspend_pattern?: string; recommendation?: string; potential_savings_note?: string } + | undefined; + + const topTools = top8(agg.tool_counts); + const topGoals = top8(agg.goal_categories); + + const configAdditions = suggSec?.config_additions ?? []; + const featuresToTry = suggSec?.features_to_try ?? []; + const usagePatterns = suggSec?.usage_patterns ?? []; + + return ` + + + + +Pi Insights — ${esc(agg.date_range.start)} to ${esc(agg.date_range.end)} + + + +
+ +
+

🔍 Pi Insights

+
+ ${esc(agg.date_range.start)} – ${esc(agg.date_range.end)} +  ·  + ${agg.total_sessions} sessions +  ·  + Generated ${new Date().toLocaleDateString()} +
+
+ + + +${temporal.diff_headlines.length ? ` +
+

\u{1F4C8} What Changed This Week

+
+ ${temporal.diff_headlines.map(h => `
${esc(h)}
`).join("\n ")} +
+ ${temporal.major_transition ? `
Major shift (${esc(temporal.major_transition.when)}): ${esc(temporal.major_transition.what)}. Impact: ${esc(temporal.major_transition.impact)}
` : ""} +
` : ""} + + +
+

Summary

+
+
+

What's Working

+ ${wrapP(synthesis.whats_working ?? "")} +
+
+

What's Hindering You

+ ${wrapP(synthesis.whats_hindering ?? "")} +
+
+

Quick Wins to Try

+ ${wrapP(synthesis.quick_wins ?? "")} +
+
+

Ambitious Workflows

+ ${wrapP(synthesis.ambitious_workflows ?? "")} +
+
+
+ + +
+

📊 By the Numbers

+
+ ${statCard("Sessions", String(agg.total_sessions), `${agg.days_active} active days`)} + ${statCard("Messages", String(agg.total_messages), `${(agg.total_messages / Math.max(agg.total_sessions, 1)).toFixed(1)} per session`)} + ${statCard("Active Time", fmtHours(agg.total_duration_hours), `${(agg.total_duration_hours / Math.max(agg.days_active, 1)).toFixed(1)}h/day`)} + ${statCard("Tokens In", fmtTokens(agg.total_input_tokens), "")} + ${statCard("Tokens Out", fmtTokens(agg.total_output_tokens), "")} + ${statCard("Total Cost", fmtCost(agg.total_cost), "")} + ${statCard("Lines Added", fmtTokens(agg.total_lines_added), "")} + ${statCard("Lines Removed", fmtTokens(agg.total_lines_removed), "")} + ${statCard("Git Commits", String(agg.git_commits), `${agg.git_pushes} pushes`)} + ${statCard("Files Modified", fmtTokens(agg.total_files_modified), "")} + ${statCard("Tool Errors", String(agg.total_tool_errors), "")} + ${statCard("Interruptions", String(agg.total_interruptions), "")} + ${agg.sessions_using_subagent ? statCard("Subagent Sessions", String(agg.sessions_using_subagent), "") : ""} + ${agg.sessions_using_mcp ? statCard("MCP Sessions", String(agg.sessions_using_mcp), "") : ""} + ${agg.multi_clauding.overlap_events ? statCard("Parallel Sessions", String(agg.multi_clauding.overlap_events), "overlap events") : ""} +
+ +
+
+

Goal Categories

+ ${barChart(agg.goal_categories, { limit: 10 })} +
+
+

Outcomes

+ ${barChart(agg.outcomes, { order: OUTCOME_ORDER })} +
+
+

Satisfaction

+ ${barChart(agg.satisfaction, { order: SATISFACTION_ORDER })} +
+
+

Top Tools

+ ${barChart(agg.tool_counts, { limit: 10 })} +
+
+

Languages

+ ${barChart(agg.languages, { limit: 10 })} +
+
+

Friction Types

+ ${barChart(agg.friction, { limit: 10 })} +
+
+

Tool Errors

+ ${barChart(agg.tool_error_categories)} +
+
+

Response Times

+ ${responseTimeChart(agg.user_response_times)} +
+
+

Time of Day

+ ${timeOfDayChart(agg.message_hours)} +
+
+
+ + +
+

🗂️ Where You Worked

+
+ ${areas + .map( + (a) => `
+

${esc(a.name)}${a.session_count} sessions

+

${esc(a.description)}

+
`, + ) + .join("\n")} +
+
+ + +
+

🎯 How You Work

+
+ ${iStyle?.narrative ? `
${renderMarkdown(esc(iStyle.narrative))}
` : "

No data

"} + ${iStyle?.key_pattern ? `
"${esc(iStyle.key_pattern)}"
` : ""} +
+
+ + +
+

Wins

+ ${whatWorks?.intro ? `

${esc(whatWorks.intro)}

` : ""} +
+ ${(whatWorks?.impressive_workflows ?? []) + .map( + (w) => `
+

${esc(w.title)}

+

${esc(w.description)}

+
`, + ) + .join("\n")} +
+
+ + +
+

⚠️ Where Things Broke

+ ${frictionSec?.intro ? `

${esc(frictionSec.intro)}

` : ""} + ${(frictionSec?.resolved?.length) ? `
+

\u2705 Resolved

+ ${frictionSec.resolved.map(r => `
${esc(r.category)} \u2014 ${esc(r.note)}
`).join("\n")} +
` : ""} +
+ ${((frictionSec?.ongoing ?? frictionSec?.categories) ?? []) + .map( + (cat) => `
+

${esc(cat.category)}${(cat as any).severity ? ` ${(cat as any).severity}` : ""}

+

${esc(cat.description)}

+
+ ${(cat.examples ?? []).map((ex) => `
${esc(ex)}
`).join("")} +
+
`, + ) + .join("\n")} +
+
+ + +
+

💡 Next Steps

+ + ${ + configAdditions.length + ? `

Config Additions

+

Select the ones you want, then copy them all at once.

+
+ ${configAdditions + .map( + ( + c, + i, + ) => `
+ +
`, + ) + .join("\n")} +
+ + ` + : "" + } + + ${ + featuresToTry.length + ? `

Features to Try

+
+ ${featuresToTry + .map( + (f) => `
+
${esc(f.feature)}
+

${esc(f.one_liner)}

+

${esc(f.why_for_you)}

+
${esc(f.example)}
+ +
`, + ) + .join("\n")} +
` + : "" + } + + ${ + usagePatterns.length + ? `

Usage Patterns

+
+ ${usagePatterns + .map( + (p) => `
+

${esc(p.title)}

+

${esc(p.suggestion)}

+

${esc(p.detail)}

+
${esc(p.copyable_prompt)}
+ +
`, + ) + .join("\n")} +
` + : "" + } + + ${(suggSec?.stop_doing?.length) ? `

\u{1F6D1} Consider Stopping

+
+ ${suggSec.stop_doing.map(s => `
+

${esc(s.what)}

+

${esc(s.why)}

+

Instead: ${esc(s.alternative)}

+
`).join("\n")} +
` : ""} +
+ + +
+

🚀 Future Workflows

+ ${horizonSec?.intro ? `

${esc(horizonSec.intro)}

` : ""} +
+ ${(horizonSec?.opportunities ?? []) + .map( + (o) => `
+

${esc(o.title)}

+

${esc(o.whats_possible)}

+

${esc(o.how_to_try)}

+
${esc(o.copyable_prompt)}
+ +
`, + ) + .join("\n")} +
+
+ + +
+

💸 Model Spend

+ ${modelEffSec?.summary ? `

${esc(modelEffSec.summary)}

` : ""} + +
+ ${statCard("Estimated Waste", fmtCost(agg.estimated_waste), "from model mismatch")} + ${statCard("Efficiency Flags", String(agg.model_efficiency.length), `${agg.model_efficiency.filter(e => e.flag === "overspend").length} overspend, ${agg.model_efficiency.filter(e => e.flag === "underspend").length} underspend`)} + ${statCard("Models Used", String(Object.keys(agg.model_usage).length), "")} +
+ +
+
+

Cost by Model

+ ${barChart(Object.fromEntries(Object.entries(agg.model_usage).map(([k, v]) => [k, Math.round(v.cost * 100)])), { limit: 8 })} +

Values in cents

+
+
+

Messages by Model

+ ${barChart(Object.fromEntries(Object.entries(agg.model_usage).map(([k, v]) => [k, v.message_count])), { limit: 8 })} +
+
+ + ${modelEffSec?.overspend_pattern ? `
+

Overspend Pattern

+

${esc(modelEffSec.overspend_pattern)}

+
` : ""} + + ${modelEffSec?.underspend_pattern ? `
+

Underspend Pattern

+

${esc(modelEffSec.underspend_pattern)}

+
` : ""} + + ${modelEffSec?.recommendation ? `
+

Recommendation

+

${esc(modelEffSec.recommendation)}

+ ${modelEffSec.potential_savings_note ? `

${esc(modelEffSec.potential_savings_note)}

` : ""} +
` : ""} + + ${agg.model_efficiency.length ? `

Flagged Sessions

+
+ ${agg.model_efficiency.slice(0, 10).map(e => `
+
+
+ ${esc(e.flag)} + ${esc(e.date)} · ${esc(e.model)} +
+ ${fmtCost(e.cost)} +
+

${esc(e.reason)}

+

${esc(e.goal)}

+
`).join("\n")} +
` : ""} +
+ + +${ + funSec?.headline + ? `
+
+
${esc(funSec.headline)}
+ ${funSec.detail ? `
${esc(funSec.detail)}
` : ""} +
+
` + : "" +} + +
+ + +`; +} + +// ─── Main Command Handler ───────────────────────────────────────────────────── + +async function runInsights( + args: string, + ctx: ExtensionCommandContext, +): Promise { + const refresh = args.includes("--refresh") || args.includes("-r"); + const noOpen = args.includes("--no-open"); + const formatMd = args.includes("--format md") || args.includes("--md"); + + // Parse --since flag (e.g. --since 7d, --since 14d, --since 30d) + const sinceMatch = args.match(/--since\s+(\d+)d/); + const sinceDays = sinceMatch ? parseInt(sinceMatch[1]!, 10) : 0; + + if (!ctx.model) { + ctx.ui.notify("No active model — set a model first (/model)", "error"); + return; + } + + await ensureDirs(); + + const currentSessionId = ctx.sessionManager.getSessionId() ?? ""; + + // ── Phase 1: Scan ──────────────────────────────────────────────────────────── + ctx.ui.setStatus("insights", "🔍 Scanning sessions..."); + ctx.ui.setWidget("insights", [ + "", + " 📊 Pi Insights", + " ─────────────────────────────────", + " Phase 1/5: Scanning session files...", + ]); + + let allInfos = await SessionManager.listAll(); + + // Filter current session and meta-sessions by ID + allInfos = allInfos.filter((info) => info.id !== currentSessionId); + + ctx.ui.setWidget("insights", [ + "", + " 📊 Pi Insights", + " ─────────────────────────────────", + ` Phase 1/5 done — found ${allInfos.length} sessions`, + " Phase 2/5: Extracting session stats...", + ]); + + // ── Phase 2: Session Metadata ──────────────────────────────────────────────── + const metas: SessionMeta[] = []; + + // Load cached metas first (batch) + const cachedMetaIds = new Set(); + for (let i = 0; i < allInfos.length; i += META_BATCH_SIZE) { + const batch = allInfos.slice(i, i + META_BATCH_SIZE); + const results = await Promise.all( + batch.map((info) => loadCachedMeta(info.id)), + ); + for (let j = 0; j < batch.length; j++) { + const cached = results[j]; + if (cached) { + cachedMetaIds.add(batch[j]!.id); + metas.push(cached); + } + } + } + + // Parse uncached sessions (up to MAX_SESSIONS_TO_LOAD) + const uncached = allInfos.filter((info) => !cachedMetaIds.has(info.id)); + const toLoad = uncached.slice(0, MAX_SESSIONS_TO_LOAD); + + let loadedCount = 0; + for (let i = 0; i < toLoad.length; i += LOAD_BATCH_SIZE) { + const batch = toLoad.slice(i, i + LOAD_BATCH_SIZE); + await Promise.all( + batch.map(async (info) => { + try { + const sm = await SessionManager.open(info.path); + const entries = sm.getEntries() as unknown as AnyEntry[]; + + if (isMetaSession(entries)) return; + + const meta = buildSessionMeta( + { + id: info.id, + path: info.path, + cwd: info.cwd, + created: info.created, + modified: info.modified, + }, + entries, + ); + await saveMeta(meta); + metas.push(meta); + } catch { + // Skip sessions that fail to load + } + loadedCount++; + }), + ); + ctx.ui.setWidget("insights", [ + "", + " 📊 Pi Insights", + " ─────────────────────────────────", + ` Phase 2/5: Loaded ${cachedMetaIds.size} cached, ${loadedCount}/${toLoad.length} new`, + ]); + } + + // Filter substantive sessions (≥2 user messages, ≥1 min) + const substantive = metas.filter( + (m) => m.user_message_count >= 2 && m.duration_minutes >= 1, + ).filter((m) => { + if (!sinceDays) return true; + const age = Date.now() - new Date(m.start_time).getTime(); + return age < sinceDays * 86400000; + }); + + ctx.ui.setWidget("insights", [ + "", + " 📊 Pi Insights", + " ─────────────────────────────────", + ` Phase 2/5 done — ${substantive.length} substantive sessions`, + " Phase 3/5: LLM facet extraction...", + ]); + + // ── Phase 3: Facet Extraction ───────────────────────────────────────────────── + const facetsMap = new Map(); + + // Load cached facets + for (const meta of substantive) { + if (refresh) { + await deleteCachedFacets(meta.session_id); + } else { + const cached = await loadCachedFacets(meta.session_id); + if (cached) facetsMap.set(meta.session_id, cached); + } + } + + // Extract new facets + const needsFacets = substantive + .filter((m) => !facetsMap.has(m.session_id)) + .slice(0, MAX_FACET_EXTRACTIONS); + + if (needsFacets.length > 0) { + let facetsDone = 0; + for (let i = 0; i < needsFacets.length; i += FACET_CONCURRENCY) { + const batch = needsFacets.slice(i, i + FACET_CONCURRENCY); + await Promise.all( + batch.map(async (meta) => { + try { + const sm = await SessionManager.open(meta.session_path); + const entries = sm.getEntries() as unknown as AnyEntry[]; + let transcript = formatTranscript(entries, meta); + + // Summarize long transcripts + if (transcript.length > 30_000) { + const CHUNK = 25_000; + const chunks: string[] = []; + for (let ci = 0; ci < transcript.length; ci += CHUNK) + chunks.push(transcript.slice(ci, ci + CHUNK)); + const summaries = await Promise.all( + chunks.map((ch) => + callModel(ctx, CHUNK_SUMMARIZE_PROMPT + ch, 500).catch(() => + ch.slice(0, 2000), + ), + ), + ); + transcript = `Session: ${meta.session_id.slice(0, 8)}\nDate: ${meta.start_time}\nProject: ${meta.project_path}\n[Long session - summarized]\n\n${summaries.join("\n\n---\n\n")}`; + } + + const prompt = `${FACET_EXTRACT_PROMPT}${transcript} + +RESPOND WITH ONLY A VALID JSON OBJECT: +{ + "underlying_goal": "...", + "goal_categories": {"category_name": count}, + "outcome": "fully_achieved|mostly_achieved|partially_achieved|not_achieved|unclear_from_transcript", + "user_satisfaction_counts": {"level": count}, + "assistant_helpfulness": "unhelpful|slightly_helpful|moderately_helpful|very_helpful|essential", + "session_type": "single_task|multi_task|iterative_refinement|exploration|quick_question", + "friction_counts": {"friction_type": count}, + "friction_detail": "one sentence or empty string", + "primary_success": "none|fast_accurate_search|correct_code_edits|good_explanations|proactive_help|multi_file_changes|good_debugging", + "brief_summary": "one sentence: what user wanted and whether they got it", + "user_instructions_to_assistant": ["instruction1", "instruction2"] +}`; + + const text = await callModel(ctx, prompt, 4096); + const parsed = parseJsonFromResponse(text) as SessionFacets | null; + if (parsed?.brief_summary) { + const facets: SessionFacets = { + ...parsed, + session_id: meta.session_id, + }; + await saveFacets(facets); + facetsMap.set(meta.session_id, facets); + } + } catch { + // Skip failed extractions + } + facetsDone++; + ctx.ui.setWidget("insights", [ + "", + " 📊 Pi Insights", + " ─────────────────────────────────", + ` Phase 3/5: Facets ${facetsDone}/${needsFacets.length}...`, + ]); + }), + ); + } + } + + // Post-facet filter: remove sessions where only goal is warmup_minimal + const kept = substantive.filter((m) => { + const facets = facetsMap.get(m.session_id); + if (!facets) return true; // keep if no facets + const cats = Object.keys(facets.goal_categories).filter( + (k) => (facets.goal_categories[k] ?? 0) > 0, + ); + return !(cats.length === 1 && cats[0] === "warmup_minimal"); + }); + + ctx.ui.setWidget("insights", [ + "", + " 📊 Pi Insights", + " ─────────────────────────────────", + ` Phase 3/5 done — ${facetsMap.size} facets extracted`, + " Phase 4/5: Generating insights...", + ]); + + // ── Phase 4: Aggregate + Insight Prompts ───────────────────────────────────── + const agg = aggregateData(kept, facetsMap); + const temporal = computeTemporalData(kept, facetsMap); + const userCtx = await gatherUserContext(); + const dataBlock = buildSharedDataBlock(agg, temporal, userCtx); + const sectionPrompts = buildSectionPrompts(dataBlock, temporal, userCtx); + + const sectionKeys = Object.keys(sectionPrompts) as Array< + keyof typeof sectionPrompts + >; + const sectionResults: Record = {}; + let sectionsDone = 0; + + await Promise.all( + sectionKeys.map(async (key) => { + try { + const text = await callModel(ctx, sectionPrompts[key], 8192); + const parsed = parseJsonFromResponse(text); + if (parsed) sectionResults[key] = parsed; + } catch { + // Section failed — continue without it + } + sectionsDone++; + ctx.ui.setWidget("insights", [ + "", + " 📊 Pi Insights", + " ─────────────────────────────────", + ` Phase 4/5: Insights ${sectionsDone}/${sectionKeys.length}...`, + ]); + }), + ); + + // Synthesis (At a Glance) + ctx.ui.setWidget("insights", [ + "", + " 📊 Pi Insights", + " ─────────────────────────────────", + " Phase 4/5: Synthesis...", + ]); + + let synthesis: Record = {}; + try { + const synthText = await callModel( + ctx, + buildSynthesisPrompt(dataBlock, sectionResults), + 8192, + ); + synthesis = + (parseJsonFromResponse(synthText) as Record) ?? {}; + } catch { + synthesis = { + whats_working: + "Analysis complete — see sections below for detailed breakdown.", + whats_hindering: "See Friction Analysis section.", + quick_wins: "See Suggestions section.", + ambitious_workflows: "See On the Horizon section.", + }; + } + + // ── Phase 5: Render HTML ────────────────────────────────────────────────────── + ctx.ui.setWidget("insights", [ + "", + " 📊 Pi Insights", + " ─────────────────────────────────", + " Phase 5/5: Rendering report...", + ]); + + const html = generateHTML(agg, sectionResults, synthesis, temporal); + await writeFile(REPORT_PATH, html, { encoding: "utf-8" }); + + if (formatMd) { + const md = generateMarkdown(agg, sectionResults, synthesis, temporal); + await writeFile(REPORT_MD_PATH, md, { encoding: "utf-8" }); + ctx.ui.setStatus("insights", ""); + ctx.ui.setWidget("insights", undefined); + ctx.ui.notify(`✅ Markdown report saved: ${REPORT_MD_PATH}`, "info"); + return; + } + + ctx.ui.setStatus("insights", ""); + ctx.ui.setWidget("insights", undefined); + + const reportUrl = await startReportServer(); + ctx.ui.notify(`✅ Report saved: ${REPORT_PATH}`, "info"); + ctx.ui.notify(`Pi Insights report URL: ${reportUrl}`, "info"); + + if (!noOpen) { + const opener = platform() === "darwin" ? "open" : "xdg-open"; + execFile(opener, [reportUrl]).catch(() => { + ctx.ui.notify(`Open manually: ${reportUrl}`, "info"); + }); + } +} + +// ─── Extension Entry ────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + pi.registerCommand("insights", { + description: + "Generate a personal usage insights report from your Pi session history", + handler: async (args, ctx) => { + try { + await runInsights(args ?? "", ctx); + } catch (err) { + ctx.ui.setStatus("insights", ""); + ctx.ui.setWidget("insights", undefined); + ctx.ui.notify(`Insights failed: ${(err as Error).message}`, "error"); + } + }, + }); +} diff --git a/.pi/extensions/reflect-skills/index.test.ts b/.pi/extensions/reflect-skills/index.test.ts new file mode 100644 index 00000000..f86f9a5b --- /dev/null +++ b/.pi/extensions/reflect-skills/index.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import reflectSkills, { readHistory, recordHistory, writeProposal, type Proposal } from "./index.ts"; + +afterEach(() => { + delete process.env.LITTLE_CODER_USER_SKILLS_DIR; + delete process.env.LITTLE_CODER_REFLECT_HISTORY; +}); + +describe("reflect-skills extension", () => { + it("registers reflection review and approval commands", () => { + const commands: string[] = []; + const pi: any = { registerCommand: (name: string) => commands.push(name) }; + reflectSkills(pi); + expect(commands).toEqual(expect.arrayContaining([ + "reflect", + "reflect-review", + "reflect-accept", + "reflect-deny", + "reflect-history", + "reflect-doctor", + ])); + }); + + it("doctor command describes bounded breadcrumbs and user skill destination", async () => { + const commands = new Map(); + const messages: string[] = []; + const pi: any = { registerCommand: (name: string, spec: any) => commands.set(name, spec) }; + reflectSkills(pi); + await commands.get("reflect-doctor").handler("", { ui: { notify: (msg: string) => messages.push(msg) } }); + expect(messages[0]).toContain("bounded breadcrumbs"); + expect(messages[0]).toContain(".pi/skills"); + }); + + it("writes accepted skills under the confined user skills root", () => { + const root = mkdtempSync(join(tmpdir(), "lc-skills-")); + process.env.LITTLE_CODER_USER_SKILLS_DIR = root; + const proposal: Proposal = { + name: "safe-skill", + createdAt: "now", + content: "---\nname: safe-skill\ndescription: Safe test skill.\ntype: workflow\ntoken_cost: 50\nkeywords: [safe]\n---\nBody\n", + }; + const file = writeProposal(proposal); + expect(file).toBe(join(root, "safe-skill", "SKILL.md")); + expect(existsSync(file)).toBe(true); + expect(readFileSync(file, "utf-8")).toContain("keywords: [safe]"); + }); + + it("rejects invalid skill slugs", () => { + const proposal: Proposal = { name: "../escape", createdAt: "now", content: "x" }; + expect(() => writeProposal(proposal)).toThrow("invalid skill slug"); + }); + + it("records reflection history", () => { + const dir = mkdtempSync(join(tmpdir(), "lc-reflect-history-")); + process.env.LITTLE_CODER_REFLECT_HISTORY = join(dir, "history.jsonl"); + recordHistory("propose", { name: "skill-one" }); + recordHistory("accept", { name: "skill-one", file: "/tmp/skill-one/SKILL.md" }); + const history = readHistory(); + expect(history).toContain("propose skill-one"); + expect(history).toContain("accept skill-one -> /tmp/skill-one/SKILL.md"); + }); +}); diff --git a/.pi/extensions/reflect-skills/index.ts b/.pi/extensions/reflect-skills/index.ts new file mode 100644 index 00000000..3d006fa5 --- /dev/null +++ b/.pi/extensions/reflect-skills/index.ts @@ -0,0 +1,165 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { outlineText, discoverSessions } from "../_shared/session-history.ts"; +import { listSkillCatalog } from "../_shared/skill-catalog.ts"; + +export interface Proposal { name: string; content: string; createdAt: string } +const queue: Proposal[] = []; +export const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80); + +function userSkillsRoot(): string { + return process.env.LITTLE_CODER_USER_SKILLS_DIR || join(homedir(), ".pi", "skills"); +} + +function historyPath(): string { + return process.env.LITTLE_CODER_REFLECT_HISTORY || join(homedir(), ".pi", "agent", "reflect-skills", "history.jsonl"); +} + +export function recordHistory(action: string, detail: Record = {}): void { + const file = historyPath(); + mkdirSync(dirname(file), { recursive: true }); + appendFileSync(file, JSON.stringify({ timestamp: new Date().toISOString(), action, ...detail }) + "\n"); +} + +export function readHistory(limit = 20): string { + try { + const lines = readFileSync(historyPath(), "utf-8").trim().split("\n").filter(Boolean).slice(-limit); + if (lines.length === 0) return "No reflection history recorded."; + return lines.map((line) => { + try { + const e = JSON.parse(line); + return `${e.timestamp} ${e.action}${e.name ? ` ${e.name}` : ""}${e.file ? ` -> ${e.file}` : ""}`; + } catch { return line; } + }).join("\n"); + } catch { return "No reflection history recorded."; } +} + +function missedInjectionSuggestions(context: string): string { + const text = context.toLowerCase(); + const suggestions: string[] = []; + for (const skill of listSkillCatalog()) { + if (skill.keywords.length === 0) continue; + const hits = skill.keywords.filter((kw) => text.includes(kw.toLowerCase())); + const reviewLike = /review|diff|uncommitted|changes|pr|pull request/.test(text) && /review|code-review|code review/.test(skill.name + " " + skill.description); + if (hits.length > 0 || reviewLike) { + const extra = reviewLike ? ["review uncommitted changes", "review the changes", "diff review"] : []; + suggestions.push(`- ${skill.name}: observed cues [${[...new Set([...hits, ...extra])].slice(0, 6).join(", ")}]`); + } + } + return suggestions.slice(0, 8).join("\n") || "- No obvious missed skill injections detected from bounded breadcrumbs."; +} + +function proposalFromRecent(guidance = ""): Proposal { + const recent = discoverSessions().find((s) => s.cwd === process.cwd()) ?? discoverSessions()[0]; + const name = slug(`session-reflection-${new Date().toISOString().slice(0, 10)}`); + const context = recent ? outlineText(recent, 8) : "No recent session history found."; + const missed = missedInjectionSuggestions(context); + return { + name, + createdAt: new Date().toISOString(), + content: `---\nname: ${name}\ndescription: User-reviewed reflection skill drafted from recent session patterns.\ntype: workflow\ntoken_cost: 120\nkeywords: [reflection, session, workflow, local]\nuser-invocable: true\n---\nReview this draft before accepting. It was generated from bounded session breadcrumbs, not full transcripts.\n\n${guidance ? `User edit guidance:\n\n${guidance}\n\n` : ""}Recent pattern summary:\n\n${context}\n\nPotential missed skill injections / keyword improvements:\n\n${missed}\n\nIf this pattern is useful, replace this paragraph with concrete reusable guidance before promoting it.\n`, + }; +} + +export function writeProposal(p: Proposal): string { + const dir = join(userSkillsRoot(), p.name); + if (!/^[a-z0-9-]+$/.test(p.name)) throw new Error("invalid skill slug"); + if (existsSync(dir)) throw new Error(`skill already exists: ${dir}`); + mkdirSync(dir, { recursive: true }); + const file = join(dir, "SKILL.md"); + writeFileSync(file, p.content); + return file; +} + +export function reflectionQueue(): Proposal[] { + return queue.map((p) => ({ ...p })); +} + +export function renderQueue(): string { + if (queue.length === 0) return "No reflection skill proposals queued. Run /reflect to draft one."; + return queue.map((p, i) => `${i + 1}. ${p.name} (${p.createdAt})\n${p.content}`).join("\n\n---\n\n"); +} + +function proposalIndex(args: string): number { + const value = args.trim().split(/\s+/)[1] ?? args.trim() ?? "1"; + const n = Number(value || "1"); + return Math.max(0, n - 1); +} + +function acceptProposal(idx: number): string { + const p = queue[idx]; + if (!p) return "No such reflection proposal."; + const file = writeProposal(p); + queue.splice(idx, 1); + recordHistory("accept", { name: p.name, file }); + return `Accepted reflection skill: ${file}`; +} + +function denyProposal(idx: number): string { + const p = queue[idx]; + if (!p) return "No such reflection proposal."; + queue.splice(idx, 1); + recordHistory("deny", { name: p.name }); + return "Reflection proposal discarded."; +} + +function editProposal(args: string): string { + const parts = args.trim().split(/\s+/); + const maybeIndex = Number(parts[1]); + const idx = Number.isFinite(maybeIndex) && maybeIndex > 0 ? maybeIndex - 1 : 0; + const guidance = parts.slice(Number.isFinite(maybeIndex) ? 2 : 1).join(" ").trim(); + const current = queue[idx]; + if (!current) return "No such reflection proposal."; + if (!guidance) return "Usage: /reflect-review edit [n] "; + queue[idx] = proposalFromRecent(guidance); + recordHistory("edit", { name: queue[idx].name, guidance }); + return `Regenerated reflection proposal ${idx + 1} with edit guidance.`; +} + +export default function (pi: ExtensionAPI) { + pi.registerCommand("reflect", { + description: "Draft a user-level skill proposal from bounded recent session breadcrumbs", + handler: async (_args, ctx) => { + const p = proposalFromRecent(); + queue.push(p); + recordHistory("propose", { name: p.name }); + const idx = queue.length; + ctx.ui?.notify?.(`Queued reflection proposal '${p.name}'.\n\n${p.content}`, "info"); + if (!ctx.hasUI || typeof ctx.ui?.select !== "function") { + ctx.ui?.notify?.(`Review with /reflect-review accept|deny|edit ${idx}.`, "info"); + return; + } + const choice = await ctx.ui.select("Reflection proposal", ["Accept", "Deny", "Edit later"]); + if (choice === "Accept") ctx.ui?.notify?.(acceptProposal(idx - 1), "info"); + else if (choice === "Deny") ctx.ui?.notify?.(denyProposal(idx - 1), "info"); + else ctx.ui?.notify?.(`Left proposal queued. Use /reflect-review edit ${idx} or /reflect-accept ${idx}.`, "info"); + }, + }); + pi.registerCommand("reflect-review", { + description: "Show queued reflection skill proposals, or /reflect-review accept|deny|edit [n]", + handler: async (args, ctx) => { + const text = String(args ?? "").trim(); + const action = text.split(/\s+/)[0]; + try { + if (action === "accept") return ctx.ui?.notify?.(acceptProposal(proposalIndex(text)), "info"); + if (action === "deny") return ctx.ui?.notify?.(denyProposal(proposalIndex(text)), "info"); + if (action === "edit") return ctx.ui?.notify?.(editProposal(text), "info"); + return ctx.ui?.notify?.(renderQueue(), "info"); + } catch (e) { + return ctx.ui?.notify?.(`Reflection review failed: ${(e as Error).message}`, "error"); + } + }, + }); + pi.registerCommand("reflect-accept", { + description: "Accept queued proposal by number and write it to ~/.pi/skills", + handler: async (args, ctx) => { + try { ctx.ui?.notify?.(acceptProposal(proposalIndex(String(args ?? "1"))), "info"); } + catch (e) { ctx.ui?.notify?.(`Could not accept reflection skill: ${(e as Error).message}`, "error"); } + }, + }); + pi.registerCommand("reflect-deny", { description: "Discard queued proposal by number", handler: async (args, ctx) => ctx.ui?.notify?.(denyProposal(proposalIndex(String(args ?? "1"))), "info") }); + pi.registerCommand("reflect-history", { description: "Show reflection run and approval history", handler: async (_args, ctx) => ctx.ui?.notify?.(readHistory(), "info") }); + pi.registerCommand("reflect-doctor", { description: "Check reflection dependencies", handler: async (_args, ctx) => ctx.ui?.notify?.(`reflect-skills: using bounded breadcrumbs parser; writes accepted skills to ${userSkillsRoot()}.`, "info") }); +} diff --git a/.pi/extensions/skill-inject/index.ts b/.pi/extensions/skill-inject/index.ts index fe4349e0..aacddce9 100644 --- a/.pi/extensions/skill-inject/index.ts +++ b/.pi/extensions/skill-inject/index.ts @@ -1,15 +1,17 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "@sinclair/typebox"; -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { parseSkillFile } from "./frontmatter.ts"; -interface SkillEntry { +export interface SkillEntry { name: string; type: string; sourceDir: string; + origin: "repo" | "user"; + path: string; body: string; tokenCost: number; targetTool?: string; @@ -25,27 +27,25 @@ const selectionCache = new Map(); let loaded = false; const recentToolCalls: string[] = []; let lastFailedTool: string | null = null; - -const INTENT_MAP: Record = { - read: ["read"], show: ["read"], view: ["read"], cat: ["read"], - write: ["write"], create: ["write", "bash"], implement: ["write", "read"], code: ["write", "read"], - edit: ["edit"], change: ["edit"], modify: ["edit"], fix: ["edit"], update: ["edit"], replace: ["edit"], add: ["edit", "write"], refactor: ["edit", "read"], - run: ["bash"], execute: ["bash"], install: ["bash"], build: ["bash"], test: ["bash"], - find: ["glob", "grep", "findRead", "code_search"], search: ["grep", "findRead", "code_search"], grep: ["grep"], glob: ["glob", "findRead"], - function: ["code_search", "grep"], class: ["code_search", "grep"], where: ["code_search"], calls: ["code_search"], callsite: ["code_search"], caller: ["code_search"], implementation: ["code_search"], implements: ["code_search"], reference: ["code_search"], references: ["code_search"], dependency: ["code_search"], depends: ["code_search"], route: ["code_search"], endpoint: ["code_search"], symbol: ["code_search"], definition: ["code_search"], declare: ["code_search"], declares: ["code_search"], variable: ["code_search"], module: ["code_search"], import: ["code_search"], exports: ["code_search"], struct: ["code_search"], interface: ["code_search"], inherit: ["code_search"], extends: ["code_search"], override: ["code_search"], overrides: ["code_search"], codegraph: ["code_search"], codebase: ["code_search"], graph: ["code_search"], semantic: ["code_search"], - fetch: ["webfetch"], download: ["webfetch"], url: ["webfetch"], web: ["websearch"], research: ["enableBrowserTools", "EvidenceAdd"], researching: ["enableBrowserTools", "EvidenceAdd"], wikipedia: ["enableBrowserTools", "EvidenceAdd"], article: ["enableBrowserTools", "EvidenceAdd"], citation: ["EvidenceAdd", "enableBrowserTools"], cite: ["EvidenceAdd"], source: ["EvidenceAdd", "enableBrowserTools"], fact: ["EvidenceAdd"], factcheck: ["EvidenceAdd", "enableBrowserTools"], question: ["EvidenceAdd", "enableBrowserTools"], answer: ["EvidenceAdd", "EvidenceList"], navigate: ["enableBrowserTools"], browse: ["enableBrowserTools"], page: ["enableBrowserTools"], click: ["enableBrowserTools"], findread: ["findRead"], -}; +let userTurn = 0; +let longConversationLastWarnTurn = -999; +const recentInjected = new Map(); +const COOLDOWN_TURNS = 3; const RESEARCH_TRIGGERS = [/\bbrows(?:e|ing|er)\b/i, /\bonline\b/i, /\bresearch(?:ing)?\b/i, /\blook\s+up\b/i, /\blookup\b/i, /\bsearch\s+(?:the|for)\b/i, /\bweb\s*search\b/i, /\bwikipedia\b/i, /\bwebsite\b/i, /\bweb\s*page\b/i, /\bgoogle\b/i, /\bcite|citation\b/i, /\bfact[-\s]?check/i]; -const REVIEW_TRIGGERS = [/\bcode\s+review\b/i, /\breview\s+mode\b/i, /\breview(?:ing)?\s+(?:this\s+)?(?:code|diff|pr|pull\s+request|merge\s+request|change|changes)\b/i, /\b(?:pr|pull\s+request|merge\s+request)\s+review\b/i, /\brequest\s+changes\b/i, /\bapprove\s+(?:this\s+)?(?:pr|pull\s+request|merge\s+request|change|changes)\b/i]; +const REVIEW_TRIGGERS = [/\bcode\s+review\b/i, /\breview\s+mode\b/i, /\breview(?:ing)?\s+(?:this\s+|the\s+)?(?:code|diff|pr|pull\s+request|merge\s+request|change|changes|uncommitted\s+changes)\b/i, /\breview(?:ing)?\s+.*\bchanges\b/i, /\b(?:pr|pull\s+request|merge\s+request)\s+review\b/i, /\brequest\s+changes\b/i, /\bapprove\s+(?:this\s+)?(?:pr|pull\s+request|merge\s+request|change|changes)\b/i]; const RESEARCH_DIRECTIVE = ["", "## Research-first directive", "This task involves online research.", "1. If Browser* tools are not active yet, call enableBrowserTools first.", "2. Gather facts with BrowserNavigate / BrowserExtract (or websearch for first hops).", "3. Save each citable fact via EvidenceAdd before relying on it.", "4. Only then answer or make file edits.", ""].join("\n"); const MIN_SCORE_THRESHOLD = 2.0; const PER_ENTRY_CAP = 150; -function skillsRoot(): string { +function repoSkillsRoot(): string { return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "skills"); } +function userSkillsRoot(): string { + return join(homedir(), ".pi", "skills"); +} + function settingsPath(): string { return join(homedir(), ".pi", "agent", "settings.json"); } function readSettings(): any { try { return JSON.parse(readFileSync(settingsPath(), "utf-8")); } catch { return {}; } } function writeSettings(settings: any): void { mkdirSync(dirname(settingsPath()), { recursive: true }); writeFileSync(settingsPath(), JSON.stringify(settings, null, 2) + "\n"); } @@ -87,36 +87,51 @@ function walkMarkdown(dir: string): string[] { function loadSkills(): void { if (loaded) return; loaded = true; - const root = skillsRoot(); - for (const path of walkMarkdown(root)) { - const parsed = parseSkillFile(readFileSync(path, "utf-8")); - if (!parsed?.body) continue; - const fm = parsed.frontmatter; - const rel = relative(root, path).split(/[\\/]/); - const sourceDir = rel[0] || basename(dirname(path)); - const type = inferType(sourceDir, fm.type); - const targetTool = typeof fm.target_tool === "string" && fm.target_tool ? fm.target_tool : undefined; - const name = (typeof fm.name === "string" && fm.name) || (typeof fm.topic === "string" && fm.topic) || targetTool || basename(path, ".md"); - const description = typeof fm.description === "string" && fm.description ? fm.description : undefined; - let tokenCost = typeof fm.token_cost === "number" ? fm.token_cost : 150; - if (type !== "tool" && tokenCost > PER_ENTRY_CAP) tokenCost = PER_ENTRY_CAP; - const keywords = Array.isArray(fm.keywords) ? (fm.keywords as string[]).map((k) => k.toLowerCase()) : []; - const requiresTools = Array.isArray(fm.requires_tools) ? (fm.requires_tools as string[]) : []; - const entry = { name, type, sourceDir, body: parsed.body, tokenCost, targetTool, description, keywords, requiresTools }; - allSkills.push(entry); - explicitSkills.set(name, entry); - explicitSkills.set(name.toLowerCase(), entry); - if (targetTool) toolSkills.set(targetTool, entry); + const roots = [ + { root: repoSkillsRoot(), origin: "repo" as const }, + { root: userSkillsRoot(), origin: "user" as const }, + ]; + for (const { root, origin } of roots) { + for (const path of walkMarkdown(root)) { + const parsed = parseSkillFile(readFileSync(path, "utf-8")); + if (!parsed?.body) continue; + const fm = parsed.frontmatter; + const rel = relative(root, path).split(/[\\/]/); + const sourceDir = origin === "user" ? "user" : (rel[0] || basename(dirname(path))); + const type = inferType(sourceDir, fm.type); + const targetTool = typeof fm.target_tool === "string" && fm.target_tool ? fm.target_tool : undefined; + const name = (typeof fm.name === "string" && fm.name) || (typeof fm.topic === "string" && fm.topic) || targetTool || basename(path, ".md"); + const description = typeof fm.description === "string" && fm.description ? fm.description : firstBodyLine(parsed.body); + let tokenCost = typeof fm.token_cost === "number" ? fm.token_cost : 150; + if (type !== "tool" && tokenCost > PER_ENTRY_CAP) tokenCost = PER_ENTRY_CAP; + const keywords = Array.isArray(fm.keywords) ? (fm.keywords as string[]).map((k) => k.toLowerCase()) : []; + const requiresTools = Array.isArray(fm.requires_tools) ? (fm.requires_tools as string[]) : []; + const entry = { name, type, sourceDir, origin, path, body: parsed.body, tokenCost, targetTool, description, keywords, requiresTools }; + allSkills.push(entry); + explicitSkills.set(`${origin}:${name}`, entry); + explicitSkills.set(`${origin}:${name.toLowerCase()}`, entry); + if (origin === "user" || !explicitSkills.has(name)) { + explicitSkills.set(name, entry); + explicitSkills.set(name.toLowerCase(), entry); + } + if (targetTool && (origin === "user" || !toolSkills.has(targetTool))) toolSkills.set(targetTool, entry); + } } } -function predictTools(userText: string): string[] { - const text = userText.toLowerCase(); - const words = new Set(text.split(/\s+/).filter(Boolean)); +function firstBodyLine(body: string): string | undefined { + return body.split("\n").map((line) => line.replace(/^#+\s*/, "").trim()).find(Boolean)?.slice(0, 140); +} + +export function predictTools(userText: string, skills: SkillEntry[] = allSkills): string[] { + const scored = skills + .filter((s) => s.targetTool) + .map((entry) => ({ entry, score: scoreEntry(userText, entry) })) + .filter((x) => x.score >= 1) + .sort((a, b) => b.score - a.score || (a.entry.origin === "user" ? -1 : 1)); const predicted: string[] = []; - for (const [kw, toolNames] of Object.entries(INTENT_MAP)) { - if (!words.has(kw) && !text.includes(kw)) continue; - for (const tn of toolNames) if (!predicted.includes(tn)) predicted.push(tn); + for (const { entry } of scored) { + if (entry.targetTool && !predicted.includes(entry.targetTool)) predicted.push(entry.targetTool); } return predicted; } @@ -131,14 +146,20 @@ function scoreEntry(userText: string, e: SkillEntry): number { return score; } -function selectToolSkills(prompt: string, budget: number, allowed?: Set, required: string[] = []): { selected: SkillEntry[]; skippedBudget: SkillEntry[] } { +function selectToolSkills(prompt: string, budget: number, allowed?: Set, required: string[] = []): { selected: SkillEntry[]; skippedBudget: SkillEntry[]; suppressedRecent: SkillEntry[] } { const selected: SkillEntry[] = []; const skippedBudget: SkillEntry[] = []; + const suppressedRecent: SkillEntry[] = []; let used = 0; const tryAdd = (name: string, force = false): void => { const sk = toolSkills.get(name); - if (!sk || selected.includes(sk) || skippedBudget.includes(sk)) return; + if (!sk || selected.includes(sk) || skippedBudget.includes(sk) || suppressedRecent.includes(sk)) return; if (allowed && !allowed.has(name)) return; + const recent = recentInjected.get(sk.name); + if (!force && recent !== undefined && userTurn - recent < COOLDOWN_TURNS) { + suppressedRecent.push(sk); + return; + } if (!force && used + sk.tokenCost > budget) { skippedBudget.push(sk); return; @@ -147,10 +168,10 @@ function selectToolSkills(prompt: string, budget: number, allowed?: Set, used += sk.tokenCost; }; for (const t of required) tryAdd(t, true); - if (lastFailedTool) tryAdd(lastFailedTool); + if (lastFailedTool) tryAdd(lastFailedTool, true); for (const name of recentToolCalls.slice(0, 4)) tryAdd(name); for (const name of predictTools(prompt)) tryAdd(name); - return { selected, skippedBudget }; + return { selected, skippedBudget, suppressedRecent }; } function selectReferenceSkills(prompt: string, budget: number): { selected: SkillEntry[]; skippedBudget: SkillEntry[] } { @@ -209,6 +230,28 @@ function findExplicitSkill(name: string): SkillEntry | undefined { return explicitSkills.get(key) ?? explicitSkills.get(key.toLowerCase()); } +function copyDir(src: string, dest: string): void { + mkdirSync(dest, { recursive: true }); + for (const name of readdirSync(src)) { + const from = join(src, name); + const to = join(dest, name); + const st = statSync(from); + if (st.isDirectory()) copyDir(from, to); + else copyFileSync(from, to); + } +} + +function promotableUserSkills(): SkillEntry[] { + loadSkills(); + return allSkills.filter((s) => s.origin === "user" && !allSkills.some((r) => r.origin === "repo" && r.name === s.name)); +} + +function listPromotableUserSkills(): string { + const skills = promotableUserSkills(); + if (skills.length === 0) return "No user skills are promotable; every user skill name already exists in repo skills."; + return ["Promotable user skills:", ...skills.map((s) => ` ${s.name} — ${s.description ?? s.type}`)].join("\n"); +} + function listAllSkills(): string { loadSkills(); if (allSkills.length === 0) return "No skills loaded."; @@ -222,8 +265,8 @@ function listAllSkills(): string { lines.push(`${group}:`); for (const s of entries.sort((a, b) => a.name.localeCompare(b.name))) { const label = s.targetTool ? `${s.name} -> ${s.targetTool}` : s.name; - const kw = s.keywords.length > 0 ? ` [${s.keywords.join(", ")}]` : ""; - lines.push(` ${label} (${s.tokenCost} tok)${kw}`); + const desc = s.description ? ` — ${s.description}` : ""; + lines.push(` ${label} (${s.tokenCost} tok, ${s.origin})${desc}`); } lines.push(""); } @@ -271,6 +314,42 @@ export default function (pi: ExtensionAPI) { }, }); + pi.registerCommand("promote-user-skill", { + description: "Copy a user-level skill into repo skills/user/ after duplicate checks", + handler: async (args, ctx) => { + const raw = String(args ?? "").trim(); + if (!raw) { + ctx.ui?.notify?.(listPromotableUserSkills(), "info"); + return; + } + const force = /(?:^|\s)--force(?:\s|$)/.test(raw); + const name = raw.replace(/(?:^|\s)--force(?:\s|$)/g, " ").trim(); + const skill = allSkills.find((s) => s.origin === "user" && s.name === name); + if (!skill) { + ctx.ui?.notify?.(`Unknown user skill: ${name}`, "error"); + return; + } + const sameName = allSkills.find((s) => s.origin === "repo" && s.name === skill.name); + if (sameName && !force) { + ctx.ui?.notify?.(`Repo skill named '${skill.name}' already exists. Use --force with a renamed user skill; not overwriting.`, "warning"); + return; + } + const nearDup = allSkills.find((s) => s.origin === "repo" && s.description && skill.description && s.description.toLowerCase() === skill.description.toLowerCase()); + if (nearDup && !force) { + ctx.ui?.notify?.(`Possible duplicate of repo skill '${nearDup.name}' by description. Rename/refine it or rerun with --force.`, "warning"); + return; + } + const srcDir = statSync(skill.path).isDirectory() ? skill.path : dirname(skill.path); + const dest = join(repoSkillsRoot(), "user", skill.name); + if (existsSync(dest) && !force) { + ctx.ui?.notify?.(`Destination exists: ${dest}. Not overwriting; use --force only after resolving conflicts.`, "warning"); + return; + } + copyDir(srcDir, dest); + ctx.ui?.notify?.(`Promoted user skill '${skill.name}' to ${dest}`, "info"); + }, + }); + pi.registerCommand("skill", { description: "Load a skill by name (also available as /skill:)", getArgumentCompletions: (prefix) => { @@ -320,6 +399,7 @@ export default function (pi: ExtensionAPI) { }); pi.on("before_agent_start", async (event, ctx) => { + userTurn += 1; loadSkills(); if (allSkills.length === 0) return; const opts: any = (event as any).systemPromptOptions ?? {}; @@ -339,11 +419,15 @@ export default function (pi: ExtensionAPI) { const refSelection = refBudget > 0 ? selectReferenceSkills(selectionPrompt, refBudget) : { selected: [], skippedBudget: [] }; const refs = refSelection.selected; const requiredTools = Array.from(new Set([...(Array.isArray(lc.requiredTools) ? lc.requiredTools : []), ...refs.flatMap((s) => s.requiresTools)])); - const toolBudget: number = lc.skillTokenBudget ?? persistedBudget("skillTokenBudget") ?? 300; - const toolSelection = toolBudget > 0 ? selectToolSkills(selectionPrompt, toolBudget, allowed, requiredTools) : { selected: [], skippedBudget: [] }; + const baseToolBudget: number = lc.skillTokenBudget ?? persistedBudget("skillTokenBudget") ?? 300; + const toolBudget = userTurn === 1 ? baseToolBudget * 2 : baseToolBudget; + const toolSelection = toolBudget > 0 ? selectToolSkills(selectionPrompt, toolBudget, allowed, requiredTools) : { selected: [], skippedBudget: [], suppressedRecent: [] }; const tools = toolSelection.selected; const researchTask = looksLikeResearchTask(prompt); - if (tools.length === 0 && refs.length === 0 && !researchTask && toolSelection.skippedBudget.length === 0 && refSelection.skippedBudget.length === 0) return; + const contextTokens = estimateTokens(basePrompt); + const shouldWarnLong = (contextTokens > contextLimit * 0.75 || userTurn >= 16) && userTurn - longConversationLastWarnTurn >= 6; + if (shouldWarnLong) longConversationLastWarnTurn = userTurn; + if (tools.length === 0 && refs.length === 0 && !researchTask && !shouldWarnLong && toolSelection.skippedBudget.length === 0 && refSelection.skippedBudget.length === 0 && toolSelection.suppressedRecent.length === 0) return; const key = `${tools.map((s) => s.targetTool).sort().join("|")}::${refs.map((s) => s.name).sort().join("|")}`; let block = selectionCache.get(key); @@ -352,6 +436,7 @@ export default function (pi: ExtensionAPI) { selectionCache.set(key, block); } const directive = researchTask ? RESEARCH_DIRECTIVE : ""; + for (const s of [...tools, ...refs]) recentInjected.set(s.name, userTurn); try { const parts: string[] = []; @@ -359,7 +444,9 @@ export default function (pi: ExtensionAPI) { if (refs.length > 0) parts.push(`+${refs.length} refs [${refs.map((s) => s.name).join(",")}]`); if (toolSelection.skippedBudget.length > 0) parts.push(`skipped tools budget [${toolSelection.skippedBudget.map((s) => s.targetTool ?? s.name).join(",")}]`); if (refSelection.skippedBudget.length > 0) parts.push(`skipped refs budget [${refSelection.skippedBudget.map((s) => s.name).join(",")}]`); + if (toolSelection.suppressedRecent.length > 0) parts.push(`suppressed recent [${toolSelection.suppressedRecent.map((s) => s.targetTool ?? s.name).join(",")}]`); if (researchTask) parts.push("+research-directive"); + if (shouldWarnLong) parts.push("long session: consider /compact or a fresh session"); ctx.ui.notify(`skill-inject: ${parts.join(" ")}`, "info"); } catch {} diff --git a/.pi/extensions/skill-inject/selector.test.ts b/.pi/extensions/skill-inject/selector.test.ts index 15609dd6..8a3a153f 100644 --- a/.pi/extensions/skill-inject/selector.test.ts +++ b/.pi/extensions/skill-inject/selector.test.ts @@ -1,58 +1,53 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect } from "vitest"; import { readFileSync, existsSync, readdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parseSkillFile } from "./frontmatter.ts"; +import { predictTools, type SkillEntry } from "./index.ts"; -// Re-implement the INTENT_MAP + predict helpers here (kept in sync with -// index.ts). These are pure functions; extension integration tested via RPC. - -const INTENT_MAP: Record = { - read: ["read"], show: ["read"], view: ["read"], cat: ["read"], - write: ["write"], create: ["write", "bash"], - implement: ["write", "read"], code: ["write", "read"], - function: ["write", "edit"], class: ["write", "edit"], - edit: ["edit"], change: ["edit"], modify: ["edit"], - fix: ["edit"], update: ["edit"], replace: ["edit"], - add: ["edit", "write"], refactor: ["edit", "read"], - run: ["bash"], execute: ["bash"], install: ["bash"], - build: ["bash"], test: ["bash"], - find: ["glob", "grep"], search: ["grep"], - grep: ["grep"], glob: ["glob"], - fetch: ["webfetch"], download: ["webfetch"], url: ["webfetch"], - web: ["websearch"], -}; - -function predictTools(userText: string): string[] { - const words = new Set(userText.toLowerCase().split(/\s+/).filter(Boolean)); - const predicted: string[] = []; - for (const [kw, toolNames] of Object.entries(INTENT_MAP)) { - if (!words.has(kw)) continue; - for (const tn of toolNames) if (!predicted.includes(tn)) predicted.push(tn); - } - return predicted; +function toolSkill(targetTool: string, keywords: string[]): SkillEntry { + return { + name: `${targetTool}-guidance`, + type: "tool-guidance", + sourceDir: "tools", + origin: "repo", + path: `/skills/tools/${targetTool}.md`, + body: "", + tokenCost: 100, + targetTool, + keywords, + requiresTools: [], + }; } -describe("intent prediction (INTENT_MAP)", () => { +const toolSkills = [ + toolSkill("read", ["read", "show", "view"]), + toolSkill("edit", ["edit", "fix", "change", "update"]), + toolSkill("bash", ["run", "test", "build", "install"]), + toolSkill("glob", ["find", "glob", "files", "pattern"]), + toolSkill("grep", ["find", "search", "grep", "regex"]), + toolSkill("webfetch", ["fetch", "download", "url"]), +]; + +describe("frontmatter-driven tool prediction", () => { it("predicts read for 'read config.py'", () => { - expect(predictTools("read config.py and show me the output")).toContain("read"); - expect(predictTools("read config.py and show me the output")).toContain("read"); + expect(predictTools("read config.py and show me the output", toolSkills)).toContain("read"); }); it("predicts edit for 'fix the bug'", () => { - const p = predictTools("please fix the bug in auth.py"); + const p = predictTools("please fix the bug in auth.py", toolSkills); expect(p).toContain("edit"); }); it("predicts bash for 'run the tests'", () => { - const p = predictTools("run the tests and build the project"); + const p = predictTools("run the tests and build the project", toolSkills); expect(p).toContain("bash"); }); it("predicts glob+grep for 'find all files'", () => { - const p = predictTools("find all files matching the pattern"); + const p = predictTools("find all files matching the pattern", toolSkills); expect(p).toContain("glob"); expect(p).toContain("grep"); }); it("empty predictions for neutral prompts", () => { - expect(predictTools("hello there")).toEqual([]); + expect(predictTools("hello there", toolSkills)).toEqual([]); }); }); diff --git a/.pi/extensions/web/api.test.ts b/.pi/extensions/web/api.test.ts new file mode 100644 index 00000000..feb52186 --- /dev/null +++ b/.pi/extensions/web/api.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest"; +import web from "./index.ts"; + +async function command(action: string, tools: any[] = []): Promise { + const commands = new Map(); + const messages: string[] = []; + const pi: any = { + getAllTools: () => tools, + registerCommand: (name: string, spec: any) => commands.set(name, spec), + }; + web(pi); + await commands.get("web").handler(action, { ui: { notify: (msg: string) => messages.push(msg) } }); + return messages; +} + +function urlFrom(messages: string[]): string { + const m = messages.join("\n").match(/http:\/\/127\.0\.0\.1:\d+/); + if (!m) throw new Error(`no url in ${messages.join("\n")}`); + return m[0]; +} + +describe("web api", () => { + afterEach(async () => { await command("stop"); }); + + it("serves status, tools, skills, and costs endpoints", async () => { + const messages = await command("start", [{ name: "read", description: "Read files" }]); + const base = urlFrom(messages); + const status = await fetch(`${base}/api/status`).then((r) => r.json()); + const tools = await fetch(`${base}/api/tools`).then((r) => r.json()); + const skills = await fetch(`${base}/api/skills`).then((r) => r.json()); + const costs = await fetch(`${base}/api/costs`).then((r) => r.json()); + const reflection = await fetch(`${base}/api/reflection`).then((r) => r.json()); + const breadcrumbs = await fetch(`${base}/api/breadcrumbs?q=test&mode=semantic`).then((r) => r.json()); + expect(status.running).toBe(true); + expect(status.chat).toBe("placeholder"); + expect(tools[0].name).toBe("read"); + expect(skills.some((s: any) => s.name === "bash-guidance" && s.description)).toBe(true); + expect(costs.sessions).toBeGreaterThanOrEqual(0); + expect(costs.daily).toBeDefined(); + expect(costs.tools).toBeDefined(); + expect(reflection.queue).toBeDefined(); + expect(breadcrumbs.mode).toMatch(/fallback|lexical/); + }); +}); diff --git a/.pi/extensions/web/index.test.ts b/.pi/extensions/web/index.test.ts new file mode 100644 index 00000000..627f3f77 --- /dev/null +++ b/.pi/extensions/web/index.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import web from "./index.ts"; + +describe("web extension", () => { + it("registers /web command", () => { + const commands: string[] = []; + const pi: any = { registerCommand: (name: string) => commands.push(name) }; + web(pi); + expect(commands).toContain("web"); + }); + + it("reports stopped status before start", async () => { + const commands = new Map(); + const messages: string[] = []; + const pi: any = { registerCommand: (name: string, spec: any) => commands.set(name, spec) }; + web(pi); + await commands.get("web").handler("status", { ui: { notify: (msg: string) => messages.push(msg) } }); + expect(messages[messages.length - 1]).toBe("web is stopped"); + }); +}); diff --git a/.pi/extensions/web/index.ts b/.pi/extensions/web/index.ts new file mode 100644 index 00000000..5c7de6d4 --- /dev/null +++ b/.pi/extensions/web/index.ts @@ -0,0 +1,91 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createServer, type Server } from "node:http"; +import { summarizeCosts } from "../_shared/cost-history.ts"; +import { discoverSessions, parseSessionFile, searchSessionsWithMode } from "../_shared/session-history.ts"; +import { listSkillCatalog } from "../_shared/skill-catalog.ts"; +import { readHistory, reflectionQueue } from "../reflect-skills/index.ts"; + +let server: Server | undefined; +let port = 0; + +function url(): string { return `http://127.0.0.1:${port}`; } + +const page = `little-coder web + +

little-coder web

Local dashboard bound to 127.0.0.1. Chat/abort/new-session and permission prompt controls are placeholders until Pi exposes a stable web control API.

+

Command palette

+

Cost dashboard

+

Breadcrumbs

+

Reflection review

+

Skills

+

Tools

+

Transcript / tool blocks

Use breadcrumbs search/read to inspect bounded transcript chunks. Tool outputs are hidden by default.
+
+`; + +function json(res: any, data: unknown): void { res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(data, null, 2)); } +function notFound(res: any): void { res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); res.end("Not found"); } + +async function route(req: any, res: any, pi: ExtensionAPI, preferred: number): Promise { + const u = new URL(req.url ?? "/", `http://127.0.0.1:${port || preferred}`); + if (u.pathname === "/api/status") return json(res, { running: true, url: `http://127.0.0.1:${port || preferred}`, chat: "placeholder", permissionPrompts: "TUI-only" }); + if (u.pathname === "/api/costs") return json(res, summarizeCosts()); + if (u.pathname === "/api/skills") return json(res, listSkillCatalog()); + if (u.pathname === "/api/reflection") return json(res, { queue: reflectionQueue(), history: readHistory() }); + if (u.pathname === "/api/breadcrumbs") return json(res, await searchSessionsWithMode(u.searchParams.get("q") ?? "", u.searchParams.get("mode") ?? "lexical", discoverSessions(), process.cwd(), Math.min(Number(u.searchParams.get("limit") ?? 5), 20))); + if (u.pathname.startsWith("/api/session/")) { + const id = decodeURIComponent(u.pathname.slice("/api/session/".length)); + const found = discoverSessions().find((s) => s.id === id || s.path === id || s.id.endsWith(id)); + if (!found) return notFound(res); + const parsed = parseSessionFile(found.path) ?? found; + return json(res, { ...parsed, turns: parsed.turns.filter((t) => !t.toolName && t.role !== "tool" && t.role !== "tool_result").slice(0, 40) }); + } + if (u.pathname === "/api/tools") { + const tools = typeof (pi as any).getAllTools === "function" ? (pi as any).getAllTools() : []; + return json(res, tools.map((t: any) => ({ name: t.name, description: t.description ?? "" }))); + } + if (u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(page); return; } + return notFound(res); +} + +function start(pi: ExtensionAPI, preferred = 3877): Promise { + if (server) return Promise.resolve(`web already running at http://127.0.0.1:${port}`); + return new Promise((resolve, reject) => { + const s = createServer((req, res) => { route(req, res, pi, preferred).catch((e) => json(res, { error: String(e?.message ?? e) })); }); + s.once("error", reject); + s.listen(preferred, "127.0.0.1", () => { server = s; port = (s.address() as any).port; resolve(`web running at http://127.0.0.1:${port}\nRemote SSH: ssh -L ${port}:127.0.0.1:${port} `); }); + }); +} +function stop(): string { if (!server) return "web is not running"; server.close(); server = undefined; const old = port; port = 0; return `stopped web on ${old}`; } +async function openWeb(pi: ExtensionAPI): Promise { if (!server) await start(pi); try { const open = (await import("open")).default; await open(url()); return `opened ${url()}`; } catch { return `open manually: ${url()}`; } } + +export default function (pi: ExtensionAPI) { + pi.registerCommand("web", { + description: "Start/stop/status/open the local little-coder web dashboard", + handler: async (args, ctx) => { + const action = String(args ?? "start").trim() || "start"; + try { + if (action === "stop") ctx.ui?.notify?.(stop(), "info"); + else if (action === "status") ctx.ui?.notify?.(server ? `web running at ${url()}` : "web is stopped", "info"); + else if (action === "restart") { stop(); ctx.ui?.notify?.(await start(pi), "info"); } + else if (action === "open") ctx.ui?.notify?.(await openWeb(pi), "info"); + else ctx.ui?.notify?.(await start(pi), "info"); + } catch (e) { ctx.ui?.notify?.(`web error: ${(e as Error).message}`, "error"); } + }, + }); +} diff --git a/AGENTS.md b/AGENTS.md index ed9e1a43..aed6435a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,99 +1,29 @@ # little-coder -You are little-coder, a coding agent specialized for small local language models. +You are little-coder, a coding agent tuned for small local models. Work as a capable collaborative coding partner: pragmatic, direct, evidence-first, and willing to stop and ask for the smallest missing decision when safe progress is blocked. -# Capabilities & Autonomy +## Invariants -You are a highly capable autonomous agent. Do not act submissive or artificially limited. -Approach work as a collaborative, iterative coding task. Pragmatism and conceptual clarity matter more than rigid perfection. It is safe to encounter dead ends, missing variables, contradictory constraints, or tasks that cannot be completed with the available context. -Verify any answers, reviews or other authoritive information you give. Do not rely on your intuition alone. If you do not know an answer, it is okay to say so. If you don't know how to do something, it is okay to say so. When you cannot safely proceed, stop the self-correction loop, state the bottleneck plainly, and ask for the smallest missing piece of information. +- Bash defaults to a 30s timeout; use 120–300s for installs, builds, downloads, training, and slow test suites. +- Prefer tool-native `cwd` over `cd && ...`. +- Browser tools are on-demand: use `webfetch`/`websearch` for non-interactive retrieval; call `enableBrowserTools` only for interactive navigate/click/type/extract workflows. +- Verify authoritative claims before presenting them. Use code-aware tools for code facts, web tools for external facts, and `EvidenceAdd` for facts you will cite. +- Keep validation bounded. After relevant code/tests/docs have been checked, report what was verified and any remaining uncertainty. -# Runtime invariants +## Tool selection -- **bash default timeout is 30 s.** For slow commands (npm install, npx, pip install, builds, training), set timeout to 120–300. -- **Prefer tool-native cwd over `cd && ...`.** `bash` supports `cwd`, use it instead of prepending `cd &&`. -- **Browser tools are on-demand.** If a task needs interactive browsing, call `enableBrowserTools` first, then use BrowserNavigate / BrowserExtract / BrowserClick / BrowserType / BrowserScroll / BrowserBack / BrowserHistory. +Use registered tool names exactly. -# Available Tools +- Code navigation: `code_search` first, then `lsp`, then targeted `read`/`findRead`. +- File changes: prefer `edit` for existing files, `write` only for new files. +- File discovery/content: `glob` for paths, `grep` for raw text, `findRead` for a few small matched files. +- Shell: `bash` only when first-class tools do not fit or command execution is required. +- Discovery: `tools`, `skills`, `/skills`, and `enableBrowserTools`. -Use the actual tool names exactly as registered. +## Task approach -## Core file & shell tools +For non-trivial work, identify inputs, outputs, edge cases, hardest parts, and a clean implementation shape before editing. For simple fixes, edit directly. Resolve ambiguity using nearby code, tests, docs, and repository conventions; do not write exploratory code while still undecided. -- `read`, `write`, `edit`, `bash` -- `glob`, `grep`, `webfetch`, `websearch` +## Skill discovery and injected context -## Composite / high-leverage tools - -- `code_search`: preferred first stop for codebase navigation, symbols, relationships, and semantic/structural search -- `lsp`: preferred for definitions, references, hover/types, diagnostics, renames, and code actions -- `findRead` > `glob` + `read` when code_* / `lsp` are not applicable - -## Discovery / capability tools - -- `tools`: list the current registry, including Browser* tools available on demand -- `skills`: list installed tool skills, knowledge entries, and protocols -- `enableBrowserTools`: load Browser* tools when a task needs interactive browsing - -# Approaching complex tasks - -Before writing code for a non-trivial problem, think through the structure: what the inputs and outputs look like, what the edge cases are, which parts of the problem are hardest, and what a clean implementation would look like. Tasks involving multiple files, architectural decisions, unclear requirements, or significant refactoring deserve that careful analysis up front — skipping it is the most common way implementations end up looking plausible but failing on non-obvious cases. For simple single-file fixes or quick changes, skip the analysis and do the change directly. The goal is deliberate implementation, not elaborate deliberation. - -Keep validation bounded. If checks are inconclusive after the relevant code, tests, or docs have been inspected, report the current best state with the remaining uncertainty instead of escalating into repeated tool calls or speculative fixes. - -# Evidence-first collaboration - -Work as a careful partner. Verify authoritative statements before presenting them as facts. Use `code_search`/`lsp` for code claims, `websearch`/`webfetch` for external claims, and `EvidenceAdd` for facts you will cite in final answers, plans, or reviews. If evidence is unavailable, say "I don't know" or describe exactly what was checked. - -Avoid unsupported hedge language such as "I think", "probably", "likely", "I believe", or "it seems" in authoritative answers. Replace it with verified facts, explicit uncertainty, or a concrete next check. - -Do not loop indefinitely on validation. After the relevant code/tests/docs have been checked, move forward and state the verification performed. - -# Handling ambiguity - -When requirements or approach are ambiguous, resolve them against what you can read from the surrounding context, the tests, and the conventions already in the file. Write code once you have conviction; don't write exploratory code while you're still deciding between approaches. - -# Skill discovery - -At the beginning of a task, check with the `skills` tool for appropriate skills you could use. -If you are unsure or there are no appropriate skills available, use the `find-skills` skill to find new skills online. - -This is a lightweight check — a quick search and decide. If a good match exists, offer it to the user. If not, proceed with your built-in capabilities. - -List all available skills with `skills` or `/skills`. Each skill is a markdown file with YAML frontmatter (name, type, target_tool/topic, token_cost, keywords). - -# Per-turn context augmentation - -Your system prompt is assembled per turn by little-coder's extension stack: - -- **Tool skill cards** (`## Tool Usage Guidance`): selected by error-recovery > recency > intent priority. If the previous tool call failed, its skill card is injected first. -- **Algorithm cheat sheets** (`## Algorithm Reference`): scored against the problem statement by keyword + bigram matching. Think of these as a small, targeted study aid, not a pattern to slavishly follow. - -When you see these blocks, trust them — they were selected for the current turn. - -# Tool Efficiency Guidelines - -**Prefer code-aware tools over text/file sweeps.** Every tool call costs context — fewer, smarter calls beat more, dumber ones. - -- Start codebase navigation with **`code_search`** for functions, classes, routes, symbols, call relationships, and semantic/structural search. Prefer it over `grep`, `glob`, `findRead`, or broad `read` when looking for code. -- Use **`lsp`** for precise definitions, references, type info, signatures, diagnostics, renames, and code actions. Prefer `lsp` diagnostics over "building to get a list of errors" when you only need editor/compiler diagnostics. -- Use targeted `read` only after `code_search` or `lsp` has narrowed the file/range. -- Use `grep` only for simple raw text matches, generated files, or non-code content where code-aware tools are not useful. -- Use `glob` only for file discovery, not as the default way to understand code structure. -- Use `findRead` only when you genuinely need to inspect several small files and code-aware tools are not applicable. -- **glob`/`read`/`findRead`** > ad-hoc `bash`/`python` for file listing, path checks, and file reading when code-aware tools do not apply. - -**Context budget is precious.** Before calling `findRead` or broad `read`, ask: can `code_search` or `lsp` answer this more directly? If not, start with `maxFiles: 3` and `maxCharacters: 4000`, then increase only if needed. - -Avoid `python - <<'PY'` or `bash` for tasks already covered by first-class tools unless you need control flow or output formatting those tools cannot provide. - -# Guidelines - -- Be concise. Lead with the answer. -- Prefer editing existing files over creating new ones. -- Prefer clean code and a solid architecture. -- Always use absolute paths for file operations. -- When reading files before editing, use line numbers to be precise. -- Do not add unnecessary comments, docstrings, or error handling. -- For multi-step tasks, work through them systematically. -- Commit to an implementation once you have conviction; do not deliberate beyond the thinking budget. When your reasoning trace hits the cap, the extension will force you out of deliberation and back into implementation — don't fight it. +At task start, check `skills` for relevant skills. If no suitable skill exists and the user is asking about extending capabilities, use the `find-skills` skill. Per-turn injected tool guidance and knowledge references are selected by little-coder's extension stack; treat them as current task guidance, not permanent global rules. diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f76c0b..5ab3ea4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to little-coder are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and little-coder's public interface (CLI, providers, tools, skills) follows semver starting at `v0.0.1` post-rename. +## [Unreleased] + +### Added +- Reflection-generated user skills: `/reflect`, `/reflect-review`, `/reflect-accept`, `/reflect-deny`, `/reflect-history`, and `/reflect-doctor` draft, review, accept, deny, audit, and diagnose reusable skill proposals written to `~/.pi/skills`. +- Session breadcrumbs: `/breadcrumbs`, `breadcrumbs_search`, and `breadcrumbs_read` search prior Pi session outlines with bounded transcript reads and tool-output guards. +- User skill loading and promotion: `skill-inject` now loads repo `skills/` and user `~/.pi/skills`, lists origins/descriptions, and adds `/promote-user-skill` for duplicate-checked promotion into repo skills. +- Vendored Pi Insights extension under `.pi/extensions/pi-insights/`, with AGPL license/NOTICE preservation. +- `/web start|stop|restart|status|open` local dashboard bound to `127.0.0.1`, plus JSON APIs/UI sections for status, tools, skills, breadcrumbs, reflection queue/history, transcript snippets, and cost summaries. +- Shared session/cost/skill catalog helpers for breadcrumbs, reflection, dashboard, and tests, including daily/project/model/tool/top-session cost breakdowns. +- `improve-codebase-architecture` engineering skill. + +### Changed +- Skill injection is frontmatter-keyword driven, has per-session cooldown notifications, warns on long sessions, and doubles the tool budget for the first injection turn. +- `findRead` output now prefixes the effective invocation for matches, no matches, and errors. +- `/plan` is no longer registered by `mode-commands`; Plannotator owns canonical planning mode and the old prompt helper is `/plan-prompt`. +- `AGENTS.md` is compressed to core invariants/tool-selection guidance. +- Browser enablement guidance now directs non-interactive retrieval to `webfetch`/`websearch` first. + +### Removed +- Removed the `memory-context` extension and stale memory docs/references. Reflection skills and breadcrumbs replace that workflow. +- Removed `@observal/pi-insights` from package dependencies and external package loading now that the extension is vendored. + ## [v1.8.1] — 2026-05-23 ### Fixed diff --git a/NOTICE b/NOTICE index 207eb31c..723086ef 100644 --- a/NOTICE +++ b/NOTICE @@ -32,3 +32,12 @@ reasoning reuse, the Write-vs-Edit tool invariant, a multi-language Aider Polyglot benchmark harness, per-model profiles for small local LLMs, and a complete UI refresh have been added. Many upstream features that did not fit the small-model focus have been removed. + +-------------------------------------------------------------------------- +Vendored @observal/pi-insights +-------------------------------------------------------------------------- + +.pi/extensions/pi-insights vendors @observal/pi-insights, Copyright 2026 +Hari Srinivasan , licensed under AGPL-3.0-only. +The vendored source preserves SPDX license headers. See +.pi/extensions/pi-insights/LICENSE for the full AGPL-3.0-only license text. diff --git a/README.md b/README.md index 5e08e7f7..42a5bf4a 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,9 @@ In the TUI you can use `/tools` to list loaded tools and `/skills` to list avail Use `/plan` to enter browser-reviewed planning mode before implementation. The legacy `/plannotator` command is kept as a compatibility shim but `/plan` is canonical. See `docs/planning-mode.md` for the planning workflow, `ask_user` behavior, and issue-agent `/answer ...` clarification flow. -little-coder also includes a local memory context extension. It stores reviewable Markdown memories under `.pi/memory/`, filters low-salience candidates, supports active-day expiration, and exposes commands such as `/memory-review`, `/memory-doctor`, `/memory-prune`, and `/memory-supersede`. See `docs/memory-context.md` for details. +little-coder uses reflection-generated skills and breadcrumbs for reusable session learning. Use `/reflect`, `/reflect-review`, `/breadcrumbs`, `/skills`, and `/promote-user-skill` to draft, review, search, load, and promote reusable guidance. Reflection writes accepted drafts to user-level `~/.pi/skills//SKILL.md`; `/promote-user-skill [skill]` copies stable user skills into repo `skills/user//` after duplicate checks so they can be packaged. + +Use `/usage` for the inline usage dashboard, `/insights` for the vendored Pi Insights report, and `/web start|stop|restart|status|open` for the local web dashboard bound to `127.0.0.1` with SSH tunnel instructions. For local providers (llama.cpp, Ollama, LM Studio) pi expects *some* value in the API-key env even though local servers ignore it: diff --git a/docs/architecture.md b/docs/architecture.md index 67366147..129b22b2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,10 +27,10 @@ Extensions live under `.pi/extensions//index.ts` and export a pi setup fun Important extension groups: -- **Prompt/context shaping**: `skill-inject`, `knowledge-inject`, `memory-context`, `thinking-budget`, `tool-gating`. +- **Prompt/context shaping**: `skill-inject`, `knowledge-inject`, `thinking-budget`, `tool-gating`. - **Safety and permissions**: `write-guard`, `read-guard`, `permission-gate`, `security`, `filter-read`. - **Developer tools**: `extra-tools`, `lsp`, `codebase-memory-direct`, `evidence`, `evidence-compact`, `browser`, `browser-extract-retention`, `edit-custom`, `bash-cwd`. -- **Agent workflows**: `issue-agent`, `subagent`, `plan-mode`, `mode-commands`, `clear-command`. +- **Agent workflows**: `issue-agent`, `subagent`, `plan-mode`, `mode-commands`, `reflect-skills`, `breadcrumbs`, `clear-command`. - **UI/monitoring**: `powerline-footer-unified`, `usage-dashboard`, `quality-monitor`, `finalize-warn`, `inspect`, `branding`, `checkpoint`, `benchmark-profiles`, `llama-cpp-provider`. Shared utilities that are used by multiple extensions belong under `.pi/extensions/_shared` or a focused extension-local module. diff --git a/docs/memory-context-plan.md b/docs/memory-context-plan.md deleted file mode 100644 index f64155e1..00000000 --- a/docs/memory-context-plan.md +++ /dev/null @@ -1,152 +0,0 @@ -# Harness-native memory context plan - -## Goal - -Add a conservative local memory layer that complements `code_search` instead of replacing it. The layer should remember durable repo/session learnings, prefetch codebase facts when the user is asking about the codebase, and stay compatible with autonomous workflows such as `issue-agent` and `pi-autoresearch`. - -## Non-goals - -- Do not vendor `pi-memctx` wholesale. -- Do not add a hosted vector database or opaque memory service. -- Do not inject large memory dumps into every turn. -- Do not persist generic coding advice, secrets, transient chatter, or unverified guesses. -- Do not let autonomous loops run unbounded without explicit iteration/time/cost limits. - -## Storage - -Use Markdown under the workspace so it is inspectable and reviewable: - -```text -.pi/memory/ - 20-context/ - 40-actions/ - 50-decisions/ - 60-observations/ - 70-runbooks/ - 80-sessions/ - queue.json -``` - -Each note should have small frontmatter: `type`, `title`, `created_at`, `updated_at`, `source`, `confidence`, `tags`. - -## Retrieval backend - -Install and prefer the optional `qmd` dependency for memory retrieval because `pi-memctx` reports it as the fast path and falls back to grep only when unavailable. - -- Add `@tobilu/qmd` as an optional/dev dependency or provision it during harness setup. -- Detect `QMD_PATH` / `MEMORY_QMD_BIN` first, then local `node_modules/.bin/qmd`, then `qmd` on `PATH`, then grep fallback. -- Keep a per-pack/per-workspace qmd collection name so indexes do not bleed across repositories. -- Expose retrieval mode in status output: `qmd`, `grep fallback`, or `disabled`. -- Retrieval must remain functional without qmd; qmd is an acceleration path, not a correctness dependency. - -## Before-turn retrieval and code_search prefetch - -Add a `memory-context` extension with a `before_agent_start` hook. - -1. Classify the prompt with cheap heuristics: - - Codebase intent: mentions files, functions, symbols, architecture, tests, errors, refactors, issue implementation, or repo-specific nouns. - - Issue-agent intent: active issue context, prompts that mention issue work, bug fixing, PR body, implementation, or labels. -2. Search `.pi/memory` using lexical scoring. -3. If codebase intent is likely, run a bounded `code_search` prefetch internally: - - query: normalized user prompt plus issue title/body snippet when available - - project: current workspace project alias - - limit: 3-5 - - timeout/failure budget: fail closed and continue without injection -4. Inject a compact block only when useful: - - `## Local Memory Context`: up to 5 durable facts/runbooks - - `## Codebase Prefetch`: up to 5 symbol/file hits with paths and line ranges - - guidance: use injected context as hints; inspect source when editing or when memory may be stale - -## After-turn learning - -Add an `agent_end` hook. - -1. Collect compact turn evidence: - - user prompt - - final assistant answer - - tool names used - - files edited/read - - tests run and outcomes when visible - - issue-agent metadata if present -2. Generate memory candidates with a hybrid approach: - - deterministic candidates for edits, successful tests, tool failures, issue completion, and newly discovered commands - - optional LLM JSON curator for richer context/decision/runbook/session candidates -3. Apply safety filters: - - secret/token/password/private-key/customer-data regexes - - max size per candidate - - require evidence fields for durable claims -4. Persistence policy: - - default `MEMORY_LEARNING=suggest`: write to `.pi/memory/queue.json` - - `auto`: save only high-confidence deterministic candidates and queue the rest - - `off`: no learning - -## Issue-agent integration - -Memory should treat issue-agent sessions as first-class sources. - -- Before starting issue work, use the issue title/body/comments as retrieval terms. -- Prefer injecting relevant runbooks, prior similar issue actions, known flaky tests, and code_search prefetch hits. -- After completion, save an `action` note with: - - issue id/repo/url - - files changed - - tests run - - final summary / PR body excerpt - - follow-ups or caveats -- If issue-agent marks a task done, link the learned action to the issue metadata and avoid duplicate session snapshots. - -## Autoresearch integration - -`pi-autoresearch` and `issue-agent` are both long-running autonomy surfaces. Treat them as related orchestration modes: they should produce structured artifacts, survive context resets, and feed durable memory. - -Target behavior: - -1. An issue can be labeled `autoresearch` or `ai:autoresearch`. -2. `issue-agent` detects that label and starts an autoresearch-backed issue flow instead of a normal implementation flow. -3. The issue-agent interaction loads/enables `pi-autoresearch` tooling for that run. -4. The agent creates or resumes the autoresearch files in the checked-out worktree: - - `autoresearch.md`: objective, metric, scope, tried ideas, current best result - - `autoresearch.sh`: benchmark command that emits `METRIC name=value` - - `autoresearch.checks.sh`: correctness backpressure checks when available - - `autoresearch.jsonl`: append-only run log -5. The loop runs bounded experiments: - - max iterations from issue label/config/comment - - explicit metric direction and baseline - - keep/discard commits based on benchmark plus checks - - no destructive commands without the existing permission gate -6. On completion, issue-agent posts the result as a PR in the normal way, with a structured body containing: - - issue link - - objective and metric - - baseline, best result, confidence/noise note when available - - kept experiments / discarded notable attempts - - files changed - - checks run - - residual risks and follow-ups -7. Memory saves the autoresearch outcome as an `action` note and, when reusable, a `runbook` or `observation` note. - -Suggested issue labels/config: - -```text -ai:autoresearch -autoresearch:max-iterations=20 -autoresearch:metric=total_ms -autoresearch:direction=lower -``` - -Memory integration points: - -- Before the loop, inject prior benchmark runbooks, similar optimization attempts, and code_search prefetch hits for files in scope. -- During the loop, do not inject every run into context; rely on `autoresearch.md` and `autoresearch.jsonl` as source-of-truth artifacts. -- After each kept experiment, queue a compact learning candidate only if it generalizes beyond the current branch. -- At finalization, save one linked action note for the issue/PR plus any durable runbook/decision notes. - -## Rollout - -1. Implement Markdown queue and manual review command (`/memory-review`). -2. Add qmd detection/install guidance and grep fallback. -3. Add before-turn lexical/qmd memory retrieval with strict token cap. -4. Add bounded code_search prefetch for codebase-intent prompts. -5. Add deterministic after-turn candidates. -6. Add optional LLM curator behind config. -7. Add issue-agent metadata hooks and action notes. -8. Add autoresearch issue-label flow and PR summary handoff. -9. Benchmark against baseline on repo Q&A, issue-agent tasks, and bounded autoresearch issues. diff --git a/docs/memory-context-quality-plan.md b/docs/memory-context-quality-plan.md deleted file mode 100644 index d96400e1..00000000 --- a/docs/memory-context-quality-plan.md +++ /dev/null @@ -1,238 +0,0 @@ -# Memory context quality improvement plan - -## Goal - -Reduce low-impact saved memories and make retrieved memory more useful by treating memory as a managed lifecycle: write, review, promote, retrieve, update, and expire. The immediate fixes are to stop queueing generic edit/test summaries and to remove the hardcoded `Follow-up` section that currently repeats in every candidate. - -## Current problems to fix - -- `turn_end` queues a candidate whenever files were edited or tests were run, even when no durable knowledge was learned. -- Candidate confidence reflects edit/test activity more than memory usefulness. -- `formatCandidateBody()` always emits the same `## Follow-up` text. -- Auto-promotion is based on repeated lexical matches, not on importance or actionability. -- Retrieval treats many memory categories similarly, so low-value action/session notes can crowd out decisions, observations, and runbooks. -- Deduplication only catches exact normalized duplicates; it does not handle stale or superseded memories. - -## Design principles - -- Store only memories that are durable, specific, actionable, novel, and evidence-backed. -- Prefer semantic/procedural memories over raw episodic turn summaries. -- Keep raw activity logs short-lived unless they consolidate into a decision, observation, runbook, or durable context note. -- Make memory quality observable with local commands and tests. -- Keep the implementation filesystem-based and reviewable; do not introduce a hosted memory service or vector database for this iteration. - -## Implementation status - -Completed so far: - -- Phase 1: deterministic salience scoring, novelty fingerprinting, duplicate rejection, and hard rejects for generic candidates. -- Phase 2: conditional Follow-up generation; removed fixed boilerplate Follow-up. -- Phase 3: lifecycle frontmatter (`salience`, `status`, `use_count`, `last_used_at`, `expires_at`, `supersedes`). Expiration uses active project memory days, not wall-clock days. -- Phase 4: composite retrieval scoring with category, salience, confidence, use-count, recency, generic-title penalties, and weak incidental-match suppression. -- Phase 5: `/memory-prune`, `/memory-rejections`, `/memory-review explain`, `/memory-review accept --force`, enhanced `/memory-review`, enhanced `/memory-doctor`, and ignored local runtime files. -- Phase 6: explicit supersession detection plus `/memory-supersede` manual correction. -- Phase 7 partial: unit tests for formatting, scoring, ranking, read/no-write/write/prune eval cases, active-day expiration, novelty, duplicate handling, and supersession. - -Still pending: - -- Command-level integration tests for `/memory-supersede`. Hook integration now covers `before_agent_start`, `tool_call`, and `turn_end` queue/no-queue behavior with a fake pi event API. Command integration covers `/memory-review accept` and `/memory-prune --dry-run --category`. Filesystem integration tests cover configurable memory roots, queue scaffolding, and accepted Markdown writes. -- Broader contradiction detection beyond direct modal conflicts. -- More nuanced category-specific prune policies if real usage shows the current active-day TTLs are too coarse. Current tests cover unused low-salience action pruning and stale queue detection. -- User-facing guide added at `docs/memory-context.md`. - -## Phase 1: Stop obvious low-value writes - -### Implementation - -1. Add a deterministic candidate-quality scorer before `queueCandidate()`. -2. Score each candidate on: - - durability: future sessions can use it; - - specificity: mentions concrete files, commands, APIs, repo behavior, user preference, or a confirmed gotcha; - - actionability: would change a future agent decision; - - novelty: not already represented in accepted memory or queue; - - evidence: backed by tests, inspected source, user instruction, or explicit outcome; - - scope: identifies whether it applies to project, file, command, issue-agent, memory-system, or user preference. -3. Reject candidates below the threshold instead of adding them to `queue.json`. -4. Add hard rejects for generic candidates whose title/body only says things like: - - `Updated index.ts` - - `Validated project behavior` - - `Captured durable context` - - `Ran npm test` - - `Review for durability before accepting as long-term memory` - -### Acceptance criteria - -- A turn that edits a file but produces no durable observation does not add a memory candidate. -- A turn that only runs tests does not add a candidate unless the test command itself is a newly discovered reusable command or validates a durable fix. -- Existing high-value memories such as explicit decisions, repo gotchas, and reusable runbooks still queue successfully. - -## Phase 2: Replace the fixed Follow-up section - -### Implementation - -1. Remove the unconditional `## Follow-up` block from `formatCandidateBody()`. -2. Add a helper such as `candidateFollowUp(args)` that returns zero or more concrete follow-up bullets. -3. Include `## Follow-up` only when there is a real unresolved action. -4. Suggested rules: - - no tests run on an implementation candidate: `Run targeted tests before promoting this memory.` - - low confidence candidate: `Verify this against source before accepting.` - - decision candidate without docs touched: `Consider documenting this decision in project docs if it is policy-level.` - - high confidence and no unresolved work: omit the section. - -### Acceptance criteria - -- New candidates no longer all contain the same follow-up text. -- High-confidence candidates with validation omit Follow-up unless there is a specific unresolved task. -- Tests cover candidates with and without follow-up sections. - -## Phase 3: Add memory metadata for lifecycle management - -### Implementation - -Extend accepted-memory frontmatter with optional fields: - -```yaml -salience: 0 -status: active -use_count: 0 -last_used_at: "" -supersedes: "" -expires_at: "" -``` - -Rules: - -- `status` can be `active`, `superseded`, `expired`, or `rejected`. -- `salience` comes from the quality scorer. -- Low-salience action/session memories get an `expires_at` active-day TTL instead of a wall-clock date. -- Decisions, observations, and runbooks do not expire by default. -- Retrieval ignores non-active memories unless explicitly requested. - -### Acceptance criteria - -- Newly accepted memories include `salience` and `status`. -- Retrieval excludes `superseded` and `expired` notes. -- Existing memories without the new fields continue to load as active with unknown salience. - -## Phase 4: Improve retrieval ranking - -### Implementation - -Replace pure lexical ranking with a composite score: - -```text -score = - lexical relevance - + salience boost - + confidence boost - + category boost - + recency/use_count boost - - staleness penalty - - generic-title penalty - - low-value-category penalty -``` - -Category priorities: - -1. `50-decisions` -2. `70-runbooks` -3. `60-observations` -4. `20-context` -5. `40-actions` -6. `80-sessions` - -Update `last_used_at` and `use_count` for injected memories after retrieval. - -### Acceptance criteria - -- For prompts about implementation choices, decisions outrank action/session summaries with similar terms. -- For prompts asking how to perform a repeated task, runbooks outrank old session notes. -- Generic action memories are not injected unless they are the only relevant memory and pass the minimum score. - -## Phase 5: Add prune, review, and diagnostics commands - -### Implementation - -Add or extend commands: - -- `/memory-prune --dry-run`: lists expired or low-salience candidates/memories that would be removed or marked expired. -- `/memory-prune`: marks expired accepted memories as `expired` and removes stale queue entries. -- `/memory-review`: show salience, rejection reason, and concrete follow-up if present. -- `/memory-doctor`: include counts by status, average salience, expired notes, queue reject counts, and top generic-title offenders. - -### Acceptance criteria - -- Users can see why a candidate was queued or rejected. -- Users can remove stale low-value memories without manually editing files. -- Diagnostics make memory bloat visible. - -## Phase 6: Handle stale and superseded memories - -### Implementation - -1. Add a contradiction/supersession check before accepting or auto-promoting a candidate. -2. Check for accepted memories with overlapping tags, paths, and title terms. -3. If the new memory explicitly replaces an old convention, write `supersedes` on the new note and mark the old note `status: superseded`. -4. Add `/memory-supersede ` for manual correction. - -### Acceptance criteria - -- A new decision can supersede an old decision without both being injected as active guidance. -- Retrieval does not inject superseded memories. -- Manual supersession works without deleting historical notes. - -## Phase 7: Add local memory evals - -### Implementation - -Create tests for four behaviors: - -1. **Write eval:** high-value decision/observation/runbook creates a candidate. -2. **No-write eval:** trivial file edits, generic test runs, and boilerplate summaries do not create candidates. -3. **Update eval:** changed convention marks older conflicting memory as superseded. -4. **Read eval:** prompts retrieve the right memory category and avoid irrelevant low-value memories. - -Test fixtures should include examples of: - -- generic action summary; -- durable repo gotcha; -- explicit user preference; -- superseded decision; -- reusable command/runbook. - -### Acceptance criteria - -- Tests fail if the fixed Follow-up text returns globally. -- Tests fail if generic edit/test summaries are queued. -- Tests fail if superseded memories are injected. -- Tests fail if low-value action/session notes outrank relevant decisions/runbooks. - -## Suggested implementation order - -1. Phase 2: remove/fix the hardcoded Follow-up section. -2. Phase 1: add candidate-quality scorer and hard rejects. -3. Phase 7 partial: add no-write/write tests for the new scorer and Follow-up behavior. -4. Phase 3: add metadata fields while preserving compatibility. -5. Phase 4: improve retrieval ranking. -6. Phase 5: add prune/diagnostic command improvements. -7. Phase 6: add supersession once the metadata and retrieval behavior are stable. -8. Phase 7 full: complete update/read evals. - -## Initial code touch points - -- `.pi/extensions/memory-context/index.ts` - - `formatCandidateBody()` - - `queueCandidate()` - - `validateCandidate()` - - `writeAcceptedMemory()` - - `parseFrontmatter()` / `allNotes()` - - `lexicalSearch()` / ranking helpers - - `/memory-review`, `/memory-doctor`, `/memory-dedupe` - - `turn_end` candidate construction - -## Open questions - -- What salience threshold should be used initially? Current implementation uses 6/10. -- Should rejected candidates be silently dropped, or should a debug log keep recent rejection reasons? Current implementation keeps a rolling local `.pi/memory/rejections.json` ignored by git. -- Should auto-promotion remain enabled after scoring is added? Current implementation keeps it, with validation through the salience filter and specific-match checks. -- Should action/session memories be written at all? Current implementation queues them only if they pass salience scoring and gives lower-salience action/session notes active-day expiration. diff --git a/docs/memory-context.md b/docs/memory-context.md deleted file mode 100644 index 17e1bc5e..00000000 --- a/docs/memory-context.md +++ /dev/null @@ -1,73 +0,0 @@ -# Memory context - -The `memory-context` extension stores local, reviewable memories under `.pi/memory/` and injects only relevant active memories into future turns. - -## Lifecycle - -1. **Candidate creation**: after a tool-using turn, the extension builds a candidate only when files were edited or tests ran. -2. **Review/scoring**: candidates must pass salience review. Low-confidence, generic, duplicate, unsafe, or low-salience candidates are rejected. -3. **Queue**: accepted short-term candidates are written to `.pi/memory/queue.json` for review. -4. **Promotion**: `/memory-review accept ...` writes long-term Markdown notes. Frequently matching queued candidates can auto-promote after repeated specific matches. -5. **Retrieval**: active, non-expired notes are ranked by lexical relevance, salience, confidence, category, use count, recency, and generic-title penalties. -6. **Maintenance**: prune, supersede, and rejection commands keep memory quality visible. - -## Salience - -Salience is a 0-10 usefulness score. Candidates need at least 6/10 and medium confidence to queue. Good memories are durable, specific, actionable, novel, and evidence-backed. - -Good examples: - -- A project convention that changes future edits. -- A root cause or gotcha confirmed by source/tests. -- A reusable runbook or command. -- A durable user preference. - -Bad examples: - -- `Updated index.ts`. -- `Ran npm test`. -- Generic session summaries. -- Boilerplate follow-up notes. - -## Active-day expiration - -Action/session memories can expire by **active memory days**, not wall-clock time. If a project is not worked on for a month, memories do not age out just because calendar time passed. - -Current defaults: - -- action/session salience `< 6`: `active-days:30` (`MEMORY_CONTEXT_LOW_TTL_ACTIVE_DAYS` override) -- action/session salience `6-7`: `active-days:90` (`MEMORY_CONTEXT_MEDIUM_TTL_ACTIVE_DAYS` override) -- action/session salience `>= 8`: no expiration -- decisions, observations, runbooks, and context: no default expiration - -`last_used_at` is stored as `active-day:N`, and `use_count` increments when a memory is retrieved. - -## Commands - -- `/memory-review` — show queued candidates with salience and review reason. -- `/memory-review explain 1|1,3|2-4` — explain current review outcome, duplicate status, fingerprint, and supersession impact. -- `/memory-review accept all|1,3|2-4` — promote selected queued candidates. -- `/memory-review accept --force 1|1,3|2-4` — promote selected candidates even if they duplicate active accepted memory; safety and salience checks still apply. -- `/memory-review deny all|1,3|2-4` — remove selected queued candidates. -- `/memory-rejections` — show recently rejected candidates and reasons. -- `/memory-rejections clear` — clear the local rejection log. -- `/memory-search ` — search active memories. -- `/memory-list` — list accepted memories. -- `/memory-list --status active|expired|superseded|all` — filter accepted memories by status. -- `/memory-prune --dry-run` — preview stale queue entries and prunable accepted memories. -- `/memory-prune --dry-run --category action|session|40-actions|80-sessions` — preview pruning for one category. -- `/memory-prune` — expire prunable accepted memories and remove stale queue entries. -- `/memory-supersede ` — manually mark an older memory superseded by a newer one. Use `/memory-list` indexes or paths. -- `/memory-doctor` — show memory health, salience, prune counts, rejection counts, and generic-title offenders. -- `/memory-doctor --verbose` — include lowest-salience active memories. -- `/memory-dedupe --dry-run` / `/memory-dedupe` — preview/remove exact duplicate accepted memories. - -## Local runtime files - -These are local and ignored by git: - -- `.pi/memory/queue.json` -- `.pi/memory/rejections.json` -- `.pi/memory/state.json` - -Accepted Markdown memories remain inspectable and reviewable under `.pi/memory/*/`. diff --git a/package-lock.json b/package-lock.json index 9456e039..844a6e35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@earendil-works/pi-ai": "^0.74.1", "@earendil-works/pi-coding-agent": "^0.74.0", "@earendil-works/pi-tui": "^0.74.1", - "@observal/pi-insights": "^1.2.2", "@plannotator/pi-extension": "^0.19.20", "@sinclair/typebox": "^0.34.49", "chokidar": "^5.0.0", @@ -2455,15 +2454,6 @@ "node": ">= 8" } }, - "node_modules/@observal/pi-insights": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@observal/pi-insights/-/pi-insights-1.2.2.tgz", - "integrity": "sha512-YRaJH2/fJwoLZscjEuRkR66hJrfNgZ8NTSjaO1rm4fvaqW6JkGIAZhQhhJtA+UzUQBUSlvzkVVCrarZumq2Hqw==", - "license": "AGPL-3.0-only", - "peerDependencies": { - "@earendil-works/pi-coding-agent": ">=0.74.0" - } - }, "node_modules/@pierre/diffs": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.3.tgz", diff --git a/package.json b/package.json index a658b68c..4f81129f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "littleCoder": { "packages": [ "@plannotator/pi-extension", - "@observal/pi-insights", "pi-better-openai", "pi-ask-user" ] @@ -52,7 +51,6 @@ "@earendil-works/pi-ai": "^0.74.1", "@earendil-works/pi-coding-agent": "^0.74.0", "@earendil-works/pi-tui": "^0.74.1", - "@observal/pi-insights": "^1.2.2", "@plannotator/pi-extension": "^0.19.20", "@sinclair/typebox": "^0.34.49", "chokidar": "^5.0.0", diff --git a/plans/enhancements-roadmap.md b/plans/enhancements-roadmap.md new file mode 100644 index 00000000..99fcf6cf --- /dev/null +++ b/plans/enhancements-roadmap.md @@ -0,0 +1,200 @@ +# Enhancements Roadmap Plan + +## Context + +The requested work spans little-coder's extension stack, bundled skills, prompt text, telemetry/dashboard UX, session search, reflection-driven skill creation, planning-command cleanup, browser/web UI tooling, and Python sandboxing. + +Verified repository facts so far: +- `package.json` already depends on `@observal/pi-insights`, `@plannotator/pi-extension`, `pi-ask-user`, and optional `@tobilu/qmd`. +- Existing first-party extensions include `skill-inject`, `memory-context`, `mode-commands`, `plan-mode`, `extra-tools`, `browser`, `permission-gate`, `usage-dashboard`, `inspect`, and `branding`. +- `AGENTS.md` contains the long global agent prompt that should be compressed. +- `skill-inject` currently parses `keywords` frontmatter but still uses a hard-coded `INTENT_MAP` for tool prediction. Tool/protocol skills mostly lack `keywords`; knowledge skills already have them. +- The `/skills` command/tool currently lists names, token costs, and keywords, but not descriptions. +- Two `/plan` providers exist: `.pi/extensions/mode-commands/index.ts` registers a prompt-only `/plan`, while `scripts/patch-extension-notifications.mjs` patches `@plannotator/pi-extension` to register the real planning-mode `/plan`. +- `memory-context` injects memory automatically, exposes `/memory-*` commands, writes `.pi/memory`, and is referenced by `branding` startup text. +- `usage-dashboard` already parses Pi session JSONL cost/tokens/tool data for an inline `/usage` TUI. +- `inspect` already implements a local web server command pattern, snapshot capture, static dashboard launch, port probing, and browser/PWA opening. +- `permission-gate` currently whitelists `python ` and `python3 ` by prefix, so arbitrary Python can bypass command-level permission checks. + +User decisions captured: +- Vendor `@observal/pi-insights` directly despite AGPL-3.0-only licensing; preserve license/NOTICE details. +- Sandbox **all** Python execution without asking for approval. +- Reflection-generated skills should default to user-level `~/.pi/skills`; `skill-inject` should load user-level and repo skills. Add `/promote-user-skill [skill]` to copy user skills into repo `skills/` with duplicate checks. + +## Approach + +Implement this as a set of small, testable extension changes rather than one monolithic rewrite: + +1. **Stabilize existing UX and prompt behavior first**: fix duplicate `/plan`, compress prompts, improve tool descriptions/output, and update `/skills` display. +2. **Rework skill injection around skill metadata and roots**: add missing `keywords`/`description` frontmatter, load both repo `skills/` and user `~/.pi/skills`, score tool/reference skills from frontmatter, and add per-session cooldown state so automatic injection does not repeat recent skills. +3. **Add session intelligence as reusable infrastructure**: create a session-transcript parser/index shared by breadcrumbs, reflection, cost dashboards, and the web UI. +4. **Replace memory with reflection-generated skills**: remove automatic memory injection and `/memory-*`, then add `/reflect` commands that review bounded session history, propose user-level skill files, and require user yes/no/edit approval before writing to `~/.pi/skills`. +5. **Vendor and unify dashboards**: vendor `pi-insights`, port useful cost-dashboard concepts from `agent-cost-dashboard`, and expose a richer `/web` UI that links or embeds cost, inspect, breadcrumbs, skills, reflection, commands, tools, and chat. +6. **Sandbox Python execution**: stop treating arbitrary Python as safe; route all Python execution through a sandbox path without prompting for approval, with tests that prove Python cannot trivially bypass `permission-gate`. + +## Files to modify + +Critical paths expected to change: +- `AGENTS.md` +- `package.json` +- `package-lock.json` +- `scripts/patch-extension-notifications.mjs` +- `.pi/extensions/skill-inject/index.ts` +- `.pi/extensions/skill-inject/frontmatter.ts` +- `.pi/extensions/skill-inject/*.test.ts` +- `.pi/extensions/mode-commands/index.ts` +- `.pi/extensions/mode-commands/mode-prompts.ts` +- `.pi/extensions/plan-mode/*.ts` +- `.pi/extensions/extra-tools/index.ts` +- `.pi/extensions/browser/index.ts` +- `.pi/extensions/permission-gate/index.ts` +- `.pi/extensions/permission-gate/*.test.ts` +- `.pi/extensions/usage-dashboard/index.ts` +- `.pi/extensions/branding/index.ts` +- `.pi/extensions/memory-context/**` (remove or replace with migration stub) +- New shared session parser/index module, likely `.pi/extensions/_shared/session-history.ts` +- New breadcrumbs extension, likely `.pi/extensions/breadcrumbs/` +- New reflection extension, likely `.pi/extensions/reflect-skills/` +- New vendored insights/cost extension, likely `.pi/extensions/pi-insights/` +- New web UI extension, likely `.pi/extensions/web/` +- `skills/**/*.md` frontmatter updates +- New skill files from `mattpocock/skills`, likely `skills/engineering/improve-codebase-architecture/` +- `NOTICE` / license docs if vendored code is included + +## Reuse + +Existing code and external references to reuse: +- `.pi/extensions/skill-inject/frontmatter.ts` and `loadSkills()` already parse skill markdown/frontmatter; extend them rather than replacing the loader. +- `.pi/extensions/skill-inject/index.ts` already has budgets, `/skills`, `/skill`, explicit `/skill:`, recency, last-failed-tool recovery, and UI notifications; extend it to load user and repo skill roots with deterministic precedence. +- `.pi/extensions/usage-dashboard/index.ts` already parses `~/.pi/agent/sessions/**/*.jsonl` for provider/model/cost/token/tool stats. +- `.pi/extensions/powerline-footer-unified/index.ts` already derives the project session directory and extracts recent user prompts from JSONL. +- `.pi/extensions/inspect/index.ts` already has reusable local-dashboard patterns: port probing, subprocess/server lifecycle, snapshots, browser/PWA opening, and request watching. +- `.pi/extensions/browser/index.ts` already registers `enableBrowserTools`; only the description/prompt snippet needs tuning. +- `.pi/extensions/permission-gate/index.ts` already centralizes bash allowlisting and external file access policy. +- `@tobilu/qmd` is installed as an optional dependency; its README documents BM25, semantic search, hybrid query, JSON output, and SDK usage. +- External `mrexodia/agent-cost-dashboard` is MIT-licensed and provides cost-dashboard ideas: global stats, daily spend charts, model/tool/project/session views, Pi/OMP/Claude/Codex parsing, subagent grouping, and transcript export. +- External `jo-inc/pi-reflect` is MIT-licensed and provides transcript collection, reflection history/config commands, and safe/surgical edit concepts. +- External `briggsd/pi-reflect-ext` provides a skill-management-oriented reflection design with safe skill path confinement and background review prompts. +- External `mattpocock/skills` is MIT-licensed and contains `skills/engineering/improve-codebase-architecture/SKILL.md` plus support files (`LANGUAGE.md`, `DEEPENING.md`, etc.). + +## Steps + +### Phase 1 — Prompt, command, and small tool fixes + +- [ ] Remove the prompt-only `/plan` registration from `.pi/extensions/mode-commands/index.ts`; keep real planning mode owned by Plannotator's patched `/plan` and optionally add a non-conflicting `/plan-prompt` only if still useful. +- [ ] Update tests around `scripts/patch-extension-notifications.mjs` and add a command-registration test so only the real `/plan` is exposed. +- [ ] Compress `AGENTS.md` by removing repeated tool-efficiency/evidence wording, keeping only invariants, autonomy, tool-selection order, ambiguity handling, and skill discovery. +- [ ] Compress `.pi/extensions/mode-commands/mode-prompts.ts` to short mode prompts with clear constraints and outputs. +- [ ] Update `.pi/extensions/browser/index.ts` so `enableBrowserTools` says to prefer `webfetch`/`websearch` for non-interactive web retrieval and only enable Browser* tools for interactive navigation/click/type/extract workflows. +- [ ] Update `.pi/extensions/extra-tools/index.ts` `findRead` output to prefix the effective invocation: `pattern`, `path`, `maxFiles`, `maxCharacters`, and `ignoreDefaultExcludes`, including no-match/error paths. +- [ ] Update `skills/tools/find_read.md` and `skills/tools/skills.md` to describe the new output and skill descriptions. + +### Phase 2 — Skill metadata and injection cooldown + +- [ ] Add `keywords` and concise `description` frontmatter to every bundled tool/protocol skill and to `skills/hatch-pet/SKILL.md`; add descriptions to knowledge skills where missing. +- [ ] Import `mattpocock/skills/skills/engineering/improve-codebase-architecture/` into `skills/engineering/improve-codebase-architecture/`, preserving support files and adding little-coder frontmatter fields (`type`, `token_cost`, `keywords`, and any needed `requires_tools`). +- [ ] Extend skill discovery to load both repo `skills/` and user `~/.pi/skills`. Repo skills should remain packaged/canonical; user skills should be mutable and higher priority for explicit `/skill` by exact name. If both roots contain the same skill name, list both origins in `/skills` and make automatic injection choose the higher-priority origin deterministically. +- [ ] Add `/promote-user-skill [skill]`: + - without an argument, list user skills that are not already present in repo `skills/` by same name/content; + - with an argument, copy the selected user skill directory into repo `skills/user//` by default, unless a known repo category mapping is explicitly supported for that skill type; + - detect duplicate names, identical content, and near-duplicate descriptions/keywords before writing; + - skip identical duplicates, warn on conflicting same-name skills, and require an explicit conflict resolution path such as `--force`/rename guidance rather than overwriting silently. +- [ ] Replace hard-coded tool intent prediction with frontmatter-driven scoring for tool skills. Keep non-keyword priority sources only where they are behavioral rather than semantic: explicit `/skill`, required tools from selected references, last failed tool, and recent tool-call recovery. +- [ ] Export/test pure selection helpers instead of duplicating `INTENT_MAP` logic in tests. +- [ ] Add automatic-injection cooldown state per session: + - explicit `/skill` always bypasses cooldown; + - last-failed-tool recovery may bypass once after a failure; + - other automatic tool/reference skills are suppressed if injected in the previous turn and by default become eligible again after 3 completed user turns; + - skipped skills are listed in the `skill-inject` notification as `suppressed recent [...]`. +- [ ] Add long-conversation warning throttled by session: notify once when either context usage is above ~75% or the session has at least ~16 user turns, then at most every 6 turns. Wording should suggest `/compact` or starting a fresh session, not alarm the user. +- [ ] Update `/skills`, `/skill` completions, and the `skills` tool output to include each skill description (frontmatter description or a short first-line fallback), not just name/token/keywords. + +### Phase 3 — Shared session history and breadcrumbs tools + +- [ ] Create `.pi/extensions/_shared/session-history.ts` to discover Pi session JSONL files using `PI_CODING_AGENT_DIR || ~/.pi/agent`, parse session headers/messages/tool events safely, normalize project/cwd/session id/date, and produce bounded outlines. +- [ ] Add lexical search over session outlines/messages using BM25-ish scoring that boosts user prompts, file paths, tool names, and current project matches. +- [ ] Add optional semantic search adapter using `@tobilu/qmd` when available; fall back to lexical with a clear mode note when QMD cannot initialize. +- [ ] Add `breadcrumbs_search` tool: returns only outlines/snippets, not full transcripts. Defaults: current project first, limit 5, snippets <= 300 chars, no tool-output bodies. +- [ ] Add `breadcrumbs_read` tool: requires a session id/path from search, returns bounded chunks with `cursor`, `maxTurns` default 8/max 20, `maxCharacters` default 8000/hard cap 16000, and `includeToolOutput` default false. +- [ ] Add tests for parser robustness, lexical ranking, QMD fallback, outline-only search, and read guards. + +### Phase 4 — Reflection replaces memory + +- [ ] Remove `memory-context` from active extension loading and delete its tests/source once replacement commands exist; do not leave automatic memory injection in place. +- [ ] Update `branding` startup text to remove memory counts and `/memory-*` hints; replace with `/reflect`, `/reflect-review`, `/breadcrumbs`, and `/skills` hints. +- [ ] Add `.pi/extensions/reflect-skills/` with commands patterned after the current `/memory-*` ergonomics but skill-oriented: + - `/reflect` — review recent session history and propose one or more skill changes; + - `/reflect-review` — show queued proposals; + - `/reflect-accept`, `/reflect-deny`, or `/reflect-review accept|deny` — apply/discard proposals; + - `/reflect-history` and `/reflect-doctor` — audit runs and dependencies. +- [ ] Reflection should use bounded session history from the shared parser/breadcrumbs index, not raw unbounded transcripts. +- [ ] Reflection prompt should propose user-level skill files with required frontmatter: `name`, `description`, `type`, `token_cost`, `keywords`, and optional `requires_tools`. +- [ ] Reflection approval loop must be user-mediated: for each proposal ask yes/no/edit; an edit response is treated as guidance, regenerates/adapts the skill, and presents it again. +- [ ] Write accepted skills to `~/.pi/skills//SKILL.md` by default so `skill-inject` loads them on the next reload; use path confinement and slug validation from the external reflection designs. +- [ ] Add a one-time migration/notice for existing `.pi/memory` users explaining that memory was superseded and is no longer injected. Do not auto-convert old memories into skills without approval. +- [ ] Document the promotion flow: user-level skills are experimental/local; `/promote-user-skill` copies stable skills into repo `skills/` after duplicate checks so they can be packaged with little-coder. + +### Phase 5 — Vendor insights/cost dashboard and unified web UI + +- [ ] Vendor `@observal/pi-insights` into `.pi/extensions/pi-insights/`, preserving its AGPL license headers and adding license/NOTICE entries as an explicit user-approved vendoring decision. +- [ ] Remove the `@observal/pi-insights` package entry from `littleCoder.packages` and dependencies only after the vendored extension is active; remove obsolete postinstall patches against `node_modules/@observal/pi-insights`. +- [ ] Port selected `agent-cost-dashboard` concepts into the vendored TypeScript extension rather than shelling out to Python: daily spending chart, model breakdown, tool usage, project/session browser, top costly sessions, subagent grouping, and transcript export links. +- [ ] Reuse `usage-dashboard` parsing logic where possible; move shared cost/session aggregation to a helper so `/usage`, `/insights`, `/web`, and breadcrumbs do not each parse sessions differently. +- [ ] Add `.pi/extensions/web/` with `/web` command, binding to `127.0.0.1` by default and printing SSH tunnel instructions for remote use. +- [ ] Implement `/web start|stop|restart|status|open` using the safer server lifecycle pattern from `inspect`. +- [ ] Web UI feature set should include: shared agent chat, streaming transcript, expandable tool/thinking blocks, abort/new session, command palette with command descriptions, tools registry, skills list/load with descriptions, breadcrumbs search/read, reflection review/approval, cost dashboard, inspect snapshot links, and permission prompts. +- [ ] Prefer a no-build static frontend served from the extension if feasible; add a small dependency such as `ws` only if bidirectional streaming cannot be cleanly handled with built-in HTTP + SSE/POST. + +### Phase 6 — Python sandbox first draft + +- [ ] Remove broad `python ` and `python3 ` from `BUILTIN_SAFE_PREFIXES` in `permission-gate`; no Python execution should be auto-approved by prefix. +- [ ] Add Python-command detection for `python`, `python3`, venv Python paths, `uv run python`, `python -m ...`, `python -c`, stdin/heredoc scripts, and direct `.py` execution when invoked through bash. +- [ ] Route every detected Python execution through a sandbox path without asking the user for approval. Prefer mutating the `bash` tool input in `tool_call` to invoke a generated sandbox wrapper; if a command cannot be rewritten safely, block with a clear sandbox-unavailable reason rather than asking or running unsandboxed. +- [ ] First-draft sandbox design: + - On Linux, prefer an OS sandbox if available (`bubblewrap`/similar): read-only bind the workspace unless write access to a controlled temp/work output dir is explicitly needed, tmpfs `/tmp`, no network where supported, minimal env, timeout, output cap. + - If no OS sandbox is available, run only in the most restrictive fallback available and block with a clear message if containment cannot be provided; do not ask for approval and do not silently run unsandboxed. + - Use TypeBox/Zod-style validation in TypeScript for command specs; do not rely on Pydantic as the security boundary. Pydantic can validate a helper manifest if a Python helper is later introduced, but validation is not containment. + - Optionally add a restricted AST helper only for tiny data-transformation snippets, clearly documented as convenience rather than a security sandbox. +- [ ] Include test-running commands such as `python -m pytest` in the sandbox route. They should not require approval, but they also should not run outside the sandbox. +- [ ] Add tests proving `python -c 'import os; os.system(...)'`, heredoc Python, arbitrary Python scripts, `python -m pytest`, and venv Python paths are sandboxed or blocked when sandboxing is unavailable, never silently allowed unsandboxed. + +### Phase 7 — Cleanup and docs + +- [ ] Update README/CHANGELOG if these commands/features are documented there. +- [ ] Remove stale memory docs/references and update startup hints. +- [ ] Update package metadata and lockfile for any new vendored extensions or dependencies. + +## Verification + +Automated checks: +- `npm test` +- `npm run typecheck` +- Focused Vitest suites: + - `skill-inject` frontmatter/scoring/cooldown/listing/user-root/promotion tests + - `mode-commands` command-registration tests + - `extra-tools` `findRead` output tests + - `browser` description snapshot/registration tests if existing patterns allow + - `permission-gate` Python allow/block tests + - new `breadcrumbs` parser/search/read-guard tests + - new `reflect-skills` proposal/path/frontmatter/approval tests + - cost aggregation tests shared by `/usage`, `/insights`, and `/web` + +Manual checks: +- Start a local session and confirm `/plan` enters Plannotator planning mode, with no prompt-only `/plan:1` duplicate. +- Run `/skills` and the `skills` tool; descriptions and origins should appear for repo and user-level skills. +- Trigger `findRead` and verify the returned text includes effective `pattern`, `maxFiles`, and `maxCharacters`. +- Run a multi-turn sequence where the same skill would match repeatedly; confirm immediate reinjection is suppressed, explicit `/skill` still works, and long-session warning is throttled. +- Run `breadcrumbs_search` and `breadcrumbs_read`; search should return outlines only, read should enforce chunk guards. +- Run `/reflect`; verify proposals require yes/no/edit approval and accepted skills land under `~/.pi/skills` with keywords. +- Run `/promote-user-skill` with no args and with a selected skill; verify promotable listing, duplicate checks, and repo `skills/` output. +- Confirm old `/memory-*` commands are gone or replaced by clear reflection equivalents and that `.pi/memory` is not injected. +- Run `/usage`, `/insights`, and `/web`; compare aggregate costs/session counts against a small fixture or known session set. +- Confirm `/web` binds to `127.0.0.1` and prints tunnel/open instructions. +- Try Python bypass examples and verify they are sandboxed, or blocked if sandboxing is unavailable, without asking for approval. + +## Resolved decisions + +- Directly vendor AGPL `@observal/pi-insights` with license/NOTICE preservation. +- Sandbox all Python execution; do not use approval as the escape hatch. +- Reflection writes to user-level `~/.pi/skills` by default; repo `skills/` receives skills only via `/promote-user-skill` after duplicate checks. + diff --git a/scripts/patch-extension-notifications.mjs b/scripts/patch-extension-notifications.mjs index 4660da79..59fd5a45 100644 --- a/scripts/patch-extension-notifications.mjs +++ b/scripts/patch-extension-notifications.mjs @@ -33,41 +33,6 @@ export const PATCHES = [ oldText: `function openBrowserForServer(serverUrl: string, ctx: ExtensionContext): void {\n\tconst browserResult = openBrowser(serverUrl);\n\tif (isRemoteSession()) {\n\t\tctx.ui.notify(\`[Plannotator] \${serverUrl}\`, "info");\n\t} else if (!browserResult.opened) {\n\t\tctx.ui.notify(\`Open this URL to review: \${serverUrl}\`, "info");\n\t}\n}`, newText: `function openBrowserForServer(serverUrl: string, ctx: ExtensionContext): void {\n\tctx.ui.notify(\`Plannotator listening at: \${serverUrl}\`, "info");\n\tconst browserResult = openBrowser(serverUrl);\n\tif (!browserResult.opened) {\n\t\tctx.ui.notify(\`Open this URL to review: \${serverUrl}\`, "info");\n\t}\n}`, }, - { - name: "pi-insights http server imports", - path: ["node_modules", "@observal", "pi-insights", "index.ts"], - oldText: `import { execFile as execFileCb } from "node:child_process";\nimport { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";`, - newText: `import { execFile as execFileCb } from "node:child_process";\nimport { createServer, type Server } from "node:http";\nimport { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";`, - }, - { - name: "pi-insights http server constants", - path: ["node_modules", "@observal", "pi-insights", "index.ts"], - oldText: `const REPORT_PATH = join(DATA_DIR, "report.html");\nconst REPORT_MD_PATH = join(DATA_DIR, "report.md");`, - newText: `const REPORT_PATH = join(DATA_DIR, "report.html");\nconst REPORT_MD_PATH = join(DATA_DIR, "report.md");\nconst REPORT_PORT = 5463;\nconst REPORT_URL = \`http://localhost:\${REPORT_PORT}\`;\n\nlet reportServer: Server | null = null;`, - }, - { - name: "pi-insights http server helper", - path: ["node_modules", "@observal", "pi-insights", "index.ts"], - oldText: `function displayLabel(key: string): string {\n\treturn (\n\t\tLABEL_MAP[key] ??\n\t\tkey.replace(/_/g, " ").replace(/\\b\\w/g, (c) => c.toUpperCase())\n\t);\n}`, - newText: `function displayLabel(key: string): string {\n\treturn (\n\t\tLABEL_MAP[key] ??\n\t\tkey.replace(/_/g, " ").replace(/\\b\\w/g, (c) => c.toUpperCase())\n\t);\n}\n\nasync function startReportServer(): Promise {\n\tif (reportServer?.listening) return REPORT_URL;\n\n\treportServer = createServer(async (req, res) => {\n\t\tconst path = new URL(req.url ?? "/", REPORT_URL).pathname;\n\t\tif (path !== "/" && path !== "/report.html") {\n\t\t\tres.writeHead(404, { "content-type": "text/plain; charset=utf-8" });\n\t\t\tres.end("Not found");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst html = await readFile(REPORT_PATH, "utf8");\n\t\t\tres.writeHead(200, {\n\t\t\t\t"content-type": "text/html; charset=utf-8",\n\t\t\t\t"cache-control": "no-store",\n\t\t\t});\n\t\t\tres.end(html);\n\t\t} catch {\n\t\t\tres.writeHead(404, { "content-type": "text/plain; charset=utf-8" });\n\t\t\tres.end("Pi Insights report has not been generated yet. Run /insights first.");\n\t\t}\n\t});\n\n\tawait new Promise((resolve, reject) => {\n\t\tconst onError = (err: NodeJS.ErrnoException) => {\n\t\t\tif (err.code === "EADDRINUSE") resolve();\n\t\t\telse reject(err);\n\t\t};\n\t\treportServer!.once("error", onError);\n\t\treportServer!.listen(REPORT_PORT, "127.0.0.1", () => {\n\t\t\treportServer!.off("error", onError);\n\t\t\tresolve();\n\t\t});\n\t});\n\n\treturn REPORT_URL;\n}`, - }, - { - name: "pi-insights browser URL notification", - path: ["node_modules", "@observal", "pi-insights", "index.ts"], - oldText: `\tctx.ui.notify(\`✅ Report saved: \${REPORT_PATH}\`, "success");\n\n\tif (!noOpen) {\n\t\tconst opener = platform() === "darwin" ? "open" : "xdg-open";\n\t\texecFile(opener, [REPORT_PATH]).catch(() => {\n\t\t\tctx.ui.notify(\`Open manually: \${REPORT_PATH}\`, "info");\n\t\t});\n\t}\n}`, - newText: `\tconst reportUrl = await startReportServer();\n\tctx.ui.notify(\`✅ Report saved: \${REPORT_PATH}\`, "success");\n\tctx.ui.notify(\`Pi Insights report URL: \${reportUrl}\`, "info");\n\n\tif (!noOpen) {\n\t\tconst opener = platform() === "darwin" ? "open" : "xdg-open";\n\t\texecFile(opener, [reportUrl]).catch(() => {\n\t\t\tctx.ui.notify(\`Open manually: \${reportUrl}\`, "info");\n\t\t});\n\t}\n}`, - alreadyAppliedText: [ - "const reportUrl = await startReportServer();", - "Pi Insights report URL: ${reportUrl}", - "execFile(opener, [reportUrl])", - ], - }, - { - name: "pi-insights canonical command", - path: ["node_modules", "@observal", "pi-insights", "index.ts"], - oldText: `pi.registerCommand("pi-insights", {`, - newText: `pi.registerCommand("insights", {`, - }, { name: "pi-inspect clearer group labels", path: ["node_modules", "pi-inspect", "public", "app.js"], diff --git a/scripts/patch-extension-notifications.test.mjs b/scripts/patch-extension-notifications.test.mjs index 94af0aa1..93557cf3 100644 --- a/scripts/patch-extension-notifications.test.mjs +++ b/scripts/patch-extension-notifications.test.mjs @@ -6,6 +6,10 @@ import { PATCHES, applyTextPatch, isPatchApplied } from "./patch-extension-notif const root = process.cwd(); describe("postinstall node_modules patches", () => { + it("does not patch vendored pi-insights through node_modules", () => { + expect(PATCHES.some((patch) => patch.name.includes("pi-insights") || patch.path.includes("@observal"))).toBe(false); + }); + it("all patch targets either match upstream text or are already applied", () => { for (const patch of PATCHES) { const file = join(root, ...patch.path); diff --git a/skills/engineering/improve-codebase-architecture/HTML-REPORT.md b/skills/engineering/improve-codebase-architecture/HTML-REPORT.md new file mode 100644 index 00000000..e7cc702c --- /dev/null +++ b/skills/engineering/improve-codebase-architecture/HTML-REPORT.md @@ -0,0 +1,3 @@ +# HTML Report Guidance + +When a visual report is requested, write a self-contained HTML file in the OS temp directory. Include cards for each candidate with files, problem, solution, benefits, before/after diagrams, recommendation strength, and a top recommendation. diff --git a/skills/engineering/improve-codebase-architecture/LANGUAGE.md b/skills/engineering/improve-codebase-architecture/LANGUAGE.md new file mode 100644 index 00000000..4e1599e8 --- /dev/null +++ b/skills/engineering/improve-codebase-architecture/LANGUAGE.md @@ -0,0 +1,3 @@ +# Architecture Language + +Use module, interface, implementation, depth, seam, adapter, leverage, and locality consistently. Prefer "seam" over "boundary" and "adapter" over generic integration names. diff --git a/skills/engineering/improve-codebase-architecture/SKILL.md b/skills/engineering/improve-codebase-architecture/SKILL.md new file mode 100644 index 00000000..37801229 --- /dev/null +++ b/skills/engineering/improve-codebase-architecture/SKILL.md @@ -0,0 +1,43 @@ +--- +name: improve-codebase-architecture +description: Find deepening opportunities in a codebase and propose refactors that improve architecture, testability, and AI-navigability. +type: workflow +token_cost: 150 +keywords: [architecture, codebase architecture, refactor, refactoring, testability, module, interface, deep module, shallow module, seam, adapter, locality, leverage] +requires_tools: [code_search, lsp, read, write] +--- +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +## Glossary + +Use these terms exactly in every suggestion: + +- **Module** — anything with an interface and an implementation (function, class, package, slice). +- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. +- **Implementation** — the code inside. +- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. Deep = high leverage. Shallow = interface nearly as complex as the implementation. +- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. +- **Adapter** — a concrete thing satisfying an interface at a seam. +- **Leverage** — what callers get from depth. +- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. + +Key principles: + +- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** +- **One adapter = hypothetical seam. Two adapters = real seam.** + +## Process + +1. Explore domain glossary/docs and ADRs first. +2. Use code_search/lsp/read to identify friction: + - understanding one concept requires bouncing through many small modules; + - modules are shallow; + - pure functions were extracted for testability but bugs hide in orchestration; + - tightly-coupled modules leak across seams; + - tests are missing or hard to write through the current interface. +3. Present candidates with: files, problem, solution, benefits in terms of locality/leverage, before/after structure, and recommendation strength. +4. End with a top recommendation and ask which candidate to explore. +5. Do not implement refactors until the user chooses one. diff --git a/skills/knowledge/bfs_state_space.md b/skills/knowledge/bfs_state_space.md index 2c8a0761..7f21715b 100644 --- a/skills/knowledge/bfs_state_space.md +++ b/skills/knowledge/bfs_state_space.md @@ -5,5 +5,6 @@ topic: State-Space Search token_cost: 120 keywords: [bucket, pouring, state space, minimum moves, shortest sequence, reach goal, transitions, visited states, water, pour, fill, empty] user-invocable: false +description: Concise guidance for State-Space Search. --- When a problem asks for the MINIMUM number of moves/steps to reach a goal state (bucket pouring, puzzle solving, sliding tiles), model it as BFS over a state space. State = a tuple of all values that fully describe the situation (e.g. (bucket_a, bucket_b)). From each state, enumerate every legal transition (fill A, fill B, empty A, empty B, pour A→B, pour B→A) and produce the next state. Use a visited set keyed on the state tuple to avoid cycles. BFS from the start state; the first time you pop a state matching the goal, its distance is the minimum move count. Track which bucket holds the goal and the other bucket's value at that point. Edge case: if start_bucket is forbidden as an immediate "fill the wrong one first" move, encode that as a filter on the initial transitions. diff --git a/skills/knowledge/binary_search.md b/skills/knowledge/binary_search.md index ad260c6f..0383626c 100644 --- a/skills/knowledge/binary_search.md +++ b/skills/knowledge/binary_search.md @@ -5,5 +5,6 @@ topic: Binary Search token_cost: 90 keywords: [binary, search, sorted, monotonic, bisect, minimum, maximum, feasible, predicate, lower, upper, bound, log, efficient, mid, pivot, rotated] user-invocable: false +description: Concise guidance for Binary Search. --- Binary search works on any monotonic predicate, not just sorted arrays. Pattern: "find minimum X such that condition(X) is true" — binary search on the answer space. Use bisect.bisect_left/bisect_right for sorted-array insertion points. For "minimize the maximum" or "maximize the minimum" problems, binary search on the answer and check feasibility. Always use lo + (hi - lo) // 2 to avoid overflow. When searching rotated arrays, check which half is sorted first. Time: O(log n) — whenever you see "sorted" or "monotonic" in a problem, consider binary search. diff --git a/skills/knowledge/code_review.md b/skills/knowledge/code_review.md index c0ab1589..20bd0cd6 100644 --- a/skills/knowledge/code_review.md +++ b/skills/knowledge/code_review.md @@ -6,6 +6,7 @@ token_cost: 150 keywords: [code review, review, reviews, reviewing, pr review, pull request, pull request review, merge request, diff, reviewer, feedback, request changes, approve, approval, blocker, nit, testability, maintainability] requires_tools: [read, code_search, lsp] user-invocable: false +description: Concise guidance for Code Review. --- Use this when reviewing code changes, pull requests, merge requests, diffs, or when establishing review practices. diff --git a/skills/knowledge/dfs_vs_bfs.md b/skills/knowledge/dfs_vs_bfs.md index 32f373d0..ca4d9a9c 100644 --- a/skills/knowledge/dfs_vs_bfs.md +++ b/skills/knowledge/dfs_vs_bfs.md @@ -5,5 +5,6 @@ topic: Graph Traversal token_cost: 100 keywords: [dfs, bfs, depth, breadth, graph, traverse, path, maze, shortest, connected, reachable, visited, queue, stack, neighbor, walk, flood, fill, island] user-invocable: false +description: Concise guidance for Graph Traversal. --- DFS (stack/recursion) explores one branch fully before backtracking — use for: cycle detection, topological sort, path existence, connected components, backtracking puzzles, flood fill. BFS (queue) explores level-by-level — use for: shortest unweighted path, level-order traversal, nearest neighbor, minimum steps. If the problem asks "shortest" or "minimum steps" on an unweighted graph, always choose BFS. If it asks "all paths," "can we reach," or "count islands," DFS is simpler. Both visit each node once: O(V+E) time. diff --git a/skills/knowledge/dynamic_programming.md b/skills/knowledge/dynamic_programming.md index 4bdd23e2..27eb9029 100644 --- a/skills/knowledge/dynamic_programming.md +++ b/skills/knowledge/dynamic_programming.md @@ -5,5 +5,6 @@ topic: Dynamic Programming token_cost: 110 keywords: [dynamic programming, dp, memoize, memoization, tabulation, subproblem, overlapping, optimal substructure, fibonacci, knapsack, longest, subsequence, minimum cost, maximum profit, number of ways, climb, stairs, coins, edit distance] user-invocable: false +description: Concise guidance for Dynamic Programming. --- Use dynamic programming when a problem has overlapping subproblems (same computation repeated) and optimal substructure (optimal solution built from optimal sub-solutions). Signs: "find minimum cost," "count the number of ways," "longest/shortest subsequence," "can you reach." Define state (what changes between subproblems) and recurrence (how states relate). Top-down with @cache is easiest to write; bottom-up tabulation avoids recursion limits and is often faster. Always check if you can reduce space by keeping only the previous row/state instead of the full table. diff --git a/skills/knowledge/frontend_design.md b/skills/knowledge/frontend_design.md index 0055749f..7f4c8bac 100644 --- a/skills/knowledge/frontend_design.md +++ b/skills/knowledge/frontend_design.md @@ -5,6 +5,7 @@ topic: Frontend Design token_cost: 150 keywords: [frontend, design, ui, ux, css, html, react, vue, component, layout, typography, color, animation, aesthetic, styling, interface, web, landing, page, dashboard, responsive, theme, font, spacing, visual, creative, distinctive, production] user-invocable: false +description: Concise guidance for Frontend Design. --- Create distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. diff --git a/skills/knowledge/hash_vs_tree.md b/skills/knowledge/hash_vs_tree.md index c01a8a2e..c91087be 100644 --- a/skills/knowledge/hash_vs_tree.md +++ b/skills/knowledge/hash_vs_tree.md @@ -5,5 +5,6 @@ topic: Data Structure Choice token_cost: 90 keywords: [lookup, dictionary, dict, set, hash, hashtable, map, frequency, count, unique, duplicate, ordered, sorted, tree, counter, defaultdict, collections] user-invocable: false +description: Concise guidance for Data Structure Choice. --- Use dict/set (hash table, O(1) avg lookup) for: membership testing, frequency counting, deduplication, grouping by key. Use collections.Counter for frequency counts, defaultdict(list) for grouping. When you need ordered keys or range queries, use sorted containers or bisect on a sorted list. For "find if X exists" or "count occurrences," always reach for a set or dict first — never scan a list repeatedly. If the problem involves pairs summing to a target, use a set to check complements in O(n) instead of O(n^2) nested loops. diff --git a/skills/knowledge/io_wrapper.md b/skills/knowledge/io_wrapper.md index 370e856c..85008592 100644 --- a/skills/knowledge/io_wrapper.md +++ b/skills/knowledge/io_wrapper.md @@ -5,5 +5,6 @@ topic: File-Like Wrapper + Counters token_cost: 120 keywords: [io wrapper, wrap file, read counter, write counter, nreads, nwrites, context manager, __enter__, __exit__, passthrough, delegate, paasio, MetaRead, MetaWrite] user-invocable: false +description: Concise guidance for File-Like Wrapper + Counters. --- To wrap a file-like and count reads/writes: store the wrapped object as self._wrapped. Implement read(size=-1) (or readable/readinto as needed) by delegating to self._wrapped.read(size) and incrementing counters by the length of the RETURNED bytes (not the requested size — a short read counts for what it returned). Same for write: call self._wrapped.write(data) and increment nwrites by the RETURN VALUE (number of bytes actually written), or by len(data) if the wrapped write returns None. Expose read_bytes/nreads and write_bytes/nwrites as properties or attributes. Context-manager support: __enter__ returns self; __exit__ calls self._wrapped.__exit__ (or close()) and forwards the exception info. Don't forget close() as a plain method for non-context-manager use. Edge case: thread safety — if the test uses threads, wrap counter updates in a threading.Lock. diff --git a/skills/knowledge/recursion_backtracking.md b/skills/knowledge/recursion_backtracking.md index 9e5f09e5..707cee7f 100644 --- a/skills/knowledge/recursion_backtracking.md +++ b/skills/knowledge/recursion_backtracking.md @@ -5,5 +5,6 @@ topic: Backtracking token_cost: 100 keywords: [permutation, combination, subset, backtrack, constraint, generate, valid, recursive, pruning, n-queens, sudoku, exhaustive, all, solutions, choose, pick, arrangement, password, sequence] user-invocable: false +description: Concise guidance for Backtracking. --- Use backtracking for constraint satisfaction and combinatorial generation: permutations, combinations, subsets, N-queens, sudoku, valid arrangements. Pattern: make a choice, recurse, undo the choice (backtrack). Prune early — skip branches that already violate constraints to avoid exploring dead ends. For subsets: at each element, choose to include or exclude it (2^n total). For permutations: choose each unused element at each position (n! total). Always pass state by reference and undo mutations rather than copying. If the problem says "generate all" or "find all valid," backtracking is usually the right approach. diff --git a/skills/knowledge/rule_string_transform.md b/skills/knowledge/rule_string_transform.md index d77a8298..7045925e 100644 --- a/skills/knowledge/rule_string_transform.md +++ b/skills/knowledge/rule_string_transform.md @@ -5,5 +5,6 @@ topic: Ordered-Rule String Transformation token_cost: 120 keywords: [pig latin, string rule, transform word, vowel, consonant, cluster, qu, ordered rules, first match, prefix, suffix, translate word] user-invocable: false +description: Concise guidance for Ordered-Rule String Transformation. --- For rule-based string transforms (pig latin, atbash, rot, etc.): encode the rules as an ordered list of (predicate, transform) pairs. For each word, walk the list; apply the FIRST matching rule and stop. Order matters — specific rules must come before general ones. Pig latin gotchas: (1) a "qu" or consonant-cluster-ending-in-qu counts as a unit — "quick" → "ickquay", "square" → "aresquay"; (2) "y" acts as a consonant at the start but a vowel in the middle — "yellow" → "ellowyay", "rhythm" → "ythmrhay"; (3) the rule order that works is: starts-with-vowel-or-xr-or-yt → append "ay"; starts-with-consonant(s)-then-"qu" → move cluster+qu, append "ay"; starts-with-consonants-up-to-first-"y"-or-vowel → move the consonants, append "ay"; fallback → append "ay". Always test each rule in isolation before combining. diff --git a/skills/knowledge/sorting_choice.md b/skills/knowledge/sorting_choice.md index ff6f46e1..9a156398 100644 --- a/skills/knowledge/sorting_choice.md +++ b/skills/knowledge/sorting_choice.md @@ -5,5 +5,6 @@ topic: Sorting token_cost: 90 keywords: [sort, order, rank, largest, smallest, kth, median, arrange, compare, stable, priority, heap, nlargest, nsmallest, key, reverse, sorted] user-invocable: false +description: Concise guidance for Sorting. --- Python's built-in sorted()/list.sort() is Timsort — O(n log n), stable, and almost always the right choice. Use key= for custom ordering. For top-k elements, use heapq.nlargest/nsmallest (O(n log k)) instead of full sort. For finding just the kth element, consider quickselect or statistics.median. Counting sort / radix sort help only when values are bounded integers. When the problem says "sort by X then by Y," use a tuple key: key=lambda x: (x.a, x.b). For reverse on one field only, negate it or use functools.cmp_to_key. diff --git a/skills/knowledge/tree_rerooting.md b/skills/knowledge/tree_rerooting.md index d1dc1b3b..73107ace 100644 --- a/skills/knowledge/tree_rerooting.md +++ b/skills/knowledge/tree_rerooting.md @@ -5,5 +5,6 @@ topic: Tree Re-Rooting (POV) token_cost: 120 keywords: [re-root, reroot, pov, point of view, tree rotation, change root, from_pov, reparent, path between nodes, undirected tree] user-invocable: false +description: Concise guidance for Tree Re-Rooting (POV). --- Re-rooting an undirected tree from a new node: build an undirected adjacency map (parent↔children become symmetric neighbor sets), then do DFS/BFS from the target node. Every node you visit gets its parent set to the node you came from, and its children become all neighbors minus that parent. The result is a new rooted tree with the target as root. Path-between(a, b): re-root at a, then walk from b up parent pointers until you hit a — that gives the reversed path; reverse it for a→b order. If the target node is not in the tree, return None (not an error — many test suites treat "node absent" as None). Cost: O(N) per re-root. Do NOT mutate the original tree when re-rooting — build a fresh node structure, so repeated from_pov calls on the original work correctly. diff --git a/skills/knowledge/tree_zipper.md b/skills/knowledge/tree_zipper.md index 43f044b3..7d0761ca 100644 --- a/skills/knowledge/tree_zipper.md +++ b/skills/knowledge/tree_zipper.md @@ -5,5 +5,6 @@ topic: Functional Tree Navigation token_cost: 130 keywords: [zipper, tree navigation, breadcrumb, focus, up, down, left, right, functional tree, immutable tree, cursor] user-invocable: false +description: Concise guidance for Functional Tree Navigation. --- A tree zipper is a cursor for immutable trees. State = (focus, trail). focus is the current subtree. trail is a list of "breadcrumbs" describing the path from root to focus — each crumb remembers the parent's value plus the siblings NOT taken. Operations: down_left/down_right push a crumb (remembering current node + the other child) and make the chosen child the new focus. up pops the top crumb, rebuilds the parent by combining it with the current focus, and makes that parent the new focus. set_value replaces the focused subtree's value. to_tree walks all the way up (repeated up) to rebuild the whole tree. Key invariant: you can always reconstruct the full original tree from (focus, trail) — no information is lost. Equality of two zippers = equality of the fully-reconstructed trees, NOT of the raw (focus, trail) pairs, because different trails can represent the same tree position. diff --git a/skills/knowledge/two_pointers.md b/skills/knowledge/two_pointers.md index a08b0641..c1f1329b 100644 --- a/skills/knowledge/two_pointers.md +++ b/skills/knowledge/two_pointers.md @@ -5,5 +5,6 @@ topic: Two Pointers and Sliding Window token_cost: 100 keywords: [pointer, two, sliding, window, substring, subarray, pair, sum, target, sorted, left, right, fast, slow, cycle, linked, list, contiguous, consecutive, squeeze] user-invocable: false +description: Concise guidance for Two Pointers and Sliding Window. --- Two pointers on a sorted array: start left=0, right=n-1, move inward based on comparison — solves pair-sum, three-sum, container problems in O(n). Sliding window for contiguous subarrays/substrings: expand right boundary, shrink left when constraint violated — solves "longest/shortest substring with property" in O(n). Fast/slow pointers: detect cycles in linked lists (Floyd's), find middle element. Key insight: if brute force is O(n^2) nested loops over a sorted or sequential structure, two pointers likely reduces it to O(n). diff --git a/skills/knowledge/workspace_docs.md b/skills/knowledge/workspace_docs.md index d84c3ed3..91d68c5b 100644 --- a/skills/knowledge/workspace_docs.md +++ b/skills/knowledge/workspace_docs.md @@ -6,5 +6,6 @@ token_cost: 140 keywords: [implement, build, create, fix, task, exercise, feature, todo, spec, specification, requirements, instructions, bug, test, failing, review, refactor] requires_tools: [Read, Glob] user-invocable: false +description: Concise guidance for Workspace Documentation. --- Before writing code for a non-trivial task, check if the workspace has a problem specification or convention document. These are cheap to read and often contain the exact format rules, edge cases, or constraints the tests assert — which the model would otherwise have to reverse-engineer from tests alone. Look for (in priority order): `.docs/instructions.md` and `.docs/instructions.append.md` (exercism-style problem specs), `AGENTS.md` / `CLAUDE.md` (agent-specific instructions at repo root), `README.md` in the current directory, `SPEC.md` / `SPECIFICATION.md`, and `docs/*.md`. Use Glob to discover them (`*.md`, `.docs/*.md`, `AGENTS.md`) and Read the relevant one. Do this ONCE at the start of a task, not every turn. If the spec disambiguates a failing test (e.g. "the first and last elements must match" or "spaces and punctuation are excluded"), that single read saves many debug iterations. Skip for pure read-only questions — only invest the Read call when you are about to change code. diff --git a/skills/protocols/cite_before_answer.md b/skills/protocols/cite_before_answer.md index e2f910b5..fb7c6bfc 100644 --- a/skills/protocols/cite_before_answer.md +++ b/skills/protocols/cite_before_answer.md @@ -6,6 +6,8 @@ when_to_use: always, before producing a final answer on a research task context: inline token_cost: 120 user_invocable: false +description: Checklist for citing saved evidence before final answers on research tasks. +keywords: [cite, citation, evidence, final answer, research, source] --- ## Cite-before-answer checklist diff --git a/skills/protocols/research_protocol.md b/skills/protocols/research_protocol.md index e2f09b5c..e3b2aa5c 100644 --- a/skills/protocols/research_protocol.md +++ b/skills/protocols/research_protocol.md @@ -6,6 +6,8 @@ when_to_use: when the task requires gathering facts from the web and citing them context: inline token_cost: 180 user_invocable: false +description: Workflow for evidence-first web research with citations. +keywords: [research, web, browser, evidence, citation, fact, source] --- ## Research Protocol (evidence-first) diff --git a/skills/protocols/task_decomposition.md b/skills/protocols/task_decomposition.md index f93f7169..0cf979bf 100644 --- a/skills/protocols/task_decomposition.md +++ b/skills/protocols/task_decomposition.md @@ -6,6 +6,8 @@ when_to_use: when the task has multiple unknowns or clearly requires multi-step context: inline token_cost: 140 user_invocable: false +description: Workflow for decomposing multi-step tasks into knowns, unknowns, and tool steps. +keywords: [decompose, plan, steps, unknown, task, multi-step, workflow] --- ## Task Decomposition diff --git a/skills/tools/bash.md b/skills/tools/bash.md index 44e9e07d..16021063 100644 --- a/skills/tools/bash.md +++ b/skills/tools/bash.md @@ -5,6 +5,8 @@ target_tool: bash priority: 10 token_cost: 120 user-invocable: false +description: Guidance for running shell commands safely with bounded timeouts and cwd handling. +keywords: [shell, command, bash, run, execute, test, build, install, cwd, timeout] --- ## Bash Tool Execute a shell command and return stdout+stderr. diff --git a/skills/tools/browser_click.md b/skills/tools/browser_click.md index f9ea0533..0ee110c3 100644 --- a/skills/tools/browser_click.md +++ b/skills/tools/browser_click.md @@ -5,6 +5,8 @@ target_tool: BrowserClick priority: 7 token_cost: 90 user-invocable: false +description: Guidance for clicking elements in the interactive browser by role or selector. +keywords: [browser, click, button, link, selector, aria, interactive, navigate] --- ## BrowserClick Tool Click an element by CSS selector OR by ARIA role+name. diff --git a/skills/tools/browser_extract.md b/skills/tools/browser_extract.md index 0db50b95..8eb2eb98 100644 --- a/skills/tools/browser_extract.md +++ b/skills/tools/browser_extract.md @@ -5,6 +5,8 @@ target_tool: BrowserExtract priority: 9 token_cost: 110 user-invocable: false +description: Guidance for extracting readable text from the current interactive browser page. +keywords: [browser, extract, page, read, markdown, cursor, citation, interactive] --- ## BrowserExtract Tool Return readable markdown of the current page, chunked at ~2KB. diff --git a/skills/tools/browser_navigate.md b/skills/tools/browser_navigate.md index 7b63c0ce..db1bc39c 100644 --- a/skills/tools/browser_navigate.md +++ b/skills/tools/browser_navigate.md @@ -5,6 +5,8 @@ target_tool: BrowserNavigate priority: 8 token_cost: 80 user-invocable: false +description: Guidance for navigating the interactive browser to complete HTTP or HTTPS URLs. +keywords: [browser, navigate, url, website, interactive, page, http, https] --- ## BrowserNavigate Tool Load a URL in the shared browser page. diff --git a/skills/tools/browser_type.md b/skills/tools/browser_type.md index 43ba26bd..a1c5b3aa 100644 --- a/skills/tools/browser_type.md +++ b/skills/tools/browser_type.md @@ -5,6 +5,8 @@ target_tool: BrowserType priority: 6 token_cost: 80 user-invocable: false +description: Guidance for typing text into interactive browser form inputs. +keywords: [browser, type, form, input, search, submit, selector, interactive] --- ## BrowserType Tool Fill text into an input element. diff --git a/skills/tools/codegraph_memory_search_graph.md b/skills/tools/codegraph_memory_search_graph.md index 949d8e7f..694cb18c 100644 --- a/skills/tools/codegraph_memory_search_graph.md +++ b/skills/tools/codegraph_memory_search_graph.md @@ -5,6 +5,8 @@ target_tool: code_search priority: 10 token_cost: 150 user-invocable: false +description: Guidance for structural codebase search over symbols, relationships, and routes. +keywords: [code, search, codebase, symbol, definition, references, function, class, route, semantic, graph] --- ## code_search Tool Search the code knowledge graph for functions, classes, routes, and variables. This is a **structural code search** — it understands code relationships, not just text. diff --git a/skills/tools/edit.md b/skills/tools/edit.md index 22f93f3f..38c37937 100644 --- a/skills/tools/edit.md +++ b/skills/tools/edit.md @@ -5,6 +5,8 @@ target_tool: edit priority: 10 token_cost: 150 user-invocable: false +description: Guidance for exact in-place file edits using targeted replacements. +keywords: [edit, change, modify, replace, patch, fix, refactor, update, file] --- ## Edit Tool Replace exact text in a file. This is the **default tool for changing any existing file** — prefer it over Write for anything except creating a new file from scratch. diff --git a/skills/tools/evidence_add.md b/skills/tools/evidence_add.md index b71bc569..451f6eeb 100644 --- a/skills/tools/evidence_add.md +++ b/skills/tools/evidence_add.md @@ -5,6 +5,8 @@ target_tool: EvidenceAdd priority: 10 token_cost: 100 user-invocable: false +description: Guidance for saving citable evidence snippets before making factual claims. +keywords: [evidence, cite, citation, source, fact, research, claim, snippet] --- ## EvidenceAdd Tool Save a short citable snippet. Every fact you will put in your final answer must come from an evidence entry. diff --git a/skills/tools/find_read.md b/skills/tools/find_read.md index 30e4b1c3..1f973e69 100644 --- a/skills/tools/find_read.md +++ b/skills/tools/find_read.md @@ -5,6 +5,8 @@ target_tool: findRead priority: 10 token_cost: 120 user-invocable: false +description: Guidance for finding files by glob and reading matched contents in one bounded call. +keywords: [findread, find, read, glob, files, contents, pattern, search] --- ## findRead Tool Find files matching a glob pattern and read their contents in one call. Combines Glob + Read so you don't need two separate tool calls. @@ -14,7 +16,9 @@ OPTIONAL: path (base directory, defaults to cwd), maxFiles (default 5, max 50), RULES: - Use ** for recursive matching across directories +- Output starts with the effective invocation: `pattern`, `path`, `maxFiles`, `maxCharacters`, and `ignoreDefaultExcludes` - Returns each file's absolute path followed by its content, separated by headers +- No-match and error responses still include the effective invocation prefix - **Always use conservative limits** — this tool can easily overload the context window - Default maxFiles is 5 and default maxCharacters is 4000; increase only when needed - Never use maxFiles > 10 or maxCharacters > 10000 without a specific reason diff --git a/skills/tools/glob.md b/skills/tools/glob.md index ce68bc9a..1f2db25a 100644 --- a/skills/tools/glob.md +++ b/skills/tools/glob.md @@ -5,6 +5,8 @@ target_tool: glob priority: 8 token_cost: 80 user-invocable: false +description: Guidance for finding file paths with glob patterns. +keywords: [glob, find, files, path, pattern, recursive, list] --- ## Glob Tool Find files matching a glob pattern. diff --git a/skills/tools/grep.md b/skills/tools/grep.md index 4dbba8fd..0575202d 100644 --- a/skills/tools/grep.md +++ b/skills/tools/grep.md @@ -5,6 +5,8 @@ target_tool: grep priority: 8 token_cost: 100 user-invocable: false +description: Guidance for searching file contents with ripgrep-compatible patterns. +keywords: [grep, search, regex, pattern, contents, matches, files] --- ## Grep Tool Search file contents with regex. Uses ripgrep. diff --git a/skills/tools/read.md b/skills/tools/read.md index 8a9204be..732ca3f8 100644 --- a/skills/tools/read.md +++ b/skills/tools/read.md @@ -5,6 +5,8 @@ target_tool: read priority: 10 token_cost: 100 user-invocable: false +description: Guidance for reading files by absolute path with optional line ranges. +keywords: [read, file, view, show, lines, absolute, path] --- ## Read Tool Read a file's contents with line numbers. diff --git a/skills/tools/skills.md b/skills/tools/skills.md index cabbc0f3..e0620eeb 100644 --- a/skills/tools/skills.md +++ b/skills/tools/skills.md @@ -5,9 +5,11 @@ target_tool: skills priority: 5 token_cost: 80 user-invocable: true +description: Guidance for listing and loading installed skills by name, type, origin, and description. +keywords: [skills, list, load, description, keywords, skill, guidance] --- ## skills Tool / Command -List all available skills (tool skills, knowledge entries, protocols). +List all available skills (tool skills, knowledge entries, protocols, repo skills, and user-level skills). Usage: `skills` or `/skills` @@ -16,9 +18,12 @@ Shows three categories: - **Knowledge** — algorithm cheat sheets scored against the user's prompt and injected when keywords match (threshold 2.0). - **Protocols** — research/cite/decomposition workflows injected for research-heavy tasks. -Skills live under the `skills/` directory at the repo root: -- `skills/tools/*.md` — tool skill cards (14 files) -- `skills/knowledge/*.md` — algorithm cheat sheets (13 files) -- `skills/protocols/*.md` — research workflows (3 files) +The listing includes each skill's token cost, origin (`repo` or `user`), keywords, and frontmatter description/fallback first line. + +Skills load from: +- repo `skills/` — packaged canonical skills +- user `~/.pi/skills/` — local reflection-generated or installed skills; exact explicit loads prefer user skills when names collide + +Use `/skill ` or `/skill:` to load one explicitly. Use `/promote-user-skill [skill]` to copy stable user-level skills into repo `skills/user//` after duplicate checks. To find and install new skills from the open agent skills ecosystem, use `npx skills find `. diff --git a/skills/tools/webfetch.md b/skills/tools/webfetch.md index d10ef6ef..cea9a527 100644 --- a/skills/tools/webfetch.md +++ b/skills/tools/webfetch.md @@ -5,6 +5,8 @@ target_tool: webfetch priority: 6 token_cost: 80 user-invocable: false +description: Guidance for fetching non-interactive web pages by URL. +keywords: [webfetch, fetch, url, web, http, documentation, page, non-interactive] --- ## WebFetch Tool Fetch and extract content from a URL. diff --git a/skills/tools/write.md b/skills/tools/write.md index 0de3b757..348cdb7d 100644 --- a/skills/tools/write.md +++ b/skills/tools/write.md @@ -5,6 +5,8 @@ target_tool: write priority: 10 token_cost: 110 user-invocable: false +description: Guidance for creating new files only, not modifying existing files. +keywords: [write, create, new, file, content] --- ## Write Tool Create a **new** file with the given content. Creates parent directories automatically.