peon-mem 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +301 -0
  3. package/bin/peon-mem.mjs +273 -0
  4. package/dist/brain.d.ts +72 -0
  5. package/dist/brain.js +224 -0
  6. package/dist/compression.d.ts +9 -0
  7. package/dist/compression.js +37 -0
  8. package/dist/config.d.ts +22 -0
  9. package/dist/config.js +99 -0
  10. package/dist/daemon-cli.d.ts +2 -0
  11. package/dist/daemon-cli.js +54 -0
  12. package/dist/daemon.d.ts +23 -0
  13. package/dist/daemon.js +1078 -0
  14. package/dist/embedding-store.d.ts +43 -0
  15. package/dist/embedding-store.js +169 -0
  16. package/dist/embeddings.d.ts +93 -0
  17. package/dist/embeddings.js +345 -0
  18. package/dist/entities.d.ts +61 -0
  19. package/dist/entities.js +191 -0
  20. package/dist/entity-extraction.d.ts +33 -0
  21. package/dist/entity-extraction.js +75 -0
  22. package/dist/eval-metrics.d.ts +27 -0
  23. package/dist/eval-metrics.js +50 -0
  24. package/dist/evaluation.d.ts +58 -0
  25. package/dist/evaluation.js +244 -0
  26. package/dist/global-extraction.d.ts +15 -0
  27. package/dist/global-extraction.js +61 -0
  28. package/dist/global-memory.d.ts +43 -0
  29. package/dist/global-memory.js +306 -0
  30. package/dist/global-promotion.d.ts +25 -0
  31. package/dist/global-promotion.js +29 -0
  32. package/dist/hyde.d.ts +31 -0
  33. package/dist/hyde.js +46 -0
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +246 -0
  36. package/dist/injection.d.ts +38 -0
  37. package/dist/injection.js +133 -0
  38. package/dist/logger.d.ts +17 -0
  39. package/dist/logger.js +63 -0
  40. package/dist/memory-mutations.d.ts +24 -0
  41. package/dist/memory-mutations.js +57 -0
  42. package/dist/memory-store.d.ts +194 -0
  43. package/dist/memory-store.js +1205 -0
  44. package/dist/monitor.d.ts +13 -0
  45. package/dist/monitor.js +977 -0
  46. package/dist/overview.d.ts +73 -0
  47. package/dist/overview.js +104 -0
  48. package/dist/processor.d.ts +90 -0
  49. package/dist/processor.js +450 -0
  50. package/dist/quality.d.ts +86 -0
  51. package/dist/quality.js +338 -0
  52. package/dist/recuration.d.ts +13 -0
  53. package/dist/recuration.js +65 -0
  54. package/dist/reranker.d.ts +34 -0
  55. package/dist/reranker.js +89 -0
  56. package/dist/retrieval.d.ts +106 -0
  57. package/dist/retrieval.js +392 -0
  58. package/dist/session-index.d.ts +34 -0
  59. package/dist/session-index.js +87 -0
  60. package/dist/temporal.d.ts +20 -0
  61. package/dist/temporal.js +62 -0
  62. package/dist/token-ab-monitor.d.ts +1 -0
  63. package/dist/token-ab-monitor.js +7 -0
  64. package/dist/tools.d.ts +232 -0
  65. package/dist/tools.js +546 -0
  66. package/dist/types.d.ts +169 -0
  67. package/dist/types.js +1 -0
  68. package/docs/assets/neural-universe.png +0 -0
  69. package/package.json +57 -0
  70. package/scripts/claude-peon-hook.mjs +522 -0
  71. package/scripts/codex-peon-hook.mjs +4 -0
  72. package/scripts/eval-retrieval-labeled.mjs +135 -0
  73. package/scripts/eval-retrieval.mjs +96 -0
  74. package/scripts/evaluate-peon.mjs +47 -0
  75. package/scripts/install-peon-stl.mjs +82 -0
  76. package/scripts/install-peon.mjs +318 -0
  77. package/scripts/lib/eval-ledger.mjs +104 -0
  78. package/scripts/lib/stl-classify.mjs +44 -0
  79. package/scripts/longmemeval-eval.mjs +144 -0
  80. package/scripts/peon-report.mjs +155 -0
  81. package/scripts/peon-stl.mjs +506 -0
  82. package/scripts/token-ab-monitor.html +235 -0
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // Retrieval evaluation harness for Peon.
3
+ //
4
+ // Builds a labeled query→gold-belief set (LLM-generated, realistic), then scores
5
+ // the LIVE retrieval ranker with standard IR metrics (Recall@K, MRR, nDCG@10).
6
+ // The eval set is SAVED so the identical queries re-score before/after a ranker
7
+ // change — a fair A/B.
8
+ //
9
+ // node scripts/eval-retrieval.mjs build "<projectPath>" [N] # generate + save the set
10
+ // node scripts/eval-retrieval.mjs run "<projectPath>" # score current ranker on saved set
11
+ // node scripts/eval-retrieval.mjs "<projectPath>" [N] # build-if-missing then run
12
+ //
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { PeonMemoryStore } from "../dist/memory-store.js";
16
+ import { loadPeonConfig } from "../dist/config.js";
17
+
18
+ const [, , maybeMode, ...rest] = process.argv;
19
+ const MODES = new Set(["build", "run"]);
20
+ const mode = MODES.has(maybeMode) ? maybeMode : "auto";
21
+ const projectPath = (MODES.has(maybeMode) ? rest[0] : maybeMode) ?? process.cwd();
22
+ const N = Number.parseInt((MODES.has(maybeMode) ? rest[1] : rest[0]) ?? "40", 10);
23
+
24
+ const setPath = join(projectPath, ".peon", "evaluation", "retrieval-eval.json");
25
+ const config = loadPeonConfig();
26
+
27
+ async function genQuery(belief) {
28
+ const sys =
29
+ "Write ONE natural, specific question a user would ask whose answer is exactly the given memory belief. " +
30
+ "Include a distinctive detail from it so the question is unambiguous, but phrase it as a real user would " +
31
+ "(do NOT quote the belief verbatim). Output ONLY the question.";
32
+ const r = await fetch("https://openrouter.ai/api/v1/chat/completions", {
33
+ method: "POST",
34
+ headers: { Authorization: `Bearer ${config.openRouterApiKey}`, "Content-Type": "application/json" },
35
+ body: JSON.stringify({ model: config.processingModel, temperature: 0.3, messages: [
36
+ { role: "system", content: sys },
37
+ { role: "user", content: belief.content }
38
+ ] })
39
+ });
40
+ const j = await r.json();
41
+ return (j.choices?.[0]?.message?.content ?? "").trim().replace(/^["']|["']$/g, "");
42
+ }
43
+
44
+ async function build() {
45
+ const store = await PeonMemoryStore.open({ projectPath });
46
+ const all = (await store.listMemoryRecords()).filter((r) => r.status === "active");
47
+ // Sample spread across types, prefer substantial content.
48
+ const pool = all.filter((r) => r.content.length > 30);
49
+ const step = Math.max(1, Math.floor(pool.length / N));
50
+ const sample = pool.filter((_, i) => i % step === 0).slice(0, N);
51
+ const items = [];
52
+ for (const belief of sample) {
53
+ const query = await genQuery(belief).catch(() => "");
54
+ if (query) items.push({ query, goldId: belief.id, goldContent: belief.content.slice(0, 100) });
55
+ }
56
+ mkdirSync(join(projectPath, ".peon", "evaluation"), { recursive: true });
57
+ writeFileSync(setPath, JSON.stringify({ projectPath, builtFrom: all.length, items }, null, 2));
58
+ console.log(`built ${items.length} labeled queries → ${setPath}`);
59
+ return items;
60
+ }
61
+
62
+ async function run(items) {
63
+ const store = await PeonMemoryStore.open({ projectPath });
64
+ const K = [1, 3, 5, 10];
65
+ const recallAt = Object.fromEntries(K.map((k) => [k, 0]));
66
+ let mrr = 0, ndcg = 0, found = 0;
67
+ for (const it of items) {
68
+ const ranked = await store.rankRecords(it.query, { limit: 20 });
69
+ const rank = ranked.findIndex((r) => r.record.id === it.goldId) + 1; // 1-based, 0 = not found
70
+ if (rank > 0) {
71
+ found++;
72
+ mrr += 1 / rank;
73
+ if (rank <= 10) ndcg += 1 / Math.log2(rank + 1);
74
+ for (const k of K) if (rank <= k) recallAt[k] += 1;
75
+ }
76
+ }
77
+ const n = items.length;
78
+ const pct = (x) => (100 * x / n).toFixed(1) + "%";
79
+ console.log(`\n Retrieval eval — ${n} queries (gold belief per query), current ranker:`);
80
+ for (const k of K) console.log(` Recall@${k}: ${pct(recallAt[k])}`);
81
+ console.log(` MRR (top-20): ${(mrr / n).toFixed(3)}`);
82
+ console.log(` nDCG@10: ${(ndcg / n).toFixed(3)}`);
83
+ console.log(` found in top-20: ${pct(found)}`);
84
+ return { recallAt, mrr: mrr / n, ndcg: ndcg / n, found: found / n, n };
85
+ }
86
+
87
+ let items;
88
+ if (mode === "run") {
89
+ if (!existsSync(setPath)) { console.error("no eval set; run 'build' first"); process.exit(1); }
90
+ items = JSON.parse(readFileSync(setPath, "utf8")).items;
91
+ } else if (mode === "build") {
92
+ items = await build();
93
+ } else {
94
+ items = existsSync(setPath) ? JSON.parse(readFileSync(setPath, "utf8")).items : await build();
95
+ }
96
+ if (mode !== "build") await run(items);
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import { access, readFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
7
+ const packageDir = resolve(scriptDir, "..");
8
+ const projectPath = resolve(process.argv[2] || process.cwd());
9
+ const expectedPath = readOption("--expected");
10
+ const memoryDirName = readOption("--memory-dir") || ".peon";
11
+ const evaluationModulePath = resolve(packageDir, "dist", "evaluation.js");
12
+
13
+ if (!(await exists(evaluationModulePath))) {
14
+ console.error(
15
+ JSON.stringify(
16
+ {
17
+ error: "Peon evaluation module is not built.",
18
+ expectedModule: evaluationModulePath,
19
+ buildCommand: "npm --workspace @peon/mcp run build"
20
+ },
21
+ null,
22
+ 2
23
+ )
24
+ );
25
+ process.exitCode = 1;
26
+ } else {
27
+ const { evaluatePeonProject } = await import(pathToFileURL(evaluationModulePath).href);
28
+ const expectedMemories = expectedPath ? JSON.parse(await readFile(resolve(expectedPath), "utf8")) : undefined;
29
+ const report = await evaluatePeonProject({
30
+ projectPath,
31
+ memoryDirName,
32
+ expectedMemories
33
+ });
34
+ console.log(JSON.stringify(report, null, 2));
35
+ }
36
+
37
+ function readOption(name) {
38
+ const index = process.argv.indexOf(name);
39
+ return index >= 0 ? process.argv[index + 1] : undefined;
40
+ }
41
+
42
+ async function exists(path) {
43
+ return access(path).then(
44
+ () => true,
45
+ () => false
46
+ );
47
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Install (or re-sync) the Peon STL daily cycle as a launchd job.
4
+ *
5
+ * It copies peon-stl.mjs into Peon's support dir (so the job is independent of any
6
+ * git checkout / worktree), writes ~/Library/LaunchAgents/com.peon.stl.daily.plist
7
+ * (generated from $HOME — the OpenRouter key is NEVER written into the plist), and
8
+ * bootstraps the job into the gui/$UID domain to run daily at 09:00.
9
+ *
10
+ * node scripts/install-peon-stl.mjs # install / re-sync
11
+ * node scripts/install-peon-stl.mjs --run-now # also run one cycle immediately
12
+ * node scripts/install-peon-stl.mjs --uninstall # remove the job
13
+ */
14
+ import { copyFileSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { homedir, userInfo } from "node:os";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ const HOME = homedir();
21
+ const UID = userInfo().uid;
22
+ const LABEL = "com.peon.stl.daily";
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+
25
+ const SUPPORT_SCRIPTS = join(HOME, "Library", "Application Support", "Peon", "scripts");
26
+ const INSTALLED_SCRIPT = join(SUPPORT_SCRIPTS, "peon-stl.mjs");
27
+ const STL_LOG_DIR = join(HOME, "Library", "Logs", "Peon", "stl");
28
+ const PLIST_PATH = join(HOME, "Library", "LaunchAgents", `${LABEL}.plist`);
29
+ const WORKING_DIR = join(HOME, "Documents", "Project_x 2"); // dir that holds Peon's .env
30
+
31
+ const sh = (cmd, args) => { try { return execFileSync(cmd, args, { stdio: "pipe" }).toString(); } catch (e) { return e.stdout?.toString() || e.message; } };
32
+
33
+ function bootout() { sh("launchctl", ["bootout", `gui/${UID}/${LABEL}`]); }
34
+
35
+ if (process.argv.includes("--uninstall")) {
36
+ bootout();
37
+ console.log(`[install-peon-stl] booted out ${LABEL}. Plist left at ${PLIST_PATH} (delete it to fully remove).`);
38
+ process.exit(0);
39
+ }
40
+
41
+ // 1. copy the script to the branch-independent support dir
42
+ mkdirSync(SUPPORT_SCRIPTS, { recursive: true });
43
+ mkdirSync(STL_LOG_DIR, { recursive: true });
44
+ copyFileSync(join(HERE, "peon-stl.mjs"), INSTALLED_SCRIPT);
45
+
46
+ // 2. generate the plist
47
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
48
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
49
+ <plist version="1.0">
50
+ <dict>
51
+ <key>Label</key><string>${LABEL}</string>
52
+ <key>ProgramArguments</key>
53
+ <array>
54
+ <string>/opt/homebrew/bin/node</string>
55
+ <string>${INSTALLED_SCRIPT}</string>
56
+ </array>
57
+ <key>WorkingDirectory</key><string>${WORKING_DIR}</string>
58
+ <key>StartCalendarInterval</key>
59
+ <dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
60
+ <key>StandardOutPath</key><string>${join(STL_LOG_DIR, "cron.out.log")}</string>
61
+ <key>StandardErrorPath</key><string>${join(STL_LOG_DIR, "cron.err.log")}</string>
62
+ <key>ProcessType</key><string>Background</string>
63
+ <key>LowPriorityIO</key><true/>
64
+ </dict>
65
+ </plist>
66
+ `;
67
+ writeFileSync(PLIST_PATH, plist);
68
+
69
+ // 3. (re)bootstrap into the gui/$UID domain
70
+ bootout();
71
+ console.log(sh("launchctl", ["bootstrap", `gui/${UID}`, PLIST_PATH]).trim() || "[install-peon-stl] bootstrapped");
72
+ sh("launchctl", ["enable", `gui/${UID}/${LABEL}`]);
73
+
74
+ console.log(`[install-peon-stl] installed ${LABEL}`);
75
+ console.log(` script : ${INSTALLED_SCRIPT}`);
76
+ console.log(` plist : ${PLIST_PATH}`);
77
+ console.log(` runs : daily 09:00 · reports → ${STL_LOG_DIR}/`);
78
+
79
+ if (process.argv.includes("--run-now")) {
80
+ console.log("[install-peon-stl] kicking off one cycle now…");
81
+ console.log(sh("launchctl", ["kickstart", "-k", `gui/${UID}/${LABEL}`]).trim() || "kickstarted");
82
+ }
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ import { access } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
7
+ const defaultPackageDir = resolve(scriptDir, "..");
8
+ const defaultRepoDir = resolve(defaultPackageDir, "..");
9
+ const defaultDaemonUrl = "http://127.0.0.1:3737";
10
+ const nonDestructiveNote =
11
+ "This helper does not write config files, install hooks, start services, or modify the project.";
12
+
13
+ if (isMain(import.meta.url, process.argv[1])) {
14
+ await main(process.argv.slice(2), {
15
+ cwd: process.cwd(),
16
+ env: process.env,
17
+ packageDir: defaultPackageDir,
18
+ repoDir: defaultRepoDir
19
+ });
20
+ }
21
+
22
+ export async function main(argv, options = {}) {
23
+ let parsed;
24
+ try {
25
+ parsed = parseInstallArgs(argv, options.cwd || process.cwd());
26
+ } catch (error) {
27
+ console.error(error instanceof Error ? error.message : String(error));
28
+ console.error("Usage: node scripts/install-peon.mjs [--json] [--check|--write-plan] [project-path]");
29
+ process.exitCode = 2;
30
+ return;
31
+ }
32
+
33
+ if (parsed.help) {
34
+ console.log(formatHelp());
35
+ return;
36
+ }
37
+
38
+ const daemonUrl = normalizeUrl(options.env?.PEON_DAEMON_URL || defaultDaemonUrl);
39
+ const packageDir = resolve(options.packageDir || defaultPackageDir);
40
+ const repoDir = resolve(options.repoDir || defaultRepoDir);
41
+ const checks = await collectChecks(parsed.projectPath, packageDir);
42
+ const plan = buildInstallPlan({
43
+ projectPath: parsed.projectPath,
44
+ packageDir,
45
+ repoDir,
46
+ daemonUrl,
47
+ checks
48
+ });
49
+ plan.mode = parsed.action;
50
+
51
+ if (parsed.output === "json") {
52
+ console.log(JSON.stringify(plan, null, 2));
53
+ } else if (parsed.action === "write-plan") {
54
+ console.log(formatWritePlan(plan));
55
+ } else {
56
+ console.log(formatTextReport(plan));
57
+ }
58
+
59
+ if (parsed.action === "check" && !plan.checks.ready) {
60
+ process.exitCode = 1;
61
+ }
62
+ }
63
+
64
+ export function parseInstallArgs(argv, cwd = process.cwd()) {
65
+ const result = {
66
+ action: "dry-run",
67
+ output: "text",
68
+ projectPath: undefined,
69
+ help: false
70
+ };
71
+
72
+ for (const arg of argv) {
73
+ if (arg === "--json") {
74
+ result.output = "json";
75
+ } else if (arg === "--check") {
76
+ result.action = setAction(result.action, "check");
77
+ } else if (arg === "--write-plan") {
78
+ result.action = setAction(result.action, "write-plan");
79
+ } else if (arg === "--help" || arg === "-h") {
80
+ result.help = true;
81
+ } else if (arg.startsWith("-")) {
82
+ throw new Error(`Unknown option: ${arg}`);
83
+ } else if (!result.projectPath) {
84
+ result.projectPath = resolve(cwd, arg);
85
+ } else {
86
+ throw new Error(`Unexpected extra project path: ${arg}`);
87
+ }
88
+ }
89
+
90
+ result.projectPath = resolve(result.projectPath || cwd);
91
+ return result;
92
+ }
93
+
94
+ export function buildInstallPlan({ projectPath, packageDir, repoDir, daemonUrl, checks }) {
95
+ const memoryDir = join(projectPath, ".peon");
96
+ const claudeHook = join(packageDir, "scripts", "claude-peon-hook.mjs");
97
+ const daemonBin = join(packageDir, "dist", "daemon-cli.js");
98
+ const mcpBin = join(packageDir, "dist", "index.js");
99
+ const launchctlLabel = "com.peon.daemon";
100
+ const launchAgentPlist = join("~", "Library", "LaunchAgents", `${launchctlLabel}.plist`);
101
+ const runDaemon = `PEON_DAEMON_URL=${daemonUrl} node ${shellQuote(daemonBin)}`;
102
+ const runMcpServer = `PEON_DAEMON_URL=${daemonUrl} node ${shellQuote(mcpBin)}`;
103
+ const hookCommand = `PEON_DAEMON_URL=${daemonUrl} node ${shellQuote(claudeHook)}`;
104
+ const ready = Boolean(checks.packageDir && checks.builtDaemon && checks.builtMcpServer && checks.claudeHook);
105
+
106
+ return {
107
+ mode: "dry-run",
108
+ nonDestructive: true,
109
+ projectPath,
110
+ packageDir,
111
+ repoDir,
112
+ daemonUrl,
113
+ paths: {
114
+ memoryDir,
115
+ daemonBin,
116
+ mcpBin,
117
+ claudeHook
118
+ },
119
+ checks: {
120
+ ...checks,
121
+ ready
122
+ },
123
+ launchctl: {
124
+ label: launchctlLabel,
125
+ plistPath: launchAgentPlist,
126
+ recommendation:
127
+ "Use this label for a user LaunchAgent if you choose to install the daemon with launchctl."
128
+ },
129
+ claude: {
130
+ mcpConfigSnippet: {
131
+ mcpServers: {
132
+ peon: {
133
+ command: "node",
134
+ args: [mcpBin],
135
+ env: {
136
+ PEON_DAEMON_URL: daemonUrl
137
+ }
138
+ }
139
+ }
140
+ },
141
+ hookCommandSnippet: hookCommand
142
+ },
143
+ commands: {
144
+ build: "npm --workspace @peon/mcp run build",
145
+ runDaemon,
146
+ runMcpServer,
147
+ claudeHook: hookCommand
148
+ },
149
+ nextCommands: buildNextCommands({ ready, daemonUrl, daemonBin, mcpBin, claudeHook }),
150
+ notes: [
151
+ nonDestructiveNote,
152
+ ready ? "Build artifacts are present." : "Build artifacts are missing; run the build command first.",
153
+ checks.projectMemoryDir
154
+ ? "Project memory directory already exists."
155
+ : "Project memory directory is not present yet; Peon will create it when the daemon writes memory."
156
+ ]
157
+ };
158
+ }
159
+
160
+ export async function collectChecks(projectPath, packageDir) {
161
+ const daemonBin = join(packageDir, "dist", "daemon-cli.js");
162
+ const mcpBin = join(packageDir, "dist", "index.js");
163
+ const claudeHook = join(packageDir, "scripts", "claude-peon-hook.mjs");
164
+ const memoryDir = join(projectPath, ".peon");
165
+
166
+ return {
167
+ packageDir: await exists(packageDir),
168
+ builtDaemon: await exists(daemonBin),
169
+ builtMcpServer: await exists(mcpBin),
170
+ claudeHook: await exists(claudeHook),
171
+ projectMemoryDir: await exists(memoryDir)
172
+ };
173
+ }
174
+
175
+ export function formatTextReport(plan) {
176
+ const status = plan.checks.ready ? "ready" : "needs build";
177
+ return [
178
+ `Peon installer ${plan.mode} (${status})`,
179
+ "",
180
+ "Paths:",
181
+ ` Project: ${plan.projectPath}`,
182
+ ` Daemon: ${plan.paths.daemonBin}`,
183
+ ` MCP: ${plan.paths.mcpBin}`,
184
+ ` Hook: ${plan.paths.claudeHook}`,
185
+ ` Memory: ${plan.paths.memoryDir}`,
186
+ "",
187
+ "Checks:",
188
+ ...formatChecks(plan.checks),
189
+ "",
190
+ "launchctl recommendation:",
191
+ ` Label: ${plan.launchctl.label}`,
192
+ ` Plist: ${plan.launchctl.plistPath}`,
193
+ "",
194
+ "Claude MCP config snippet:",
195
+ indent(JSON.stringify(plan.claude.mcpConfigSnippet, null, 2), 2),
196
+ "",
197
+ "Claude hook command snippet:",
198
+ ` ${plan.claude.hookCommandSnippet}`,
199
+ "",
200
+ "Next commands:",
201
+ ...plan.nextCommands.map((command) => ` ${command}`),
202
+ "",
203
+ "Notes:",
204
+ ...plan.notes.map((note) => ` - ${note}`)
205
+ ].join("\n");
206
+ }
207
+
208
+ export function formatWritePlan(plan) {
209
+ return [
210
+ "# Peon Local Installation Plan",
211
+ "",
212
+ "> Planning output only. This command does not modify files, install services, or start Peon.",
213
+ "",
214
+ "## 1. Build Peon MCP",
215
+ "",
216
+ "```sh",
217
+ plan.commands.build,
218
+ "```",
219
+ "",
220
+ "## 2. Run the daemon manually",
221
+ "",
222
+ "```sh",
223
+ plan.commands.runDaemon,
224
+ "```",
225
+ "",
226
+ "If you later choose to use launchctl, use:",
227
+ "",
228
+ `- Label: \`${plan.launchctl.label}\``,
229
+ `- Plist path: \`${plan.launchctl.plistPath}\``,
230
+ "",
231
+ "## 3. Add Claude MCP server config",
232
+ "",
233
+ "```json",
234
+ JSON.stringify(plan.claude.mcpConfigSnippet, null, 2),
235
+ "```",
236
+ "",
237
+ "## 4. Add Claude hook command",
238
+ "",
239
+ "```sh",
240
+ plan.claude.hookCommandSnippet,
241
+ "```",
242
+ "",
243
+ "## 5. Smoke test commands",
244
+ "",
245
+ "```sh",
246
+ ...plan.nextCommands,
247
+ "```"
248
+ ].join("\n");
249
+ }
250
+
251
+ function formatHelp() {
252
+ return [
253
+ "Usage: node scripts/install-peon.mjs [--json] [--check|--write-plan] [project-path]",
254
+ "",
255
+ "Modes:",
256
+ " default Print a non-destructive dry-run report.",
257
+ " --json Print the report as JSON.",
258
+ " --check Verify local build artifacts and exit 1 if required artifacts are missing.",
259
+ " --write-plan Print a Markdown installation plan to stdout.",
260
+ "",
261
+ "This helper never writes config files, installs hooks, starts services, or calls launchctl."
262
+ ].join("\n");
263
+ }
264
+
265
+ function formatChecks(checks) {
266
+ return [
267
+ ["packageDir", checks.packageDir],
268
+ ["builtDaemon", checks.builtDaemon],
269
+ ["builtMcpServer", checks.builtMcpServer],
270
+ ["claudeHook", checks.claudeHook],
271
+ ["projectMemoryDir", checks.projectMemoryDir],
272
+ ["ready", checks.ready]
273
+ ].map(([name, value]) => ` ${name}: ${value ? "ok" : "missing"}`);
274
+ }
275
+
276
+ function buildNextCommands({ ready, daemonUrl, daemonBin, mcpBin, claudeHook }) {
277
+ const commands = [];
278
+ if (!ready) commands.push("npm --workspace @peon/mcp run build");
279
+ commands.push(`PEON_DAEMON_URL=${daemonUrl} node ${shellQuote(daemonBin)}`);
280
+ commands.push(`PEON_DAEMON_URL=${daemonUrl} node ${shellQuote(mcpBin)}`);
281
+ commands.push(`PEON_DAEMON_URL=${daemonUrl} node ${shellQuote(claudeHook)}`);
282
+ commands.push("node peon-mcp/scripts/install-peon.mjs --check");
283
+ return commands;
284
+ }
285
+
286
+ function setAction(current, next) {
287
+ if (current !== "dry-run" && current !== next) {
288
+ throw new Error("Choose only one of --check or --write-plan.");
289
+ }
290
+ return next;
291
+ }
292
+
293
+ function normalizeUrl(value) {
294
+ return String(value || defaultDaemonUrl).replace(/\/$/, "");
295
+ }
296
+
297
+ async function exists(path) {
298
+ return access(path).then(
299
+ () => true,
300
+ () => false
301
+ );
302
+ }
303
+
304
+ function isMain(moduleUrl, argvPath) {
305
+ return Boolean(argvPath && moduleUrl === pathToFileURL(argvPath).href);
306
+ }
307
+
308
+ function indent(value, spaces) {
309
+ const prefix = " ".repeat(spaces);
310
+ return value
311
+ .split("\n")
312
+ .map((line) => `${prefix}${line}`)
313
+ .join("\n");
314
+ }
315
+
316
+ function shellQuote(value) {
317
+ return `'${value.replace(/'/g, "'\\''")}'`;
318
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Eval results ledger — a committed, append-only history of every eval run so improvements
3
+ * can be PROVEN (diffed against a fixed baseline) rather than asserted.
4
+ *
5
+ * Peon's most valuable findings (graph-is-dead at -2.9%/-3.8% Recall@10; consolidation
6
+ * belief-only 16.7% vs raw 61.1%) came from evals that only `console.log`'d — there was no
7
+ * baseline to regress against. Each row pins THREE fingerprints so a comparison is trustworthy:
8
+ * - gitSha : the code under test
9
+ * - qrelsHash : the exact question/relevance set
10
+ * - brain : {records, hash} of memories.jsonl — because retrieval evals rank against the
11
+ * LIVE, mutating brain, a diff is only apples-to-apples when the brain matches.
12
+ * Side-effect-free except appendRow/mkdir, so the pure logic is unit-testable.
13
+ */
14
+ import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
15
+ import { createHash } from "node:crypto";
16
+ import { execFileSync } from "node:child_process";
17
+ import { join, dirname } from "node:path";
18
+
19
+ export function sha256(text) {
20
+ return createHash("sha256").update(String(text)).digest("hex").slice(0, 16);
21
+ }
22
+
23
+ export function gitSha(cwd = process.cwd()) {
24
+ try {
25
+ // execFileSync (no shell) with a fixed argv — no interpolation, no injection surface.
26
+ return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd, stdio: ["ignore", "pipe", "ignore"] })
27
+ .toString()
28
+ .trim();
29
+ } catch {
30
+ return "unknown";
31
+ }
32
+ }
33
+
34
+ /** sha256 of a file's contents (short), or "absent" if unreadable — for the qrels fingerprint. */
35
+ export function fileHash(path) {
36
+ try {
37
+ return sha256(readFileSync(path, "utf8"));
38
+ } catch {
39
+ return "absent";
40
+ }
41
+ }
42
+
43
+ /** Pin the memory state: record count + content hash of memories.jsonl. */
44
+ export function brainFingerprint(projectPath, memoryDirName = ".peon") {
45
+ const mem = join(projectPath, memoryDirName, "brain", "memories.jsonl");
46
+ let content = "";
47
+ try {
48
+ content = readFileSync(mem, "utf8");
49
+ } catch {
50
+ return { records: 0, hash: "absent" };
51
+ }
52
+ const records = content.split(/\r?\n/).filter(Boolean).length;
53
+ return { records, hash: sha256(content) };
54
+ }
55
+
56
+ export function ledgerPath(cwd = process.cwd()) {
57
+ return join(cwd, "eval-results", "history.jsonl");
58
+ }
59
+
60
+ export function readLedger(path) {
61
+ if (!existsSync(path)) return [];
62
+ return readFileSync(path, "utf8")
63
+ .split(/\r?\n/)
64
+ .filter(Boolean)
65
+ .map((l) => {
66
+ try {
67
+ return JSON.parse(l);
68
+ } catch {
69
+ return null;
70
+ }
71
+ })
72
+ .filter(Boolean);
73
+ }
74
+
75
+ /**
76
+ * The most recent prior row comparable to `row`. Prefers an EXACT match (same kind + qrelsHash +
77
+ * brain hash) → a trustworthy code-only A/B. Falls back to the latest same-kind+qrels row with
78
+ * `sameBrain:false` so the caller can still show a delta but flag it as brain-drifted (informational).
79
+ */
80
+ export function findBaseline(rows, row) {
81
+ let loose = null;
82
+ for (let i = rows.length - 1; i >= 0; i--) {
83
+ const r = rows[i];
84
+ if (r.kind !== row.kind || r.qrelsHash !== row.qrelsHash) continue;
85
+ if ((r.brain && r.brain.hash) === (row.brain && row.brain.hash)) return { row: r, sameBrain: true };
86
+ if (!loose) loose = r;
87
+ }
88
+ return loose ? { row: loose, sameBrain: false } : null;
89
+ }
90
+
91
+ export function appendRow(path, row) {
92
+ mkdirSync(dirname(path), { recursive: true });
93
+ appendFileSync(path, JSON.stringify(row) + "\n");
94
+ }
95
+
96
+ /** Signed per-key delta of two flat numeric metric maps (keys present in both). */
97
+ export function metricDelta(current, baseline) {
98
+ const out = {};
99
+ if (!baseline) return out;
100
+ for (const k of Object.keys(current)) {
101
+ if (typeof current[k] === "number" && typeof baseline[k] === "number") out[k] = current[k] - baseline[k];
102
+ }
103
+ return out;
104
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pure classifiers for STL daemon-log analysis.
3
+ *
4
+ * Kept side-effect free (no fs / no top-level work) so the STL engine's behaviour
5
+ * can be unit-tested without executing the whole report-generation script.
6
+ */
7
+
8
+ /**
9
+ * True when a `response_out` log entry is a CLIENT-side connection abort rather than
10
+ * a genuine server fault. Node/Express logs status 500 with `error: "aborted"` when
11
+ * the socket closes mid-response — e.g. a Claude Code hook process exits (or fires a
12
+ * newer request) before Peon finishes replying. The event is not lost: the client
13
+ * retries and the record lands (the 201s dominate the log). Counting these as
14
+ * "serious recording-path 5xx" cries wolf and has repeatedly skewed the verdict.
15
+ */
16
+ export function isClientAbort(e) {
17
+ if (!e || Number(e.status) < 500) return false;
18
+ return /\babort(ed)?\b/i.test(String(e.error || ""));
19
+ }
20
+
21
+ /**
22
+ * True when a `response_out` 500 is a stale-session rejection the client self-heals,
23
+ * rather than a genuine server fault. The daemon throws `Unknown Peon session: <id>`
24
+ * (HTTP 500) when a recording call (`/messages`, `/events`) references a session id it
25
+ * no longer holds — typically because the daemon restarted, or the session index pruned
26
+ * the entry, after the client cached the id. The Claude Code hook's `recordWithSession`
27
+ * catches exactly this message, drops the stale id, recreates the session and retries,
28
+ * so the record still lands (the log shows the 500 immediately followed by
29
+ * `/sessions:201` + `/messages:201`). Surfacing these as "serious recording-path 5xx"
30
+ * cries wolf just like client aborts did, and repeatedly drove the recording-path
31
+ * headline off a self-healed, no-data-loss condition.
32
+ */
33
+ export function isStaleSession(e) {
34
+ if (!e || Number(e.status) < 500) return false;
35
+ return /Unknown Peon session/i.test(String(e.error || ""));
36
+ }
37
+
38
+ /**
39
+ * True when a `response_out` is a real server fault we should surface — status >= 500
40
+ * that is neither a client abort nor a self-healed stale-session rejection.
41
+ */
42
+ export function isServerFault(e) {
43
+ return !!e && Number(e.status) >= 500 && !isClientAbort(e) && !isStaleSession(e);
44
+ }