@possumtech/rummy 0.3.1 → 0.5.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 (63) hide show
  1. package/.env.example +12 -0
  2. package/FIDELITY_CONTRACT.md +172 -0
  3. package/README.md +5 -1
  4. package/SPEC.md +31 -17
  5. package/migrations/001_initial_schema.sql +3 -4
  6. package/package.json +1 -1
  7. package/src/agent/AgentLoop.js +51 -153
  8. package/src/agent/ContextAssembler.js +2 -0
  9. package/src/agent/KnownStore.js +16 -9
  10. package/src/agent/ResponseHealer.js +54 -1
  11. package/src/agent/TurnExecutor.js +125 -323
  12. package/src/agent/XmlParser.js +172 -42
  13. package/src/agent/known_queries.sql +1 -1
  14. package/src/agent/known_store.sql +29 -72
  15. package/src/agent/runs.sql +2 -2
  16. package/src/hooks/Hooks.js +1 -0
  17. package/src/hooks/PluginContext.js +8 -2
  18. package/src/hooks/RummyContext.js +6 -3
  19. package/src/hooks/ToolRegistry.js +29 -32
  20. package/src/plugins/ask_user/ask_user.js +2 -2
  21. package/src/plugins/ask_user/ask_userDoc.js +7 -10
  22. package/src/plugins/budget/README.md +28 -18
  23. package/src/plugins/budget/budget.js +80 -3
  24. package/src/plugins/budget/recovery.js +47 -0
  25. package/src/plugins/cp/cp.js +5 -5
  26. package/src/plugins/cp/cpDoc.js +1 -14
  27. package/src/plugins/engine/engine.sql +1 -1
  28. package/src/plugins/env/env.js +4 -4
  29. package/src/plugins/env/envDoc.js +4 -9
  30. package/src/plugins/file/file.js +2 -7
  31. package/src/plugins/get/get.js +32 -13
  32. package/src/plugins/get/getDoc.js +26 -44
  33. package/src/plugins/helpers.js +4 -4
  34. package/src/plugins/instructions/instructions.js +9 -7
  35. package/src/plugins/instructions/preamble.md +45 -26
  36. package/src/plugins/known/known.js +71 -15
  37. package/src/plugins/known/knownDoc.js +4 -20
  38. package/src/plugins/mv/mv.js +6 -6
  39. package/src/plugins/mv/mvDoc.js +4 -30
  40. package/src/plugins/policy/policy.js +47 -0
  41. package/src/plugins/previous/previous.js +10 -14
  42. package/src/plugins/progress/progress.js +29 -48
  43. package/src/plugins/prompt/prompt.js +18 -6
  44. package/src/plugins/rm/rm.js +4 -4
  45. package/src/plugins/rm/rmDoc.js +5 -14
  46. package/src/plugins/rpc/rpc.js +4 -2
  47. package/src/plugins/set/set.js +86 -91
  48. package/src/plugins/set/setDoc.js +28 -41
  49. package/src/plugins/sh/sh.js +4 -4
  50. package/src/plugins/sh/shDoc.js +4 -9
  51. package/src/plugins/skill/skill.js +2 -1
  52. package/src/plugins/summarize/summarize.js +9 -2
  53. package/src/plugins/summarize/summarizeDoc.js +10 -16
  54. package/src/plugins/telemetry/telemetry.js +36 -11
  55. package/src/plugins/think/think.js +13 -0
  56. package/src/plugins/think/thinkDoc.js +16 -0
  57. package/src/plugins/unknown/unknown.js +37 -9
  58. package/src/plugins/unknown/unknownDoc.js +7 -16
  59. package/src/plugins/update/update.js +9 -2
  60. package/src/plugins/update/updateDoc.js +12 -14
  61. package/src/server/ClientConnection.js +11 -1
  62. package/src/sql/functions/slugify.js +13 -1
  63. package/src/sql/v_model_context.sql +6 -6
@@ -4,7 +4,7 @@ import Hedberg, { generatePatch } from "../hedberg/hedberg.js";
4
4
  import { storePatternResult } from "../helpers.js";
5
5
  import docs from "./setDoc.js";
6
6
 
7
- const VALID_FIDELITY = { archive: 1, summary: 1, index: 1, full: 1 };
7
+ const VALID_FIDELITY = { archived: 1, demoted: 1, promoted: 1 };
8
8
 
9
9
  // biome-ignore lint/suspicious/noShadowRestrictedNames: tool name is "set"
10
10
  export default class Set {
@@ -14,8 +14,8 @@ export default class Set {
14
14
  this.#core = core;
15
15
  core.registerScheme();
16
16
  core.on("handler", this.handler.bind(this));
17
- core.on("full", this.full.bind(this));
18
- core.on("summary", this.summary.bind(this));
17
+ core.on("promoted", this.full.bind(this));
18
+ core.on("demoted", this.summary.bind(this));
19
19
  core.on("turn.proposing", this.#materializeRevisions.bind(this));
20
20
  core.filter("instructions.toolDocs", async (docsMap) => {
21
21
  docsMap.set = docs;
@@ -26,47 +26,18 @@ export default class Set {
26
26
  async handler(entry, rummy) {
27
27
  const { entries: store, sequence: turn, runId, loopId } = rummy;
28
28
  const attrs = entry.attributes;
29
-
30
- // Fidelity control: <set path="..." fidelity="archive"/>
31
29
  const fidelityAttr = VALID_FIDELITY[attrs.fidelity] ? attrs.fidelity : null;
32
- if (fidelityAttr && attrs.path) {
30
+ const rawSummary = typeof attrs.summary === "string" ? attrs.summary : null;
31
+ const summaryText = rawSummary ? rawSummary.slice(0, 80) : null;
32
+
33
+ // Pure fidelity/metadata change — no body content
34
+ if (!entry.body && fidelityAttr && attrs.path) {
33
35
  const target = attrs.path;
34
- const rawSummary =
35
- typeof attrs.summary === "string" ? attrs.summary : null;
36
- const summaryText = rawSummary ? rawSummary.slice(0, 80) : null;
37
36
  const matches = await store.getEntriesByPattern(
38
37
  runId,
39
38
  target,
40
39
  attrs.body,
41
40
  );
42
- if (entry.body) {
43
- // Write content directly at specified fidelity
44
- const entryAttrs = summaryText ? { summary: summaryText } : null;
45
- for (const match of matches) {
46
- await store.upsert(runId, turn, match.path, entry.body, 200, {
47
- fidelity: fidelityAttr,
48
- attributes: entryAttrs,
49
- loopId,
50
- });
51
- }
52
- if (matches.length === 0) {
53
- await store.upsert(runId, turn, target, entry.body, 200, {
54
- fidelity: fidelityAttr,
55
- attributes: entryAttrs,
56
- loopId,
57
- });
58
- }
59
- } else {
60
- // No body — change fidelity, attach summary if provided
61
- for (const match of matches) {
62
- await store.setFidelity(runId, match.path, fidelityAttr);
63
- if (summaryText) {
64
- await store.setAttributes(runId, match.path, {
65
- summary: summaryText,
66
- });
67
- }
68
- }
69
- }
70
41
  if (matches.length === 0) {
71
42
  await store.upsert(
72
43
  runId,
@@ -74,35 +45,35 @@ export default class Set {
74
45
  entry.resultPath,
75
46
  `${target} not found`,
76
47
  404,
77
- {
78
- fidelity: "archive",
79
- loopId,
80
- },
48
+ { fidelity: "archived", loopId },
81
49
  );
82
50
  return;
83
51
  }
84
- const label =
85
- fidelityAttr === "archive" ? "archived" : `set to ${fidelityAttr}`;
52
+ for (const match of matches) {
53
+ await store.setFidelity(runId, match.path, fidelityAttr);
54
+ if (summaryText) {
55
+ await store.setAttributes(runId, match.path, {
56
+ summary: summaryText,
57
+ });
58
+ }
59
+ }
60
+ const label = `set to ${fidelityAttr}`;
86
61
  await store.upsert(
87
62
  runId,
88
63
  turn,
89
64
  entry.resultPath,
90
65
  `${matches.map((m) => m.path).join(", ")} ${label}`,
91
66
  200,
92
- {
93
- fidelity: "archive",
94
- loopId,
95
- },
67
+ { fidelity: "archived", loopId },
96
68
  );
97
69
  return;
98
70
  }
99
71
 
72
+ // Edit: sed patterns or SEARCH/REPLACE blocks
100
73
  if (attrs.blocks || attrs.search != null) {
101
74
  await this.#processEdit(rummy, entry, attrs);
102
- return;
103
- }
104
-
105
- if (attrs.preview && attrs.path) {
75
+ } else if (attrs.preview && attrs.path) {
76
+ // Preview
106
77
  const matches = await store.getEntriesByPattern(
107
78
  runId,
108
79
  attrs.path,
@@ -119,43 +90,68 @@ export default class Set {
119
90
  { preview: true, loopId },
120
91
  );
121
92
  return;
122
- }
93
+ } else {
94
+ // Write content
95
+ const target = attrs.path;
96
+ if (!target) return;
123
97
 
124
- const target = attrs.path;
125
- if (!target) return;
98
+ const scheme = KnownStore.scheme(target);
99
+ if (scheme === null) {
100
+ // File write — diff against existing content
101
+ const existing = await store.getBody(runId, target);
102
+ const oldContent = existing ?? "";
103
+ const newContent = entry.body || "";
104
+ const udiff = generatePatch(target, oldContent, newContent);
105
+ const merge = oldContent
106
+ ? `<<<<<<< SEARCH\n${oldContent}\n=======\n${newContent}\n>>>>>>> REPLACE`
107
+ : `<<<<<<< SEARCH\n=======\n${newContent}\n>>>>>>> REPLACE`;
108
+ await store.upsert(runId, turn, entry.resultPath, oldContent, 202, {
109
+ attributes: { file: target, patch: udiff, merge },
110
+ loopId,
111
+ });
112
+ } else if (attrs.filter || target.includes("*")) {
113
+ // Pattern update
114
+ const matches = await store.getEntriesByPattern(
115
+ runId,
116
+ target,
117
+ attrs.filter,
118
+ );
119
+ await store.updateBodyByPattern(
120
+ runId,
121
+ target,
122
+ attrs.filter || null,
123
+ entry.body,
124
+ );
125
+ await storePatternResult(
126
+ store,
127
+ runId,
128
+ turn,
129
+ "set",
130
+ target,
131
+ attrs.filter,
132
+ matches,
133
+ { loopId },
134
+ );
135
+ } else {
136
+ // Direct scheme write
137
+ await store.upsert(runId, turn, target, entry.body, 200, {
138
+ fidelity: fidelityAttr || "promoted",
139
+ attributes: summaryText ? { summary: summaryText } : null,
140
+ loopId,
141
+ });
142
+ }
143
+ }
126
144
 
127
- const scheme = KnownStore.scheme(target);
128
- if (scheme === null) {
129
- const udiff = generatePatch(target, "", entry.body || "");
130
- const merge = `<<<<<<< SEARCH\n=======\n${entry.body || ""}\n>>>>>>> REPLACE`;
131
- await store.upsert(runId, turn, entry.resultPath, "", 202, {
132
- attributes: { file: target, patch: udiff, merge },
133
- loopId,
134
- });
135
- } else if (attrs.filter || target.includes("*")) {
136
- const matches = await store.getEntriesByPattern(
137
- runId,
138
- target,
139
- attrs.filter,
140
- );
141
- await store.updateBodyByPattern(
142
- runId,
143
- target,
144
- attrs.filter || null,
145
- entry.body,
146
- );
147
- await storePatternResult(
148
- store,
149
- runId,
150
- turn,
151
- "set",
152
- target,
153
- attrs.filter,
154
- matches,
155
- { loopId },
156
- );
157
- } else {
158
- await store.upsert(runId, turn, target, entry.body, 200, { loopId });
145
+ // Apply fidelity after all write operations
146
+ if (fidelityAttr && attrs.path) {
147
+ const target = attrs.path;
148
+ const scheme = KnownStore.scheme(target);
149
+ if (scheme !== null) {
150
+ await store.setFidelity(runId, target, fidelityAttr);
151
+ }
152
+ if (summaryText) {
153
+ await store.setAttributes(runId, target, { summary: summaryText });
154
+ }
159
155
  }
160
156
  }
161
157
 
@@ -171,8 +167,8 @@ export default class Set {
171
167
  return `# set ${file}${tokens}\n${attrs.merge}`;
172
168
  }
173
169
 
174
- summary(entry) {
175
- return entry.attributes.merge || "";
170
+ summary() {
171
+ return "";
176
172
  }
177
173
 
178
174
  async #processEdit(rummy, entry, attrs) {
@@ -215,7 +211,7 @@ export default class Set {
215
211
  searchText != null
216
212
  ? `<<<<<<< SEARCH\n${searchText}\n=======\n${replaceText}\n>>>>>>> REPLACE`
217
213
  : null;
218
- const beforeTokens = match.tokens_full || 0;
214
+ const beforeTokens = match.tokens || 0;
219
215
  const afterTokens = patch ? countTokens(patch) : beforeTokens;
220
216
 
221
217
  await store.upsert(runId, turn, resultPath, match.body, status, {
@@ -282,7 +278,7 @@ export default class Set {
282
278
  ? generatePatch(filePath, original, current)
283
279
  : null;
284
280
  const merge = mergeBlocks.length > 0 ? mergeBlocks.join("\n") : null;
285
- const beforeTokens = fileEntry[0].tokens_full || 0;
281
+ const beforeTokens = fileEntry[0].tokens || 0;
286
282
  const afterTokens = current ? countTokens(current) : beforeTokens;
287
283
 
288
284
  await store.upsert(runId, turn, entry.path, original, state, {
@@ -334,7 +330,6 @@ export default class Set {
334
330
  flags: block.flags,
335
331
  });
336
332
  }
337
- // Multi-block: apply sequentially, no per-hunk merge notation
338
333
  let current = body;
339
334
  let lastWarning = null;
340
335
  for (const block of attrs.blocks) {
@@ -2,48 +2,35 @@
2
2
  // Text goes to the model. Rationale stays in source.
3
3
  // Changing ANY line requires reading ALL rationales first.
4
4
  const LINES = [
5
- // --- Syntax: path attr + body = edit content
6
- ['## <set path="[path/to/file]">[edit]</set> - Edit a file or entry'],
7
-
8
- // --- Examples: sed, SEARCH/REPLACE, fidelity control
9
- [
10
- 'Example: <set path="src/config.js">s/port = 3000/port = 8080/g</set>',
11
- "Sed syntax: most common edit pattern. Shows s/old/new/ with g flag.",
12
- ],
13
- [
14
- `Example: <set path="src/app.js"><<<<<<< SEARCH
15
- // TODO: add error handling
5
+ [
6
+ '## <set path="[path/to/file]">[content or edit]</set> - Create, edit, or update a file or entry',
7
+ ],
8
+ [
9
+ 'Example: <set path="known://project/milestones" fidelity="demoted" summary="milestone,deadline,2026"/>',
10
+ "Fidelity control first most unique capability of set.",
11
+ ],
12
+ [
13
+ `Example: <set path="src/app.js">
14
+ <<<<<<< SEARCH
15
+ old text
16
16
  =======
17
- // error handler configured
18
- >>>>>>> REPLACE</set>`,
19
- "SEARCH/REPLACE block: literal match and replace. Use when sed escaping is complex.",
20
- ],
21
- [
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
- ],
25
-
26
- // --- Constraints
27
- [
28
- '* `fidelity="..."`: `archive`, `summary`, `index`, `full`',
29
- "Fidelity control. Archive removes from context but preserves for retrieval.",
30
- ],
31
- [
32
- '* `fidelity="summary"` HIDES the bodydoes 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
- ],
35
- [
36
- '* `summary="..."` (<= 80 chars) persists across fidelity changes',
37
- "Model-authored descriptions survive demotion. No janitorial pass needed.",
38
- ],
39
- [
40
- "* YOU MUST NOT use <sh/> or <env/> to read, create, or edit files",
41
- "Forces file operations through set/get. Prevents untracked mutations.",
42
- ],
43
- [
44
- "* Editing: s/old/new/ sed patterns and literal SEARCH/REPLACE blocks",
45
- "Both syntaxes supported. Hedberg normalizes either form.",
46
- ],
17
+ new text
18
+ >>>>>>> REPLACE
19
+ </set>`,
20
+ "SEARCH/REPLACE block — primary edit pattern for existing files.",
21
+ ],
22
+ [
23
+ `Example: <set path="src/config.js">s/port = 3000/port = 8080/g;s/We're almost done/We're done./g;</set>`,
24
+ "Sed syntax: chained s/old/new/ patterns with semicolons.",
25
+ ],
26
+ [
27
+ 'Example: <set path="example.md">Full file content here</set>',
28
+ "Create: body contents are entire file.",
29
+ ],
30
+ [
31
+ "* YOU MUST NOT use <sh></sh> or <env></env> to list, create, read, or edit files — use <get></get> and <set></set>",
32
+ "Reinforces at the decision point model reading setDoc for file ops sees the prohibition here, not just buried in shDoc/envDoc which it may not be reading.",
33
+ ],
47
34
  ];
48
35
 
49
36
  export default LINES.map(([text]) => text).join("\n");
@@ -7,8 +7,8 @@ export default class Sh {
7
7
  this.#core = core;
8
8
  core.registerScheme();
9
9
  core.on("handler", this.handler.bind(this));
10
- core.on("full", this.full.bind(this));
11
- core.on("summary", this.summary.bind(this));
10
+ core.on("promoted", this.full.bind(this));
11
+ core.on("demoted", this.summary.bind(this));
12
12
  core.filter("instructions.toolDocs", async (docsMap) => {
13
13
  docsMap.sh = docs;
14
14
  return docsMap;
@@ -27,7 +27,7 @@ export default class Sh {
27
27
  return `# sh ${entry.attributes.command || ""}\n${entry.body}`;
28
28
  }
29
29
 
30
- summary(entry) {
31
- return entry.attributes.command || "";
30
+ summary() {
31
+ return "";
32
32
  }
33
33
  }
@@ -2,26 +2,21 @@
2
2
  // Text goes to the model. Rationale stays in source.
3
3
  // Changing ANY line requires reading ALL rationales first.
4
4
  const LINES = [
5
- // --- Syntax
6
5
  ["## <sh>[command]</sh> - Run a shell command with side effects"],
7
-
8
- // --- Examples: install and test — real mutations
9
6
  [
10
7
  "Example: <sh>npm install express</sh>",
11
- "Package install. Shows a real side-effect command.",
8
+ "Package install. Real side-effect command.",
12
9
  ],
13
10
  [
14
11
  "Example: <sh>npm test</sh>",
15
12
  "Test execution. Another common side-effect action.",
16
13
  ],
17
-
18
- // --- Constraints
19
14
  [
20
- "* YOU MUST NOT use <sh/> to read, create, or edit files — use <get/> and <set/>",
21
- "Forces file operations through the entry system. Prevents untracked mutations.",
15
+ "* YOU MUST NOT use <sh></sh> to read, create, or edit files — use <get></get> and <set></set>",
16
+ "Forces file operations through the entry system.",
22
17
  ],
23
18
  [
24
- "* YOU MUST use <env/> for commands without side effects",
19
+ "* YOU MUST use <env></env> for commands without side effects",
25
20
  "Reinforces the env/sh split. Read = env, mutate = sh.",
26
21
  ],
27
22
  ];
@@ -10,7 +10,8 @@ export default class Skill {
10
10
  name: "skill",
11
11
  category: "data",
12
12
  });
13
- core.hooks.tools.onView("skill", (entry) => entry.body);
13
+ core.hooks.tools.onView("skill", (entry) => entry.body, "promoted");
14
+ core.hooks.tools.onView("skill", () => "", "demoted");
14
15
 
15
16
  const r = core.hooks.rpc.registry;
16
17
 
@@ -7,14 +7,21 @@ export default class Summarize {
7
7
  this.#core = core;
8
8
  core.ensureTool();
9
9
  core.registerScheme({ category: "logging" });
10
- core.on("full", this.full.bind(this));
11
- core.on("summary", this.summary.bind(this));
10
+ core.on("handler", this.handler.bind(this));
11
+ core.on("promoted", this.full.bind(this));
12
+ core.on("demoted", this.summary.bind(this));
12
13
  core.filter("instructions.toolDocs", async (docsMap) => {
13
14
  docsMap.summarize = docs;
14
15
  return docsMap;
15
16
  });
16
17
  }
17
18
 
19
+ async handler(entry, rummy) {
20
+ const { entries: store, sequence: turn, runId, loopId } = rummy;
21
+ const statusPath = await store.slugPath(runId, "summarize", entry.body);
22
+ await store.upsert(runId, turn, statusPath, entry.body, 200, { loopId });
23
+ }
24
+
18
25
  full(entry) {
19
26
  return `# summarize\n${entry.body}`;
20
27
  }
@@ -2,31 +2,25 @@
2
2
  // Text goes to the model. Rationale stays in source.
3
3
  // Changing ANY line requires reading ALL rationales first.
4
4
  const LINES = [
5
- // --- Syntax
6
- ["## <summarize>[answer or summary]</summarize> - Signal completion"],
7
-
8
- // --- Examples: answer and task completion
9
5
  [
10
- "Example: <summarize>The port is 8080</summarize>",
11
- "Direct answer. Shows summarize as the vehicle for delivering answers.",
6
+ "## <summarize>[answer or final summary]</summarize> - Terminate the run with the final answer",
7
+ "Header teaches consequence (run ends), not just label. Model now knows emitting this stops everything.",
12
8
  ],
13
9
  [
14
- "Example: <summarize>Installed express, updated config</summarize>",
15
- "Task summary. Shows summarize for action completion.",
10
+ "Example: <summarize>The port is 8080</summarize>",
11
+ "Direct answer. Summarize delivers answers.",
16
12
  ],
17
-
18
- // --- Constraints: RFC-style MUST/MUST NOT
19
13
  [
20
- "* YOU MUST use <summarize> when done describes the final state",
21
- "Completion signal. Without this, the loop continues indefinitely.",
14
+ "* Urgent: <summarize></summarize> ENDS THE RUN. After this, no more turns happen.",
15
+ "Direct statement of terminal behavior — the model treating summarize as a generic 'done message' was causing zombie-update loops (model unsure if truly finished, defaulted to update).",
22
16
  ],
23
17
  [
24
- "* YOU MUST NOT use <summarize> if still workinguse <update/> instead",
25
- "Mutual exclusion with update. Prevents premature completion.",
18
+ "* Urgent: YOU MUST NOT include <summarize></summarize> with other tools. Termination is a deliberate, isolated act not a side effect of a turn doing other things.",
19
+ "Prior 'they might fail' rationale was argued around (when set on known:// succeeds, model rationalized bundling). Reframing as architectural ('termination is deliberate') removes the argument surface.",
26
20
  ],
27
21
  [
28
- "* YOU MUST keep <summarize> to <= 80 characters",
29
- "Length cap. Matches the summary attribute constraint. Prevents verbose output.",
22
+ "* YOU MUST keep <summarize></summarize> to <= 80 characters",
23
+ "Length cap.",
30
24
  ],
31
25
  ];
32
26
 
@@ -1,17 +1,23 @@
1
- import { writeFile } from "node:fs/promises";
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
 
4
4
  export default class Telemetry {
5
5
  #core;
6
6
  #starts = new Map();
7
7
  #lastRunPath = null;
8
+ #turnsDir = null;
8
9
  #turnLog = [];
10
+ #currentRunAlias = null;
11
+ #currentTurn = null;
9
12
 
10
13
  constructor(core) {
11
14
  this.#core = core;
12
15
 
13
16
  const home = process.env.RUMMY_HOME;
14
- if (home) this.#lastRunPath = join(home, "last_run.txt");
17
+ if (home) {
18
+ this.#lastRunPath = join(home, "last_run.txt");
19
+ this.#turnsDir = join(home, "turns");
20
+ }
15
21
 
16
22
  core.on("rpc.started", this.#onRpcStarted.bind(this));
17
23
  core.on("rpc.completed", this.#onRpcCompleted.bind(this));
@@ -85,20 +91,20 @@ export default class Telemetry {
85
91
  // assistant://N — the model's raw response
86
92
  await store.upsert(runId, turn, `assistant://${turn}`, content, 200, {
87
93
  loopId,
88
- fidelity: "archive",
94
+ fidelity: "archived",
89
95
  });
90
96
 
91
97
  // system://N, user://N — assembled messages as audit
92
98
  if (systemMsg) {
93
99
  await store.upsert(runId, turn, `system://${turn}`, systemMsg, 200, {
94
100
  loopId,
95
- fidelity: "archive",
101
+ fidelity: "archived",
96
102
  });
97
103
  }
98
104
  if (userMsg) {
99
105
  await store.upsert(runId, turn, `user://${turn}`, userMsg, 200, {
100
106
  loopId,
101
- fidelity: "archive",
107
+ fidelity: "archived",
102
108
  });
103
109
  }
104
110
 
@@ -115,7 +121,7 @@ export default class Telemetry {
115
121
  model: result.model || null,
116
122
  }),
117
123
  200,
118
- { loopId, fidelity: "archive" },
124
+ { loopId, fidelity: "archived" },
119
125
  );
120
126
 
121
127
  // reasoning://N
@@ -126,15 +132,18 @@ export default class Telemetry {
126
132
  `reasoning://${turn}`,
127
133
  responseMessage.reasoning_content,
128
134
  200,
129
- { loopId, fidelity: "archive" },
135
+ { loopId, fidelity: "archived" },
130
136
  );
131
137
  }
132
138
 
133
- // content://N — unparsed text
139
+ // content://N — unparsed text. 400 Bad Request because anything in
140
+ // unparsed is text the parser couldn't dispatch (malformed XML, native
141
+ // tool call attempts, reasoning bleed). Visible to the model so it
142
+ // sees the rejection on its next turn and can correct.
134
143
  if (unparsed) {
135
- await store.upsert(runId, turn, `content://${turn}`, unparsed, 200, {
144
+ await store.upsert(runId, turn, `content://${turn}`, unparsed, 400, {
136
145
  loopId,
137
- fidelity: "archive",
146
+ fidelity: "promoted",
138
147
  });
139
148
  }
140
149
 
@@ -168,8 +177,10 @@ export default class Telemetry {
168
177
  }
169
178
 
170
179
  async #logMessages(messages, context) {
180
+ this.#currentRunAlias = context.runAlias || `run_${context.runId}`;
181
+ this.#currentTurn = context.turn ?? null;
171
182
  this.#turnLog.push(
172
- `\n${"=".repeat(60)}\nTURN — model=${context.model} run=${context.runId}\n${"=".repeat(60)}`,
183
+ `\n${"=".repeat(60)}\nTURN ${this.#currentTurn ?? "?"} — model=${context.model} run=${this.#currentRunAlias}\n${"=".repeat(60)}`,
173
184
  );
174
185
  for (const msg of messages) {
175
186
  const label = msg.role.toUpperCase();
@@ -191,6 +202,7 @@ export default class Telemetry {
191
202
  const usage = response.usage || {};
192
203
  this.#turnLog.push(`\n--- USAGE ---\n${JSON.stringify(usage)}`);
193
204
  this.#flush();
205
+ this.#writeTurnFile();
194
206
  return response;
195
207
  }
196
208
 
@@ -200,4 +212,17 @@ export default class Telemetry {
200
212
  () => {},
201
213
  );
202
214
  }
215
+
216
+ async #writeTurnFile() {
217
+ if (!this.#turnsDir || !this.#currentRunAlias || this.#currentTurn == null)
218
+ return;
219
+ const runDir = join(this.#turnsDir, this.#currentRunAlias);
220
+ try {
221
+ await mkdir(runDir, { recursive: true });
222
+ const fileName = `turn_${String(this.#currentTurn).padStart(3, "0")}.txt`;
223
+ await writeFile(join(runDir, fileName), `${this.#turnLog.join("\n")}\n`);
224
+ } catch {
225
+ // best effort — diagnostic feature, don't fail the turn
226
+ }
227
+ }
203
228
  }
@@ -1,5 +1,18 @@
1
+ import docs from "./thinkDoc.js";
2
+
3
+ const THINK_ENABLED = process.env.RUMMY_THINK;
4
+ if (THINK_ENABLED === undefined)
5
+ throw new Error("RUMMY_THINK must be set (1 or 0)");
6
+
1
7
  export default class Think {
2
8
  constructor(core) {
3
9
  core.registerScheme({ modelVisible: 0, category: "logging" });
10
+ if (THINK_ENABLED === "1") {
11
+ core.ensureTool();
12
+ core.filter("instructions.toolDocs", async (docsMap) => {
13
+ docsMap.think = docs;
14
+ return docsMap;
15
+ });
16
+ }
4
17
  }
5
18
  }
@@ -0,0 +1,16 @@
1
+ // Tool doc for <think/>. Each entry: [text, rationale].
2
+ // Text goes to the model. Rationale stays in source.
3
+ // Changing ANY line requires reading ALL rationales first.
4
+ const LINES = [
5
+ ["## <think>[reasoning]</think> - Think before acting"],
6
+ [
7
+ "* Use <think></think> before any other tools to plan your approach",
8
+ "Positioning: think first, then act. Prevents degenerate tool-call storms.",
9
+ ],
10
+ [
11
+ "* Reasoning inside <think></think> is private — it does not appear in your context",
12
+ "Frees the model to reason without consuming context budget.",
13
+ ],
14
+ ];
15
+
16
+ export default LINES.map(([text]) => text).join("\n");