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,144 @@
1
+ #!/usr/bin/env node
2
+ // LongMemEval harness for Peon (Node-only, via OpenRouter).
3
+ //
4
+ // Per question: feed the timestamped haystack into a fresh isolated Peon store,
5
+ // consolidate into beliefs, retrieve context for the question, let a reader LLM
6
+ // answer from ONLY that memory, then grade with gpt-4o using LongMemEval's exact
7
+ // per-category prompts. Reports overall + per-category accuracy.
8
+ //
9
+ // node scripts/longmemeval-eval.mjs <dataset.json> [N_per_type] [readerModel]
10
+ //
11
+ import { readFileSync, mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { PeonMemoryStore } from "../dist/memory-store.js";
15
+ import { PeonMemoryProcessor } from "../dist/processor.js";
16
+ import { loadPeonConfig } from "../dist/config.js";
17
+ import { rerankRecords } from "../dist/reranker.js";
18
+ import { expandQuery } from "../dist/hyde.js";
19
+
20
+ const [, , dataPath, perTypeArg, readerArg, modeArg] = process.argv;
21
+ const perType = Number.parseInt(perTypeArg ?? "3", 10);
22
+ const READER = readerArg ?? "openai/gpt-4o";
23
+ const MODE = modeArg ?? "beliefs"; // "beliefs" = consolidate first; "raw" = rank raw episodic turns
24
+ const RERANK = process.env.PEON_RERANK === "1"; // stage-two LLM reranker (raw mode only)
25
+ const HYDE = process.env.PEON_HYDE === "1"; // hypothetical-document query expansion (raw mode only)
26
+ const GRAPH = process.env.PEON_GRAPH === "1"; // entity-graph 1-hop expansion (raw mode only)
27
+ const JUDGE = "openai/gpt-4o-2024-08-06";
28
+ const KEY = loadPeonConfig().openRouterApiKey;
29
+ const processor = new PeonMemoryProcessor();
30
+
31
+ async function chat(model, messages, temperature = 0) {
32
+ for (let attempt = 0; attempt < 4; attempt++) {
33
+ try {
34
+ const r = await fetch("https://openrouter.ai/api/v1/chat/completions", {
35
+ method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
36
+ body: JSON.stringify({ model, temperature, messages })
37
+ });
38
+ if (!r.ok) throw new Error(`${r.status}`);
39
+ const j = await r.json();
40
+ return (j.choices?.[0]?.message?.content ?? "").trim();
41
+ } catch (e) { await new Promise((res) => setTimeout(res, 1000 * (attempt + 1))); }
42
+ }
43
+ return "";
44
+ }
45
+
46
+ // ── Peon: ingest haystack → (consolidate OR keep raw) → retrieve ──
47
+ async function peonMemoryFor(q) {
48
+ const projectPath = mkdtempSync(join(tmpdir(), "lme-"));
49
+ try {
50
+ const store = await PeonMemoryStore.open({ projectPath });
51
+ if (MODE === "raw") {
52
+ // Index each raw turn as an episodic record, then rank with Peon's ranker.
53
+ const records = [];
54
+ for (let s = 0; s < q.haystack_sessions.length; s++) {
55
+ const date = q.haystack_dates?.[s] ?? new Date().toISOString();
56
+ for (const turn of q.haystack_sessions[s]) {
57
+ if (!turn?.content) continue;
58
+ const content = `[${date}] ${turn.role}: ${turn.content}`;
59
+ records.push({ id: `t${records.length}`, type: "fact", content, normalized: content.toLowerCase(),
60
+ scope: "project", status: "active", score: { importance: 0.5, confidence: 0.6 },
61
+ source: { kind: "manual" }, entities: [], createdAt: date, updatedAt: date });
62
+ }
63
+ }
64
+ await store.replaceMemoryRecords(records);
65
+ // HyDE: retrieve against a hypothetical answer to the question, not the bare question.
66
+ const retrievalQuery = HYDE ? (await expandQuery(q.question, { config: loadPeonConfig() })).expanded : q.question;
67
+ let ranked = await store.rankRecords(retrievalQuery, { limit: RERANK ? 30 : 15, expandGraph: GRAPH });
68
+ if (RERANK) {
69
+ // Stage two: LLM reranks the recall set, then keep the top 15 for the reader.
70
+ ranked = (await rerankRecords(q.question, ranked, { config: loadPeonConfig(), topK: 30 })).slice(0, 15);
71
+ }
72
+ return ranked.map((r) => r.record.content).join("\n");
73
+ }
74
+ // beliefs / blended mode: real Peon — record, consolidate, retrieve distilled beliefs.
75
+ // "blended" additionally turns on the episodic layer (raw turns) to recover the detail
76
+ // consolidation compresses away — the high-recall complement to the belief layer.
77
+ const session = await store.startSession({ client: "lme" });
78
+ for (let s = 0; s < q.haystack_sessions.length; s++) {
79
+ const date = q.haystack_dates?.[s] ?? "";
80
+ for (const turn of q.haystack_sessions[s]) {
81
+ if (turn?.content) await store.recordMessage({ sessionId: session.id, role: turn.role === "assistant" ? "assistant" : "user", content: `[${date}] ${turn.content}` });
82
+ }
83
+ }
84
+ await store.endSession({ sessionId: session.id });
85
+ await processor.processMemory({ projectPath, reason: "lme" });
86
+ const ctx = await store.getContext({ query: q.question, maxChars: 6000, includeEpisodes: MODE === "blended" });
87
+ return [ctx.summary, ctx.memories, ctx.decisions, ctx.preferences, ctx.openQuestions, ctx.artifacts, ctx.timeline, ctx.episodes].filter((t) => t && t.trim()).join("\n\n");
88
+ } finally {
89
+ rmSync(projectPath, { recursive: true, force: true });
90
+ }
91
+ }
92
+
93
+ async function read(question, questionDate, memory) {
94
+ const sys = "You answer a question using ONLY the memory below, which was recalled from the user's past conversations. " +
95
+ "Be concise and specific. If the memory does not contain enough information to answer, reply exactly: \"I don't know.\"";
96
+ return chat(READER, [
97
+ { role: "system", content: sys },
98
+ { role: "user", content: `Today's date: ${questionDate}\n\nMemory:\n${memory || "(empty)"}\n\nQuestion: ${question}\n\nAnswer:` }
99
+ ]);
100
+ }
101
+
102
+ // LongMemEval's exact judge prompts (from src/evaluation/evaluate_qa.py).
103
+ function judgePrompt(task, question, answer, response, abstention) {
104
+ if (abstention) return `I will give you an unanswerable question, an explanation, and a response from a model. Please answer yes if the model correctly identifies the question as unanswerable. The model could say that the information is incomplete, or some other information is given but the asked information is not.\n\nQuestion: ${question}\n\nExplanation: ${answer}\n\nModel Response: ${response}\n\nDoes the model correctly identify the question as unanswerable? Answer yes or no only.`;
105
+ if (["single-session-user", "single-session-assistant", "multi-session"].includes(task))
106
+ return `I will give you a question, a correct answer, and a response from a model. Please answer yes if the response contains the correct answer. Otherwise, answer no. If the response is equivalent to the correct answer or contains all the intermediate steps to get the correct answer, you should also answer yes. If the response only contains a subset of the information required by the answer, answer no.\n\nQuestion: ${question}\n\nCorrect Answer: ${answer}\n\nModel Response: ${response}\n\nIs the model response correct? Answer yes or no only.`;
107
+ if (task === "temporal-reasoning")
108
+ return `I will give you a question, a correct answer, and a response from a model. Please answer yes if the response contains the correct answer. Otherwise, answer no. If the response is equivalent to the correct answer or contains all the intermediate steps to get the correct answer, you should also answer yes. If the response only contains a subset of the information required by the answer, answer no. In addition, do not penalize off-by-one errors for the number of days. If the question asks for the number of days/weeks/months, etc., and the model makes off-by-one errors, the model's response is still correct.\n\nQuestion: ${question}\n\nCorrect Answer: ${answer}\n\nModel Response: ${response}\n\nIs the model response correct? Answer yes or no only.`;
109
+ if (task === "knowledge-update")
110
+ return `I will give you a question, a correct answer, and a response from a model. Please answer yes if the response contains the correct answer. Otherwise, answer no. If the response contains some previous information along with an updated answer, the response should be considered as correct as long as the updated answer is the required answer.\n\nQuestion: ${question}\n\nCorrect Answer: ${answer}\n\nModel Response: ${response}\n\nIs the model response correct? Answer yes or no only.`;
111
+ if (task === "single-session-preference")
112
+ return `I will give you a question, a rubric for desired personalized response, and a response from a model. Please answer yes if the response satisfies the desired response. Otherwise, answer no. The model does not need to reflect all the points in the rubric. The response is correct as long as it recalls and utilizes the user's personal information correctly.\n\nQuestion: ${question}\n\nRubric: ${answer}\n\nModel Response: ${response}\n\nIs the model response correct? Answer yes or no only.`;
113
+ return `Question: ${question}\nCorrect Answer: ${answer}\nModel Response: ${response}\nIs the model response correct? Answer yes or no only.`;
114
+ }
115
+
116
+ async function grade(q, hyp) {
117
+ const abstention = String(q.question_id).endsWith("_abs");
118
+ const out = await chat(JUDGE, [{ role: "user", content: judgePrompt(q.question_type, q.question, q.answer, hyp, abstention) }]);
119
+ return /^\s*yes/i.test(out);
120
+ }
121
+
122
+ // ── main ──
123
+ const data = JSON.parse(readFileSync(dataPath, "utf8"));
124
+ const byType = {};
125
+ for (const q of data) (byType[q.question_type] ??= []).push(q);
126
+ const subset = Object.values(byType).flatMap((arr) => arr.slice(0, perType));
127
+ console.log(`LongMemEval: ${subset.length} questions (${perType}/type) · mode=${MODE} · reader=${READER} · judge=${JUDGE}\n`);
128
+
129
+ const perCat = {};
130
+ let correct = 0, done = 0;
131
+ for (const q of subset) {
132
+ const mem = await peonMemoryFor(q).catch(() => "");
133
+ const hyp = await read(q.question, q.question_date, mem);
134
+ const ok = await grade(q, hyp);
135
+ const cat = q.question_type + (String(q.question_id).endsWith("_abs") ? "(abs)" : "");
136
+ (perCat[cat] ??= { n: 0, ok: 0 }).n++;
137
+ if (ok) { perCat[cat].ok++; correct++; }
138
+ done++;
139
+ process.stdout.write(`\r scored ${done}/${subset.length} · running acc ${(100 * correct / done).toFixed(1)}% `);
140
+ }
141
+ console.log(`\n\n === RESULTS ===`);
142
+ console.log(` Overall accuracy: ${(100 * correct / subset.length).toFixed(1)}% (${correct}/${subset.length})`);
143
+ console.log(` By category:`);
144
+ for (const [cat, v] of Object.entries(perCat).sort()) console.log(` ${cat.padEnd(28)} ${(100 * v.ok / v.n).toFixed(0).padStart(3)}% (${v.ok}/${v.n})`);
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Peon analysis report.
4
+ *
5
+ * Reads a project's on-disk brain + the daemon log and prints what Peon did:
6
+ * how much it captured, how often it consolidated (and what changed), how many
7
+ * tokens that cost, and how much context it handed back. Pass two project paths
8
+ * to compare an A/B run (e.g. Peon-off folder vs Peon-on folder).
9
+ *
10
+ * node scripts/peon-report.mjs "/path/to/projectB"
11
+ * node scripts/peon-report.mjs "/path/to/projectA-off" "/path/to/projectB-on"
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ const LOG_PATH = process.env.PEON_LOG_PATH || join(homedir(), "Library", "Logs", "Peon", "daemon.jsonl");
18
+
19
+ function readJsonl(path) {
20
+ let raw = "";
21
+ try {
22
+ raw = readFileSync(path, "utf8");
23
+ } catch {
24
+ return [];
25
+ }
26
+ const out = [];
27
+ for (const line of raw.split(/\r?\n/)) {
28
+ const t = line.trim();
29
+ if (!t) continue;
30
+ try {
31
+ out.push(JSON.parse(t));
32
+ } catch {
33
+ /* skip bad line */
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function readJson(path) {
40
+ try {
41
+ return JSON.parse(readFileSync(path, "utf8"));
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ const LOG = readJsonl(LOG_PATH);
48
+
49
+ function analyze(projectPath) {
50
+ const peon = join(projectPath, ".peon");
51
+ const records = readJsonl(join(peon, "brain", "memories.jsonl"));
52
+ const messages = readJsonl(join(peon, "raw", "messages.jsonl"));
53
+ const events = readJsonl(join(peon, "raw", "events.jsonl"));
54
+ const state = readJson(join(peon, "brain", "processing-state.json")) || {};
55
+
56
+ const enabled = records.length > 0 || messages.length > 0 || events.length > 0;
57
+ const byStatus = {};
58
+ const byType = {};
59
+ for (const r of records) {
60
+ byStatus[r.status] = (byStatus[r.status] || 0) + 1;
61
+ byType[r.type] = (byType[r.type] || 0) + 1;
62
+ }
63
+
64
+ const logForProject = LOG.filter((e) => e.projectPath === projectPath);
65
+ const runs = logForProject.filter((e) => e.type === "process_finish" || e.type === "auto_process_finish");
66
+ const processed = runs.filter((e) => e.status === "processed");
67
+ const served = logForProject.filter((e) => e.type === "context_served");
68
+
69
+ const sum = (arr, key) => arr.reduce((a, e) => a + (Number(e[key]) || 0), 0);
70
+
71
+ return {
72
+ projectPath,
73
+ enabled,
74
+ captured: { messages: messages.length, events: events.length },
75
+ brain: { total: records.length, byStatus, byType },
76
+ consolidation: {
77
+ runs: runs.length,
78
+ processed: processed.length,
79
+ llmTokens: sum(processed, "estimatedTokens"),
80
+ superseded: sum(processed, "superseded"),
81
+ obsoleted: sum(processed, "obsoleted"),
82
+ recordsAdded: sum(processed, "recordsAdded"),
83
+ merged: sum(processed, "merged"),
84
+ lastModel: state.lastModel
85
+ },
86
+ recall: {
87
+ contextServed: served.length,
88
+ totalChars: sum(served, "chars"),
89
+ avgChars: served.length ? Math.round(sum(served, "chars") / served.length) : 0
90
+ }
91
+ };
92
+ }
93
+
94
+ function fmt(n) {
95
+ return Number(n || 0).toLocaleString("en-US");
96
+ }
97
+
98
+ function printReport(a) {
99
+ const name = a.projectPath.split("/").filter(Boolean).pop();
100
+ console.log("\n" + "=".repeat(64));
101
+ console.log(` ${name} ${a.enabled ? "(Peon ON)" : "(Peon OFF — no brain on disk)"}`);
102
+ console.log("=".repeat(64));
103
+ if (!a.enabled) {
104
+ console.log(" No Peon data. This was a cloud-only / Peon-disabled session.");
105
+ return;
106
+ }
107
+ console.log(` Captured ${fmt(a.captured.messages)} messages, ${fmt(a.captured.events)} events`);
108
+ console.log(
109
+ ` Brain ${fmt(a.brain.total)} records ` +
110
+ Object.entries(a.brain.byStatus)
111
+ .map(([k, v]) => `${v} ${k}`)
112
+ .join(", ")
113
+ );
114
+ console.log(
115
+ ` Consolidation ${a.consolidation.processed} runs · ${fmt(a.consolidation.llmTokens)} LLM tokens (${a.consolidation.lastModel || "?"})`
116
+ );
117
+ console.log(
118
+ ` ${a.consolidation.superseded} changed-mind · ${a.consolidation.merged} merged · ${a.consolidation.recordsAdded} learned · ${a.consolidation.obsoleted} retired`
119
+ );
120
+ console.log(
121
+ ` Recall context handed back ${fmt(a.recall.contextServed)}× · ${fmt(a.recall.totalChars)} chars total · ${fmt(a.recall.avgChars)} avg/prompt`
122
+ );
123
+ }
124
+
125
+ const args = process.argv.slice(2);
126
+ if (args.length === 0) {
127
+ console.error("usage: node scripts/peon-report.mjs <projectPath> [otherProjectPath]");
128
+ process.exit(1);
129
+ }
130
+
131
+ const reports = args.map((p) => analyze(p.replace(/\/$/, "")));
132
+ reports.forEach(printReport);
133
+
134
+ if (reports.length === 2) {
135
+ const on = reports.find((r) => r.enabled);
136
+ const off = reports.find((r) => !r.enabled) || reports[1];
137
+ console.log("\n" + "=".repeat(64));
138
+ console.log(" A/B SUMMARY");
139
+ console.log("=".repeat(64));
140
+ if (on) {
141
+ console.log(
142
+ ` Peon overhead this run: ${fmt(on.consolidation.llmTokens)} tokens on ${on.consolidation.lastModel || "the consolidation model"} (cheap),`
143
+ );
144
+ console.log(
145
+ ` in exchange for auto-injecting context ${fmt(on.recall.contextServed)} times (${fmt(on.recall.totalChars)} chars) so you did not have to re-explain.`
146
+ );
147
+ }
148
+ console.log(
149
+ "\n NOTE: Peon cannot see your main model's token usage (that is on the AI\n" +
150
+ " provider's side). Compare the MAIN model's tokens between the two folders\n" +
151
+ " from your AI tool's own usage view; this report quantifies Peon's overhead\n" +
152
+ " and the context it added, which is the other half of that trade-off."
153
+ );
154
+ }
155
+ console.log("");