@possumtech/rummy 0.3.0 → 0.3.1

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 (47) hide show
  1. package/.env.example +2 -1
  2. package/PLUGINS.md +1 -1
  3. package/SPEC.md +181 -38
  4. package/migrations/001_initial_schema.sql +1 -1
  5. package/package.json +7 -3
  6. package/service.js +5 -3
  7. package/src/agent/AgentLoop.js +182 -136
  8. package/src/agent/ContextAssembler.js +2 -0
  9. package/src/agent/KnownStore.js +28 -85
  10. package/src/agent/ResponseHealer.js +65 -31
  11. package/src/agent/TurnExecutor.js +326 -181
  12. package/src/agent/XmlParser.js +5 -2
  13. package/src/agent/known_store.sql +48 -0
  14. package/src/agent/tokens.js +1 -0
  15. package/src/agent/turns.sql +5 -0
  16. package/src/hooks/HookRegistry.js +7 -0
  17. package/src/hooks/Hooks.js +1 -4
  18. package/src/hooks/ToolRegistry.js +2 -8
  19. package/src/plugins/budget/README.md +2 -14
  20. package/src/plugins/budget/budget.js +15 -39
  21. package/src/plugins/cp/cp.js +1 -1
  22. package/src/plugins/cp/cpDoc.js +1 -1
  23. package/src/plugins/get/get.js +71 -1
  24. package/src/plugins/get/getDoc.js +14 -4
  25. package/src/plugins/hedberg/matcher.js +10 -29
  26. package/src/plugins/instructions/preamble.md +16 -6
  27. package/src/plugins/known/known.js +4 -10
  28. package/src/plugins/known/knownDoc.js +15 -14
  29. package/src/plugins/mv/mv.js +18 -1
  30. package/src/plugins/mv/mvDoc.js +15 -1
  31. package/src/plugins/{current → performed}/README.md +4 -3
  32. package/src/plugins/{current/current.js → performed/performed.js} +15 -20
  33. package/src/plugins/previous/README.md +2 -1
  34. package/src/plugins/previous/previous.js +31 -25
  35. package/src/plugins/progress/README.md +1 -2
  36. package/src/plugins/progress/progress.js +15 -29
  37. package/src/plugins/prompt/prompt.js +0 -7
  38. package/src/plugins/rm/rm.js +27 -15
  39. package/src/plugins/rm/rmDoc.js +3 -3
  40. package/src/plugins/set/set.js +55 -19
  41. package/src/plugins/set/setDoc.js +6 -2
  42. package/src/plugins/telemetry/telemetry.js +14 -9
  43. package/src/plugins/unknown/README.md +2 -1
  44. package/src/plugins/unknown/unknown.js +5 -4
  45. package/src/server/ClientConnection.js +59 -45
  46. package/src/sql/v_model_context.sql +3 -13
  47. package/src/plugins/budget/BudgetGuard.js +0 -74
@@ -1,25 +1,26 @@
1
- export default class Current {
1
+ export default class Performed {
2
2
  #core;
3
3
 
4
4
  constructor(core) {
5
5
  this.#core = core;
6
- core.filter("assembly.user", this.assembleCurrent.bind(this), 100);
6
+ core.filter("assembly.user", this.assemblePerformed.bind(this), 100);
7
7
  }
8
8
 
9
- async assembleCurrent(content, ctx) {
9
+ async assemblePerformed(content, ctx) {
10
10
  const entries = ctx.rows.filter(
11
- (r) => r.category === "logging" && r.source_turn >= ctx.loopStartTurn,
11
+ (r) =>
12
+ r.category === "logging" &&
13
+ r.source_turn >= ctx.loopStartTurn &&
14
+ r.scheme !== "unknown",
12
15
  );
13
16
  if (entries.length === 0) return content;
14
17
 
15
- const lines = await Promise.all(
16
- entries.map((e) => renderToolTag(e, this.#core)),
17
- );
18
- return `${content}<current>\n${lines.join("\n")}\n</current>\n`;
18
+ const lines = entries.map((e) => renderToolTag(e));
19
+ return `${content}<performed>\n${lines.join("\n")}\n</performed>\n`;
19
20
  }
20
21
  }
21
22
 
22
- async function renderToolTag(entry, core) {
23
+ function renderToolTag(entry) {
23
24
  const attrs =
24
25
  typeof entry.attributes === "string"
25
26
  ? JSON.parse(entry.attributes)
@@ -28,23 +29,17 @@ async function renderToolTag(entry, core) {
28
29
  const target = attrs?.path || attrs?.file || attrs?.command || "";
29
30
  const turn = entry.source_turn ? ` turn="${entry.source_turn}"` : "";
30
31
  const status = entry.status ? ` status="${entry.status}"` : "";
32
+ const fidelity = entry.fidelity ? ` fidelity="${entry.fidelity}"` : "";
33
+ const tokens = entry.tokens ? ` tokens="${entry.tokens}"` : "";
31
34
  const summary =
32
35
  typeof attrs?.summary === "string"
33
36
  ? ` summary="${attrs.summary.slice(0, 80)}"`
34
37
  : "";
35
38
 
36
- let body;
37
- try {
38
- body = await core.hooks.tools.view(entry.scheme, {
39
- ...entry,
40
- attributes: attrs,
41
- });
42
- } catch {
43
- body = entry.body;
44
- }
39
+ const body = entry.body || null;
45
40
 
46
41
  if (body) {
47
- return `<${entry.scheme} path="${target}"${turn}${status}${summary}>${body}</${entry.scheme}>`;
42
+ return `<${entry.scheme} path="${target}"${turn}${status}${summary}${fidelity}${tokens}>${body}</${entry.scheme}>`;
48
43
  }
49
- return `<${entry.scheme} path="${target}"${turn}${status}${summary}/>`;
44
+ return `<${entry.scheme} path="${target}"${turn}${status}${summary}${fidelity}${tokens}/>`;
50
45
  }
@@ -12,4 +12,5 @@ history from prior ask/act invocations on this run.
12
12
 
13
13
  Filters turn_context rows where `category` is `logging` or `prompt`
14
14
  and `source_turn < loopStartTurn`. Renders each entry chronologically
15
- with turn number and status.
15
+ with turn, status, summary, fidelity, and tokens. The model can target
16
+ these entries by path with `<set>` or `<rm>` to free context space.
@@ -9,11 +9,20 @@ export default class Previous {
9
9
  async assemblePrevious(content, ctx) {
10
10
  if (ctx.loopStartTurn <= 1) return content;
11
11
 
12
- const entries = ctx.rows.filter(
13
- (r) =>
14
- (r.category === "logging" || r.category === "prompt") &&
15
- r.source_turn < ctx.loopStartTurn,
16
- );
12
+ const entries = ctx.rows
13
+ .filter(
14
+ (r) =>
15
+ (r.category === "logging" || r.category === "prompt") &&
16
+ r.source_turn < ctx.loopStartTurn,
17
+ )
18
+ .toSorted((a, b) => {
19
+ if (a.source_turn !== b.source_turn)
20
+ return a.source_turn - b.source_turn;
21
+ // Within the same turn: prompt first (cause before effect)
22
+ if (a.category === "prompt" && b.category !== "prompt") return -1;
23
+ if (b.category === "prompt" && a.category !== "prompt") return 1;
24
+ return 0;
25
+ });
17
26
  if (entries.length === 0) return content;
18
27
 
19
28
  const lines = await Promise.all(
@@ -23,7 +32,7 @@ export default class Previous {
23
32
  }
24
33
  }
25
34
 
26
- async function renderToolTag(entry, core) {
35
+ async function renderToolTag(entry, _core) {
27
36
  const attrs =
28
37
  typeof entry.attributes === "string"
29
38
  ? JSON.parse(entry.attributes)
@@ -32,23 +41,20 @@ async function renderToolTag(entry, core) {
32
41
  const target = attrs?.path || attrs?.file || attrs?.command || "";
33
42
  const turn = entry.source_turn ? ` turn="${entry.source_turn}"` : "";
34
43
  const status = entry.status ? ` status="${entry.status}"` : "";
35
- const summary =
36
- typeof attrs?.summary === "string"
37
- ? ` summary="${attrs.summary.slice(0, 80)}"`
38
- : "";
39
-
40
- let body;
41
- try {
42
- body = await core.hooks.tools.view(entry.scheme, {
43
- ...entry,
44
- attributes: attrs,
45
- });
46
- } catch {
47
- body = entry.body;
48
- }
49
-
50
- if (body) {
51
- return `<${entry.scheme} path="${target}"${turn}${status}${summary}>${body}</${entry.scheme}>`;
52
- }
53
- return `<${entry.scheme} path="${target}"${turn}${status}${summary}/>`;
44
+ const fidelity = entry.fidelity ? ` fidelity="${entry.fidelity}"` : "";
45
+ const tokens = entry.tokens ? ` tokens="${entry.tokens}"` : "";
46
+
47
+ // Previous entries render at summary. Prompts get 512 chars for orientation.
48
+ const limit = entry.scheme === "prompt" ? 512 : 80;
49
+ const rawSummary =
50
+ (typeof attrs?.summary === "string" ? attrs.summary : null) ||
51
+ entry.body?.slice(0, limit) ||
52
+ "";
53
+ // Strip internal dedup namespace prefixes (e.g. "get://turn_3/src/app.js" → "src/app.js")
54
+ const summaryText = rawSummary.replace(/\b\w+:\/\/turn_\d+\//g, "");
55
+ const summaryAttr = summaryText
56
+ ? ` summary="${summaryText.replace(/"/g, "'").slice(0, limit)}"`
57
+ : "";
58
+
59
+ return `<${entry.scheme} path="${target}"${turn}${status}${summaryAttr}${fidelity}${tokens}/>`;
54
60
  }
@@ -9,9 +9,8 @@ current work log to the active prompt.
9
9
 
10
10
  ## Behavior
11
11
 
12
- On first turn: "Begin."
12
+ Emits `<progress turn="N">` carrying token budget and fidelity stats.
13
13
  On continuation turns with current entries: "The above actions were
14
14
  performed in response to the following prompt:"
15
- If a `progress://` entry exists, uses its body directly.
16
15
 
17
16
  Progress text is the tuning knob for model orientation between turns.
@@ -7,38 +7,34 @@ export default class Progress {
7
7
  }
8
8
 
9
9
  async assembleProgress(content, ctx) {
10
- // Use last turn's real assembled token count when available.
11
- // Falls back to row token sum (less accurate — missing system prompt overhead).
12
- const rowTokens = ctx.rows.reduce((sum, r) => sum + (r.tokens || 0), 0);
13
- const usedTokens = ctx.lastContextTokens || rowTokens;
14
- const contextSize = ctx.contextSize || 0;
10
+ const { lastContextTokens: usedTokens, contextSize } = ctx;
15
11
  const pct = contextSize ? Math.round((usedTokens / contextSize) * 100) : 0;
16
12
 
17
- // Fidelity distribution across known/file entries
18
- const entries = ctx.rows.filter((r) => r.category === "data");
13
+ // Fidelity distribution across all manageable entries (data + logging)
14
+ const dataEntries = ctx.rows.filter((r) => r.category === "data");
15
+ const loggingEntries = ctx.rows.filter((r) => r.category === "logging");
16
+ const entries = [...dataEntries, ...loggingEntries];
19
17
  const fullEntries = entries.filter((r) => r.fidelity === "full");
20
18
  const summaryEntries = entries.filter((r) => r.fidelity === "summary");
21
19
  const indexEntries = entries.filter((r) => r.fidelity === "index");
22
- const fullTokens = fullEntries.reduce((s, r) => s + (r.tokens || 0), 0);
23
- const summaryTokens = summaryEntries.reduce(
24
- (s, r) => s + (r.tokens || 0),
25
- 0,
26
- );
27
- const indexTokens = indexEntries.reduce((s, r) => s + (r.tokens || 0), 0);
20
+ const fullTokens = fullEntries.reduce((s, r) => s + r.tokens, 0);
21
+ const summaryTokens = summaryEntries.reduce((s, r) => s + r.tokens, 0);
22
+ const indexTokens = indexEntries.reduce((s, r) => s + r.tokens, 0);
28
23
 
29
24
  const unknownCount = ctx.rows.filter(
30
25
  (r) => r.category === "unknown",
31
26
  ).length;
32
27
 
33
- const hasCurrent = ctx.rows.some(
34
- (r) => r.category === "logging" && r.source_turn >= ctx.loopStartTurn,
28
+ const hasPerformed = loggingEntries.some(
29
+ (r) => r.source_turn >= ctx.loopStartTurn,
35
30
  );
36
31
 
37
32
  const parts = [];
38
33
 
39
- const knownCount = entries.length;
34
+ const knownCount = dataEntries.length;
35
+ const loggingCount = loggingEntries.length;
40
36
  const tokenLine = contextSize
41
- ? `${usedTokens} of ${contextSize} tokens (${pct}%) · ${knownCount} known${knownCount !== 1 ? "s" : ""} · ${unknownCount} unknown${unknownCount !== 1 ? "s" : ""}`
37
+ ? `${usedTokens} of ${contextSize} tokens (${pct}%) · ${knownCount} known${knownCount !== 1 ? "s" : ""} · ${loggingCount} logging · ${unknownCount} unknown${unknownCount !== 1 ? "s" : ""}`
42
38
  : "";
43
39
  if (tokenLine) parts.push(tokenLine);
44
40
 
@@ -55,22 +51,12 @@ export default class Progress {
55
51
  if (fidelityParts.length > 0)
56
52
  parts.push(`Entries: ${fidelityParts.join(" · ")}`);
57
53
 
58
- if (pct > 75) {
59
- parts.push(
60
- 'Context above 75%. YOU MUST free space: <set fidelity="summary" summary="topic,detail,keyword"/>, <set fidelity="archive"/>, or <rm/>. Target the largest entries.',
61
- );
62
- } else if (pct > 50) {
63
- parts.push(
64
- 'Context above 50%. You may free space: <set fidelity="summary" summary="topic,detail,keyword"/>, <set fidelity="archive"/>, or <rm/>.',
65
- );
66
- }
67
-
68
- if (hasCurrent) {
54
+ if (hasPerformed) {
69
55
  parts.push(
70
56
  "The above actions were performed in response to the following prompt:",
71
57
  );
72
58
  }
73
59
 
74
- return `${content}<progress>${parts.join("\n")}</progress>\n`;
60
+ return `${content}<progress turn="${ctx.turn}">${parts.join("\n")}</progress>\n`;
75
61
  }
76
62
  }
@@ -4,7 +4,6 @@ export default class Prompt {
4
4
  constructor(core) {
5
5
  this.#core = core;
6
6
  core.hooks.tools.onView("prompt", (entry) => entry.body);
7
- core.hooks.tools.onView("progress", (entry) => entry.body);
8
7
  core.on("turn.started", this.onTurnStarted.bind(this));
9
8
  core.filter("assembly.user", this.assemblePrompt.bind(this), 300);
10
9
  }
@@ -17,10 +16,6 @@ export default class Prompt {
17
16
  attributes: { mode },
18
17
  loopId,
19
18
  });
20
- } else {
21
- await store.upsert(runId, turn, `progress://${turn}`, prompt || "", 200, {
22
- loopId,
23
- });
24
19
  }
25
20
  }
26
21
 
@@ -41,8 +36,6 @@ export default class Prompt {
41
36
  const tools = toolNames.join(",");
42
37
  let warn = "";
43
38
  if (mode === "ask") warn = ' warn="File editing disallowed."';
44
- if (mode === "panic")
45
- warn = ' warn="Context overflow. Free space to continue."';
46
39
 
47
40
  return `${content}<prompt mode="${mode}" tools="${tools}"${warn}>${body}</prompt>`;
48
41
  }
@@ -41,25 +41,37 @@ export default class Rm {
41
41
  return;
42
42
  }
43
43
 
44
- for (const match of matches) {
45
- const resultPath = `rm://${match.path}`;
46
- if (match.scheme === null) {
47
- await store.upsert(runId, turn, resultPath, match.path, 202, {
48
- attributes: { path: match.path },
49
- loopId,
50
- });
51
- } else {
52
- await store.remove(runId, match.path);
53
- await store.upsert(runId, turn, resultPath, match.path, 200, {
54
- attributes: { path: match.path },
55
- loopId,
56
- });
57
- }
44
+ const fileMatches = matches.filter((m) => m.scheme === null);
45
+ const schemeMatches = matches.filter((m) => m.scheme !== null);
46
+
47
+ // Scheme entries: remove all, write one aggregate result entry
48
+ for (const match of schemeMatches) await store.remove(runId, match.path);
49
+ if (schemeMatches.length > 0) {
50
+ const paths = schemeMatches.map((m) => m.path).join("\n");
51
+ await store.upsert(runId, turn, entry.resultPath, paths, 200, {
52
+ attributes: { path: target },
53
+ loopId,
54
+ });
55
+ }
56
+
57
+ // File entries: individual 202 proposals (require user resolution)
58
+ if (fileMatches.length > 0 && schemeMatches.length > 0)
59
+ await store.remove(runId, entry.resultPath);
60
+ for (const match of fileMatches) {
61
+ const resultPath =
62
+ schemeMatches.length === 0 && fileMatches.length === 1
63
+ ? entry.resultPath
64
+ : await store.dedup(runId, "rm", match.path, turn);
65
+ await store.upsert(runId, turn, resultPath, match.path, 202, {
66
+ attributes: { path: match.path },
67
+ loopId,
68
+ });
58
69
  }
59
70
  }
60
71
 
61
72
  full(entry) {
62
- return `# rm ${entry.attributes.path || entry.path}`;
73
+ const header = `# rm ${entry.attributes.path || entry.path}`;
74
+ return entry.body ? `${header}\n${entry.body}` : header;
63
75
  }
64
76
 
65
77
  summary(entry) {
@@ -8,8 +8,8 @@ const LINES = [
8
8
  // --- Examples: file, known (with slug path), preview safety
9
9
  ['Example: <rm path="src/config.js"/>', "File removal. Simplest form."],
10
10
  [
11
- 'Example: <rm path="known://donald-rumsfeld-was-born-in-1932"/>',
12
- "Shows the slugified path convention. Model sees these paths in <knowns> section.",
11
+ 'Example: <rm path="known://config/deprecated_service"/>',
12
+ "Shows topic-hierarchy path convention. Paths are category/key, not sentence slugs.",
13
13
  ],
14
14
  [
15
15
  'Example: <rm path="known://temp_*" preview/>',
@@ -22,7 +22,7 @@ const LINES = [
22
22
  "Nudges toward archive over rm. Archive keeps the key; rm deletes permanently.",
23
23
  ],
24
24
  [
25
- "* Paths accept globs — use `preview` to check matches first",
25
+ "* Paths accept patterns — use `preview` to check matches first",
26
26
  "Reinforces preview safety pattern. Prevents accidental bulk deletion.",
27
27
  ],
28
28
  ];
@@ -1,4 +1,5 @@
1
1
  import KnownStore from "../../agent/KnownStore.js";
2
+ import { countTokens } from "../../agent/tokens.js";
2
3
  import Hedberg, { generatePatch } from "../hedberg/hedberg.js";
3
4
  import { storePatternResult } from "../helpers.js";
4
5
  import docs from "./setDoc.js";
@@ -66,16 +67,33 @@ export default class Set {
66
67
  }
67
68
  }
68
69
  }
70
+ if (matches.length === 0) {
71
+ await store.upsert(
72
+ runId,
73
+ turn,
74
+ entry.resultPath,
75
+ `${target} not found`,
76
+ 404,
77
+ {
78
+ fidelity: "archive",
79
+ loopId,
80
+ },
81
+ );
82
+ return;
83
+ }
69
84
  const label =
70
85
  fidelityAttr === "archive" ? "archived" : `set to ${fidelityAttr}`;
71
- const body =
72
- matches.length > 0
73
- ? `${matches.map((m) => m.path).join(", ")} ${label}`
74
- : `${target} not found`;
75
- await store.upsert(runId, turn, entry.resultPath, body, 200, {
76
- fidelity: "archive",
77
- loopId,
78
- });
86
+ await store.upsert(
87
+ runId,
88
+ turn,
89
+ entry.resultPath,
90
+ `${matches.map((m) => m.path).join(", ")} ${label}`,
91
+ 200,
92
+ {
93
+ fidelity: "archive",
94
+ loopId,
95
+ },
96
+ );
79
97
  return;
80
98
  }
81
99
 
@@ -198,7 +216,7 @@ export default class Set {
198
216
  ? `<<<<<<< SEARCH\n${searchText}\n=======\n${replaceText}\n>>>>>>> REPLACE`
199
217
  : null;
200
218
  const beforeTokens = match.tokens_full || 0;
201
- const afterTokens = patch ? (patch.length / 4) | 0 : beforeTokens;
219
+ const afterTokens = patch ? countTokens(patch) : beforeTokens;
202
220
 
203
221
  await store.upsert(runId, turn, resultPath, match.body, status, {
204
222
  attributes: {
@@ -265,7 +283,7 @@ export default class Set {
265
283
  : null;
266
284
  const merge = mergeBlocks.length > 0 ? mergeBlocks.join("\n") : null;
267
285
  const beforeTokens = fileEntry[0].tokens_full || 0;
268
- const afterTokens = current ? (current.length / 4) | 0 : beforeTokens;
286
+ const afterTokens = current ? countTokens(current) : beforeTokens;
269
287
 
270
288
  await store.upsert(runId, turn, entry.path, original, state, {
271
289
  attributes: {
@@ -287,10 +305,7 @@ export default class Set {
287
305
  return { search: attrs.search, replace: attrs.replace ?? "" };
288
306
  }
289
307
  if (attrs.blocks?.length > 0) {
290
- return {
291
- search: attrs.blocks[0].search,
292
- replace: attrs.blocks[0].replace,
293
- };
308
+ return { blocks: attrs.blocks };
294
309
  }
295
310
  return null;
296
311
  }
@@ -312,11 +327,32 @@ export default class Set {
312
327
  };
313
328
  }
314
329
  if (body && attrs.blocks?.length > 0) {
315
- const block = attrs.blocks[0];
316
- return Hedberg.replace(body, block.search, block.replace, {
317
- sed: block.sed,
318
- flags: block.flags,
319
- });
330
+ if (attrs.blocks.length === 1) {
331
+ const block = attrs.blocks[0];
332
+ return Hedberg.replace(body, block.search, block.replace, {
333
+ sed: block.sed,
334
+ flags: block.flags,
335
+ });
336
+ }
337
+ // Multi-block: apply sequentially, no per-hunk merge notation
338
+ let current = body;
339
+ let lastWarning = null;
340
+ for (const block of attrs.blocks) {
341
+ const result = Hedberg.replace(current, block.search, block.replace, {
342
+ sed: block.sed,
343
+ flags: block.flags,
344
+ });
345
+ if (result.error) return result;
346
+ if (result.warning) lastWarning = result.warning;
347
+ if (result.patch) current = result.patch;
348
+ }
349
+ return {
350
+ patch: current !== body ? current : null,
351
+ searchText: null,
352
+ replaceText: null,
353
+ warning: lastWarning,
354
+ error: null,
355
+ };
320
356
  }
321
357
  return {
322
358
  patch: null,
@@ -19,8 +19,8 @@ const LINES = [
19
19
  "SEARCH/REPLACE block: literal match and replace. Use when sed escaping is complex.",
20
20
  ],
21
21
  [
22
- 'Example: <set path="known://plan" stored summary="Migration plan for Q2"/>',
23
- "Fidelity + summary: archive an entry while preserving a description. Lifecycle endpoint.",
22
+ 'Example: <set path="known://project/milestones" fidelity="summary" summary="milestone,deadline,2026"/> ... <set path="prompt://3" fidelity="index"/>',
23
+ "Fidelity control: compress a known entry to keywords, demote a previous prompt to index-only. Both free context while keeping paths visible.",
24
24
  ],
25
25
 
26
26
  // --- Constraints
@@ -28,6 +28,10 @@ const LINES = [
28
28
  '* `fidelity="..."`: `archive`, `summary`, `index`, `full`',
29
29
  "Fidelity control. Archive removes from context but preserves for retrieval.",
30
30
  ],
31
+ [
32
+ '* `fidelity="summary"` HIDES the body — does NOT require reading or compressing content. Write any short keyword label you already know.',
33
+ "M-10 fix: model was reading files before compressing to summary, believing it needed semantic content. It does not. The body is preserved on disk; only context visibility changes.",
34
+ ],
31
35
  [
32
36
  '* `summary="..."` (<= 80 chars) persists across fidelity changes',
33
37
  "Model-authored descriptions survive demotion. No janitorial pass needed.",
@@ -1,4 +1,4 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
 
4
4
  export default class Telemetry {
@@ -85,17 +85,20 @@ export default class Telemetry {
85
85
  // assistant://N — the model's raw response
86
86
  await store.upsert(runId, turn, `assistant://${turn}`, content, 200, {
87
87
  loopId,
88
+ fidelity: "archive",
88
89
  });
89
90
 
90
91
  // system://N, user://N — assembled messages as audit
91
92
  if (systemMsg) {
92
93
  await store.upsert(runId, turn, `system://${turn}`, systemMsg, 200, {
93
94
  loopId,
95
+ fidelity: "archive",
94
96
  });
95
97
  }
96
98
  if (userMsg) {
97
99
  await store.upsert(runId, turn, `user://${turn}`, userMsg, 200, {
98
100
  loopId,
101
+ fidelity: "archive",
99
102
  });
100
103
  }
101
104
 
@@ -112,7 +115,7 @@ export default class Telemetry {
112
115
  model: result.model || null,
113
116
  }),
114
117
  200,
115
- { loopId },
118
+ { loopId, fidelity: "archive" },
116
119
  );
117
120
 
118
121
  // reasoning://N
@@ -123,7 +126,7 @@ export default class Telemetry {
123
126
  `reasoning://${turn}`,
124
127
  responseMessage.reasoning_content,
125
128
  200,
126
- { loopId },
129
+ { loopId, fidelity: "archive" },
127
130
  );
128
131
  }
129
132
 
@@ -131,6 +134,7 @@ export default class Telemetry {
131
134
  if (unparsed) {
132
135
  await store.upsert(runId, turn, `content://${turn}`, unparsed, 200, {
133
136
  loopId,
137
+ fidelity: "archive",
134
138
  });
135
139
  }
136
140
 
@@ -147,9 +151,12 @@ export default class Telemetry {
147
151
  usage.completion_tokens_details?.reasoning_tokens ||
148
152
  usage.output_tokens_details?.reasoning_tokens ||
149
153
  0;
154
+ // Use LLM's actual prompt_tokens as the ground-truth context size when available.
155
+ // This back-fills context_tokens so get_last_context_tokens reflects reality for the next turn.
156
+ const actualContextTokens = usage.prompt_tokens || assembledTokens || 0;
150
157
  await rummy.db.update_turn_stats.run({
151
158
  id: rummy.turnId,
152
- context_tokens: assembledTokens ?? 0,
159
+ context_tokens: actualContextTokens,
153
160
  reasoning_content: responseMessage?.reasoning_content || null,
154
161
  prompt_tokens: usage.prompt_tokens ?? 0,
155
162
  cached_tokens: cachedTokens ?? 0,
@@ -189,10 +196,8 @@ export default class Telemetry {
189
196
 
190
197
  #flush() {
191
198
  if (!this.#lastRunPath || this.#turnLog.length === 0) return;
192
- try {
193
- writeFileSync(this.#lastRunPath, `${this.#turnLog.join("\n")}\n`);
194
- } catch {
195
- // RUMMY_HOME may not exist yet
196
- }
199
+ writeFile(this.#lastRunPath, `${this.#turnLog.join("\n")}\n`).catch(
200
+ () => {},
201
+ );
197
202
  }
198
203
  }
@@ -20,4 +20,5 @@ The Rumsfeld mechanism. The model registers what it doesn't know before acting.
20
20
  Unknowns are sticky — they persist across turns until the model explicitly
21
21
  removes them with `<rm>`. The model investigates unknowns using `<get>`,
22
22
  `<env>`, or `<ask_user>`, then removes resolved ones. Server deduplicates
23
- on insert. Turn numbers shown on each unknown for temporal reasoning.
23
+ on insert. Each unknown renders with turn, fidelity, and tokens for
24
+ temporal reasoning and context management.
@@ -25,10 +25,11 @@ export default class Unknown {
25
25
  const entries = ctx.rows.filter((r) => r.category === "unknown");
26
26
  if (entries.length === 0) return content;
27
27
 
28
- const lines = entries.map(
29
- (u) =>
30
- `<unknown path="${u.path}" turn="${u.source_turn || u.turn}">${u.body}</unknown>`,
31
- );
28
+ const lines = entries.map((u) => {
29
+ const fidelity = u.fidelity ? ` fidelity="${u.fidelity}"` : "";
30
+ const tokens = u.tokens ? ` tokens="${u.tokens}"` : "";
31
+ return `<unknown path="${u.path}" turn="${u.source_turn || u.turn}"${fidelity}${tokens}>${u.body}</unknown>`;
32
+ });
32
33
  return `${content}\n\n<unknowns>\n${lines.join("\n")}\n</unknowns>`;
33
34
  }
34
35
  }