@possumtech/rummy 0.5.0 → 2.0.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 (157) hide show
  1. package/.env.example +42 -5
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +934 -373
  5. package/bin/demo.js +166 -0
  6. package/bin/rummy.js +9 -3
  7. package/biome/no-fallbacks.grit +50 -0
  8. package/lang/en.json +2 -2
  9. package/migrations/001_initial_schema.sql +88 -37
  10. package/package.json +13 -11
  11. package/scriptify/ask_run.js +77 -0
  12. package/service.js +50 -9
  13. package/src/agent/AgentLoop.js +476 -335
  14. package/src/agent/ContextAssembler.js +4 -4
  15. package/src/agent/Entries.js +676 -0
  16. package/src/agent/ProjectAgent.js +30 -18
  17. package/src/agent/TurnExecutor.js +232 -421
  18. package/src/agent/XmlParser.js +99 -33
  19. package/src/agent/budget.js +56 -0
  20. package/src/agent/errors.js +22 -0
  21. package/src/agent/httpStatus.js +39 -0
  22. package/src/agent/known_checks.sql +8 -4
  23. package/src/agent/known_queries.sql +9 -13
  24. package/src/agent/known_store.sql +280 -125
  25. package/src/agent/materializeContext.js +104 -0
  26. package/src/agent/runs.sql +29 -7
  27. package/src/agent/schemes.sql +14 -3
  28. package/src/agent/tokens.js +6 -0
  29. package/src/agent/turns.sql +9 -9
  30. package/src/hooks/HookRegistry.js +6 -5
  31. package/src/hooks/Hooks.js +44 -3
  32. package/src/hooks/PluginContext.js +29 -21
  33. package/src/{server → hooks}/RpcRegistry.js +2 -1
  34. package/src/hooks/RummyContext.js +139 -35
  35. package/src/hooks/ToolRegistry.js +21 -16
  36. package/src/llm/LlmProvider.js +66 -89
  37. package/src/llm/errors.js +21 -0
  38. package/src/llm/retry.js +63 -0
  39. package/src/plugins/ask_user/README.md +1 -1
  40. package/src/plugins/ask_user/ask_user.js +37 -12
  41. package/src/plugins/ask_user/ask_userDoc.js +2 -25
  42. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  43. package/src/plugins/budget/README.md +27 -25
  44. package/src/plugins/budget/budget.js +306 -88
  45. package/src/plugins/cp/README.md +2 -2
  46. package/src/plugins/cp/cp.js +29 -11
  47. package/src/plugins/cp/cpDoc.js +2 -15
  48. package/src/plugins/cp/cpDoc.md +7 -0
  49. package/src/plugins/engine/README.md +2 -2
  50. package/src/plugins/engine/engine.sql +4 -4
  51. package/src/plugins/engine/turn_context.sql +10 -10
  52. package/src/plugins/env/README.md +20 -5
  53. package/src/plugins/env/env.js +45 -6
  54. package/src/plugins/env/envDoc.js +2 -23
  55. package/src/plugins/env/envDoc.md +13 -0
  56. package/src/plugins/error/README.md +16 -0
  57. package/src/plugins/error/error.js +151 -0
  58. package/src/plugins/file/README.md +6 -6
  59. package/src/plugins/file/file.js +15 -2
  60. package/src/plugins/get/README.md +1 -1
  61. package/src/plugins/get/get.js +103 -48
  62. package/src/plugins/get/getDoc.js +2 -32
  63. package/src/plugins/get/getDoc.md +36 -0
  64. package/src/plugins/hedberg/README.md +1 -2
  65. package/src/plugins/hedberg/hedberg.js +8 -4
  66. package/src/plugins/hedberg/matcher.js +16 -17
  67. package/src/plugins/hedberg/normalize.js +0 -48
  68. package/src/plugins/helpers.js +42 -2
  69. package/src/plugins/index.js +146 -123
  70. package/src/plugins/instructions/README.md +35 -9
  71. package/src/plugins/instructions/instructions.js +244 -9
  72. package/src/plugins/instructions/instructions.md +33 -0
  73. package/src/plugins/instructions/instructions_104.md +7 -0
  74. package/src/plugins/instructions/instructions_105.md +38 -0
  75. package/src/plugins/instructions/instructions_106.md +21 -0
  76. package/src/plugins/instructions/instructions_107.md +10 -0
  77. package/src/plugins/instructions/instructions_108.md +0 -0
  78. package/src/plugins/instructions/protocol.js +12 -0
  79. package/src/plugins/known/README.md +2 -2
  80. package/src/plugins/known/known.js +68 -36
  81. package/src/plugins/known/knownDoc.js +2 -17
  82. package/src/plugins/known/knownDoc.md +8 -0
  83. package/src/plugins/log/README.md +48 -0
  84. package/src/plugins/log/log.js +129 -0
  85. package/src/plugins/mv/README.md +2 -2
  86. package/src/plugins/mv/mv.js +55 -22
  87. package/src/plugins/mv/mvDoc.js +2 -18
  88. package/src/plugins/mv/mvDoc.md +10 -0
  89. package/src/plugins/ollama/README.md +15 -0
  90. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  91. package/src/plugins/openai/README.md +17 -0
  92. package/src/plugins/openai/openai.js +120 -0
  93. package/src/plugins/openrouter/README.md +27 -0
  94. package/src/plugins/openrouter/openrouter.js +121 -0
  95. package/src/plugins/persona/README.md +20 -0
  96. package/src/plugins/persona/persona.js +9 -16
  97. package/src/plugins/policy/README.md +21 -0
  98. package/src/plugins/policy/policy.js +29 -14
  99. package/src/plugins/prompt/README.md +1 -1
  100. package/src/plugins/prompt/prompt.js +64 -16
  101. package/src/plugins/rm/README.md +1 -1
  102. package/src/plugins/rm/rm.js +56 -12
  103. package/src/plugins/rm/rmDoc.js +2 -20
  104. package/src/plugins/rm/rmDoc.md +13 -0
  105. package/src/plugins/rpc/README.md +2 -2
  106. package/src/plugins/rpc/rpc.js +525 -296
  107. package/src/plugins/set/README.md +1 -1
  108. package/src/plugins/set/set.js +318 -75
  109. package/src/plugins/set/setDoc.js +2 -35
  110. package/src/plugins/set/setDoc.md +22 -0
  111. package/src/plugins/sh/README.md +28 -5
  112. package/src/plugins/sh/sh.js +50 -6
  113. package/src/plugins/sh/shDoc.js +2 -23
  114. package/src/plugins/sh/shDoc.md +13 -0
  115. package/src/plugins/skill/README.md +23 -0
  116. package/src/plugins/skill/skill.js +14 -18
  117. package/src/plugins/stream/README.md +101 -0
  118. package/src/plugins/stream/stream.js +290 -0
  119. package/src/plugins/telemetry/README.md +1 -1
  120. package/src/plugins/telemetry/telemetry.js +129 -80
  121. package/src/plugins/think/README.md +1 -1
  122. package/src/plugins/think/think.js +12 -0
  123. package/src/plugins/think/thinkDoc.js +2 -15
  124. package/src/plugins/think/thinkDoc.md +7 -0
  125. package/src/plugins/unknown/README.md +3 -3
  126. package/src/plugins/unknown/unknown.js +47 -19
  127. package/src/plugins/unknown/unknownDoc.js +2 -21
  128. package/src/plugins/unknown/unknownDoc.md +11 -0
  129. package/src/plugins/update/README.md +1 -1
  130. package/src/plugins/update/update.js +83 -5
  131. package/src/plugins/update/updateDoc.js +2 -30
  132. package/src/plugins/update/updateDoc.md +8 -0
  133. package/src/plugins/xai/README.md +23 -0
  134. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  135. package/src/plugins/yolo/yolo.js +192 -0
  136. package/src/server/ClientConnection.js +64 -37
  137. package/src/server/SocketServer.js +23 -10
  138. package/src/server/protocol.js +11 -0
  139. package/src/sql/v_model_context.sql +27 -31
  140. package/src/sql/v_run_log.sql +9 -14
  141. package/EXCEPTIONS.md +0 -46
  142. package/FIDELITY_CONTRACT.md +0 -172
  143. package/src/agent/KnownStore.js +0 -337
  144. package/src/agent/ResponseHealer.js +0 -241
  145. package/src/llm/OpenAiClient.js +0 -100
  146. package/src/llm/OpenRouterClient.js +0 -100
  147. package/src/plugins/budget/recovery.js +0 -47
  148. package/src/plugins/instructions/preamble.md +0 -45
  149. package/src/plugins/performed/README.md +0 -15
  150. package/src/plugins/performed/performed.js +0 -45
  151. package/src/plugins/previous/README.md +0 -16
  152. package/src/plugins/previous/previous.js +0 -56
  153. package/src/plugins/progress/README.md +0 -16
  154. package/src/plugins/progress/progress.js +0 -43
  155. package/src/plugins/summarize/README.md +0 -19
  156. package/src/plugins/summarize/summarize.js +0 -32
  157. package/src/plugins/summarize/summarizeDoc.js +0 -27
@@ -30,14 +30,15 @@ export default class Telemetry {
30
30
 
31
31
  async #onRpcStarted({ method, id, params }) {
32
32
  this.#starts.set(id, Date.now());
33
- const summary =
34
- method === "ask" || method === "act"
35
- ? `prompt="${(params?.prompt || "").slice(0, 60)}"`
36
- : method === "run/abort"
37
- ? `run=${params?.run}`
38
- : method === "run/resolve"
39
- ? `run=${params?.run} action=${params?.resolution?.action}`
40
- : "";
33
+ let summary = "";
34
+ if (method === "ask" || method === "act") {
35
+ const prompt = params?.prompt ? params.prompt : "";
36
+ summary = `prompt="${prompt.slice(0, 60)}"`;
37
+ } else if (method === "run/abort") {
38
+ summary = `run=${params?.run}`;
39
+ } else if (method === "run/resolve") {
40
+ summary = `run=${params?.run} action=${params?.resolution?.action}`;
41
+ }
41
42
  console.log(`[RPC] → ${method}(${id})${summary ? ` ${summary}` : ""}`);
42
43
 
43
44
  if (method === "ask" || method === "act") {
@@ -50,11 +51,13 @@ export default class Telemetry {
50
51
  ? `${((Date.now() - this.#starts.get(id)) / 1000).toFixed(1)}s`
51
52
  : "";
52
53
  this.#starts.delete(id);
53
- const summary = result?.run
54
- ? `run=${result.run} status=${result.status || "ok"}`
55
- : result?.status
56
- ? `status=${result.status}`
57
- : "";
54
+ let summary = "";
55
+ if (result?.run) {
56
+ const status = result.status ? result.status : "ok";
57
+ summary = `run=${result.run} status=${status}`;
58
+ } else if (result?.status) {
59
+ summary = `status=${result.status}`;
60
+ }
58
61
  console.log(
59
62
  `[RPC] ← ${method}(${id}) ${elapsed}${summary ? ` ${summary}` : ""}`,
60
63
  );
@@ -65,7 +68,8 @@ export default class Telemetry {
65
68
  ? `${((Date.now() - this.#starts.get(id)) / 1000).toFixed(1)}s`
66
69
  : "";
67
70
  this.#starts.delete(id);
68
- console.error(`[RPC] (${id}) ${elapsed} ${error?.message || error}`);
71
+ const detail = error?.message ? error.message : error;
72
+ console.error(`[RPC] ✗ (${id}) ${elapsed} ${detail}`);
69
73
  }
70
74
 
71
75
  async #onStepCompleted(payload) {
@@ -87,53 +91,74 @@ export default class Telemetry {
87
91
  userMsg,
88
92
  }) {
89
93
  const { entries: store, runId, loopId } = rummy;
94
+ // Audit schemes are system-only writes (see initPlugins).
95
+ const systemOpts = { loopId, visibility: "archived", writer: "system" };
90
96
 
91
97
  // assistant://N — the model's raw response
92
- await store.upsert(runId, turn, `assistant://${turn}`, content, 200, {
93
- loopId,
94
- fidelity: "archived",
98
+ await store.set({
99
+ runId,
100
+ turn,
101
+ path: `assistant://${turn}`,
102
+ body: content,
103
+ state: "resolved",
104
+ ...systemOpts,
95
105
  });
96
106
 
97
107
  // system://N, user://N — assembled messages as audit
98
108
  if (systemMsg) {
99
- await store.upsert(runId, turn, `system://${turn}`, systemMsg, 200, {
100
- loopId,
101
- fidelity: "archived",
109
+ await store.set({
110
+ runId,
111
+ turn,
112
+ path: `system://${turn}`,
113
+ body: systemMsg,
114
+ state: "resolved",
115
+ ...systemOpts,
102
116
  });
103
117
  }
104
118
  if (userMsg) {
105
- await store.upsert(runId, turn, `user://${turn}`, userMsg, 200, {
106
- loopId,
107
- fidelity: "archived",
119
+ await store.set({
120
+ runId,
121
+ turn,
122
+ path: `user://${turn}`,
123
+ body: userMsg,
124
+ state: "resolved",
125
+ ...systemOpts,
108
126
  });
109
127
  }
110
128
 
111
129
  // model://N — raw API response diagnostics
112
- await store.upsert(
130
+ await store.set({
113
131
  runId,
114
132
  turn,
115
- `model://${turn}`,
116
- JSON.stringify({
133
+ path: `model://${turn}`,
134
+ body: JSON.stringify({
117
135
  keys: responseMessage ? Object.keys(responseMessage) : [],
118
- reasoning_content: responseMessage?.reasoning_content || null,
136
+ reasoning_content: responseMessage?.reasoning_content
137
+ ? responseMessage.reasoning_content
138
+ : null,
119
139
  content: content.slice(0, 4096),
120
- usage: result.usage || null,
121
- model: result.model || null,
140
+ usage: result.usage ? result.usage : null,
141
+ model: result.model ? result.model : null,
122
142
  }),
123
- 200,
124
- { loopId, fidelity: "archived" },
125
- );
143
+ state: "resolved",
144
+ ...systemOpts,
145
+ });
126
146
 
127
147
  // reasoning://N
128
148
  if (responseMessage?.reasoning_content) {
129
- await store.upsert(
149
+ await store.set({
130
150
  runId,
131
151
  turn,
132
- `reasoning://${turn}`,
133
- responseMessage.reasoning_content,
134
- 200,
135
- { loopId, fidelity: "archived" },
136
- );
152
+ path: `reasoning://${turn}`,
153
+ body: responseMessage.reasoning_content,
154
+ state: "resolved",
155
+ ...systemOpts,
156
+ });
157
+ if (process.env.RUMMY_DEBUG === "true") {
158
+ console.log(
159
+ `\n--- REASONING turn ${turn} (${responseMessage.reasoning_content.length} chars) ---\n${responseMessage.reasoning_content}\n--- END REASONING turn ${turn} ---\n`,
160
+ );
161
+ }
137
162
  }
138
163
 
139
164
  // content://N — unparsed text. 400 Bad Request because anything in
@@ -141,46 +166,75 @@ export default class Telemetry {
141
166
  // tool call attempts, reasoning bleed). Visible to the model so it
142
167
  // sees the rejection on its next turn and can correct.
143
168
  if (unparsed) {
144
- await store.upsert(runId, turn, `content://${turn}`, unparsed, 400, {
169
+ await store.set({
170
+ runId,
171
+ turn,
172
+ path: `content://${turn}`,
173
+ body: unparsed,
174
+ state: "failed",
175
+ outcome: "unparsed",
145
176
  loopId,
146
- fidelity: "promoted",
177
+ visibility: "visible",
178
+ writer: "system",
147
179
  });
148
180
  }
149
181
 
150
- // Commit usage stats
151
- const usage = result.usage || {};
152
- const cachedTokens =
153
- usage.cached_tokens ||
154
- usage.prompt_tokens_details?.cached_tokens ||
155
- usage.input_tokens_details?.cached_tokens ||
156
- usage.cache_read_input_tokens ||
157
- 0;
158
- const reasoningTokens =
159
- usage.reasoning_tokens ||
160
- usage.completion_tokens_details?.reasoning_tokens ||
161
- usage.output_tokens_details?.reasoning_tokens ||
162
- 0;
163
- // Use LLM's actual prompt_tokens as the ground-truth context size when available.
164
- // This back-fills context_tokens so get_last_context_tokens reflects reality for the next turn.
165
- const actualContextTokens = usage.prompt_tokens || assembledTokens || 0;
166
- await rummy.db.update_turn_stats.run({
182
+ // Commit usage stats. Providers surface token counts under
183
+ // incompatible keys; walk them in priority order and fall back
184
+ // to 0 only as the definitional "not reported" value.
185
+ const usage = result.usage ? result.usage : {};
186
+ const cachedSources = [
187
+ usage.cached_tokens,
188
+ usage.prompt_tokens_details?.cached_tokens,
189
+ usage.input_tokens_details?.cached_tokens,
190
+ usage.cache_read_input_tokens,
191
+ ];
192
+ const reasoningSources = [
193
+ usage.reasoning_tokens,
194
+ usage.completion_tokens_details?.reasoning_tokens,
195
+ usage.output_tokens_details?.reasoning_tokens,
196
+ ];
197
+ let cachedTokens = 0;
198
+ for (const v of cachedSources)
199
+ if (v) {
200
+ cachedTokens = v;
201
+ break;
202
+ }
203
+ let reasoningTokens = 0;
204
+ for (const v of reasoningSources)
205
+ if (v) {
206
+ reasoningTokens = v;
207
+ break;
208
+ }
209
+ // Use LLM's actual prompt_tokens as the ground-truth context size
210
+ // when available; falls back to our pre-call estimate.
211
+ let actualContextTokens = 0;
212
+ if (usage.prompt_tokens) actualContextTokens = usage.prompt_tokens;
213
+ else if (assembledTokens) actualContextTokens = assembledTokens;
214
+ const numberOrZero = (v) => (typeof v === "number" ? v : 0);
215
+ await rummy.entries.updateTurnStats({
167
216
  id: rummy.turnId,
168
217
  context_tokens: actualContextTokens,
169
- reasoning_content: responseMessage?.reasoning_content || null,
170
- prompt_tokens: usage.prompt_tokens ?? 0,
171
- cached_tokens: cachedTokens ?? 0,
172
- completion_tokens: usage.completion_tokens ?? 0,
173
- reasoning_tokens: reasoningTokens ?? 0,
174
- total_tokens: usage.total_tokens ?? 0,
175
- cost: usage.cost ?? 0,
218
+ reasoning_content: responseMessage?.reasoning_content
219
+ ? responseMessage.reasoning_content
220
+ : null,
221
+ prompt_tokens: numberOrZero(usage.prompt_tokens),
222
+ cached_tokens: cachedTokens,
223
+ completion_tokens: numberOrZero(usage.completion_tokens),
224
+ reasoning_tokens: reasoningTokens,
225
+ total_tokens: numberOrZero(usage.total_tokens),
226
+ cost: numberOrZero(usage.cost),
176
227
  });
177
228
  }
178
229
 
179
230
  async #logMessages(messages, context) {
180
- this.#currentRunAlias = context.runAlias || `run_${context.runId}`;
181
- this.#currentTurn = context.turn ?? null;
231
+ this.#currentRunAlias = context.runAlias
232
+ ? context.runAlias
233
+ : `run_${context.runId}`;
234
+ this.#currentTurn = context.turn === undefined ? null : context.turn;
235
+ const turnLabel = this.#currentTurn === null ? "?" : this.#currentTurn;
182
236
  this.#turnLog.push(
183
- `\n${"=".repeat(60)}\nTURN ${this.#currentTurn ?? "?"} — model=${context.model} run=${this.#currentRunAlias}\n${"=".repeat(60)}`,
237
+ `\n${"=".repeat(60)}\nTURN ${turnLabel} — model=${context.model} run=${this.#currentRunAlias}\n${"=".repeat(60)}`,
184
238
  );
185
239
  for (const msg of messages) {
186
240
  const label = msg.role.toUpperCase();
@@ -195,34 +249,29 @@ export default class Telemetry {
195
249
 
196
250
  async #logResponse(response) {
197
251
  const msg = response.choices?.[0]?.message;
198
- this.#turnLog.push(`\n--- ASSISTANT ---\n${msg?.content || "(empty)"}`);
252
+ const content = msg?.content ? msg.content : "(empty)";
253
+ this.#turnLog.push(`\n--- ASSISTANT ---\n${content}`);
199
254
  if (msg?.reasoning_content) {
200
255
  this.#turnLog.push(`\n--- REASONING ---\n${msg.reasoning_content}`);
201
256
  }
202
- const usage = response.usage || {};
257
+ const usage = response.usage ? response.usage : {};
203
258
  this.#turnLog.push(`\n--- USAGE ---\n${JSON.stringify(usage)}`);
204
259
  this.#flush();
205
260
  this.#writeTurnFile();
206
261
  return response;
207
262
  }
208
263
 
209
- #flush() {
264
+ async #flush() {
210
265
  if (!this.#lastRunPath || this.#turnLog.length === 0) return;
211
- writeFile(this.#lastRunPath, `${this.#turnLog.join("\n")}\n`).catch(
212
- () => {},
213
- );
266
+ await writeFile(this.#lastRunPath, `${this.#turnLog.join("\n")}\n`);
214
267
  }
215
268
 
216
269
  async #writeTurnFile() {
217
270
  if (!this.#turnsDir || !this.#currentRunAlias || this.#currentTurn == null)
218
271
  return;
219
272
  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
- }
273
+ await mkdir(runDir, { recursive: true });
274
+ const fileName = `turn_${String(this.#currentTurn).padStart(3, "0")}.txt`;
275
+ await writeFile(join(runDir, fileName), `${this.#turnLog.join("\n")}\n`);
227
276
  }
228
277
  }
@@ -1,4 +1,4 @@
1
- # think
1
+ # think {#think_plugin}
2
2
 
3
3
  Provides a `<think>` tag for model reasoning. Not a tool — does not
4
4
  appear in the tool list.
@@ -14,5 +14,17 @@ export default class Think {
14
14
  return docsMap;
15
15
  });
16
16
  }
17
+
18
+ // Merge <think> tag bodies into the turn's reasoning_content so
19
+ // models without a dedicated reasoning channel still expose their
20
+ // reasoning through the same field.
21
+ core.filter("llm.reasoning", (reasoning, { commands }) => {
22
+ const thinkText = commands
23
+ .filter((c) => c.name === "think")
24
+ .map((c) => c.body)
25
+ .filter(Boolean)
26
+ .join("\n");
27
+ return [reasoning, thinkText].filter(Boolean).join("\n");
28
+ });
17
29
  }
18
30
  }
@@ -1,16 +1,3 @@
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
- ];
1
+ import { loadDoc } from "../helpers.js";
15
2
 
16
- export default LINES.map(([text]) => text).join("\n");
3
+ export default loadDoc(import.meta.url, "thinkDoc.md");
@@ -0,0 +1,7 @@
1
+ ## <think>[reasoning]</think> - Think before acting
2
+
3
+ * Use <think></think> before any other tools to plan your approach
4
+ <!-- Positioning: think first, then act. Prevents degenerate tool-call storms. -->
5
+
6
+ * Reasoning inside <think></think> is private — it does not appear in your context
7
+ <!-- Frees the model to reason without consuming context budget. -->
@@ -1,4 +1,4 @@
1
- # unknown
1
+ # unknown {#unknown_plugin}
2
2
 
3
3
  The Rumsfeld mechanism. The model registers what it doesn't know before acting.
4
4
 
@@ -9,7 +9,7 @@ The Rumsfeld mechanism. The model registers what it doesn't know before acting.
9
9
  - **Tool**: `unknown`
10
10
  - **Category**: `unknown`
11
11
  - **Handler**: None — recorded by TurnExecutor, deduplicated against existing unknowns.
12
- - **Filter**: `assembly.system` at priority 300 — renders `<unknowns>` section.
12
+ - **Filter**: `assembly.user` at priority 200 — renders `<unknowns>` adjacent to `<prompt>` (priority 300), after `<performed>` (priority 100). Unknowns are active work, not stable environment state; they belong in the user packet.
13
13
 
14
14
  ## Projection
15
15
 
@@ -20,5 +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. Each unknown renders with turn, fidelity, and tokens for
23
+ on insert. Each unknown renders with turn, visibility, and tokens for
24
24
  temporal reasoning and context management.
@@ -8,12 +8,9 @@ export default class Unknown {
8
8
  category: "unknown",
9
9
  });
10
10
  core.on("handler", this.handler.bind(this));
11
- core.on("promoted", this.full.bind(this));
12
- core.on("demoted", this.summary.bind(this));
13
- core.filter("assembly.system", this.assembleUnknowns.bind(this), 300);
14
- // <unknown> is internal — written via <set path="unknown://...">. Hidden
15
- // from all model-facing tool lists. Handler still dispatches if the
16
- // model emits <unknown> directly out of habit.
11
+ core.on("visible", this.full.bind(this));
12
+ core.on("summarized", this.summary.bind(this));
13
+ core.filter("assembly.user", this.assembleUnknowns.bind(this), 200);
17
14
  core.markHidden();
18
15
  }
19
16
 
@@ -23,7 +20,13 @@ export default class Unknown {
23
20
  // Deduplicate — if this exact body already exists, skip
24
21
  const existingValues = await store.getUnknownValues(runId);
25
22
  if (existingValues.has(entry.body)) {
26
- console.warn(`[RUMMY] Unknown deduped: "${entry.body.slice(0, 60)}"`);
23
+ await this.#core.hooks.error.log.emit({
24
+ store,
25
+ runId,
26
+ turn,
27
+ loopId,
28
+ message: `Unknown deduped: "${entry.body.slice(0, 60)}"`,
29
+ });
27
30
  return;
28
31
  }
29
32
 
@@ -35,29 +38,54 @@ export default class Unknown {
35
38
  entry.body,
36
39
  entry.attributes?.summary,
37
40
  );
38
- await store.upsert(runId, turn, unknownPath, entry.body, 200, { loopId });
41
+ await store.set({
42
+ runId,
43
+ turn,
44
+ path: unknownPath,
45
+ body: entry.body,
46
+ state: "resolved",
47
+ loopId,
48
+ });
39
49
  }
40
50
 
41
51
  full(entry) {
42
52
  return entry.body;
43
53
  }
44
54
 
45
- summary() {
46
- return "";
55
+ // Same principle as knowns: keep the first 500 characters on
56
+ // summarized unknowns so demotion doesn't erase the question,
57
+ // but cap large bodies to bound the packet cost.
58
+ summary(entry) {
59
+ if (!entry.body) return "";
60
+ if (entry.body.length <= 500) return entry.body;
61
+ return `${entry.body.slice(0, 500)}\n[truncated — promote to see the full question]`;
47
62
  }
48
63
 
49
64
  async assembleUnknowns(content, ctx) {
50
65
  const entries = ctx.rows.filter((r) => r.category === "unknown");
51
66
  if (entries.length === 0) return content;
67
+ const lines = entries.map((e) => renderUnknownTag(e));
68
+ return `${content}<unknowns>\n${lines.join("\n")}\n</unknowns>\n`;
69
+ }
70
+ }
52
71
 
53
- const lines = entries.map((u) => {
54
- const fidelity = u.fidelity ? ` fidelity="${u.fidelity}"` : "";
55
- const tokens = u.tokens ? ` tokens="${u.tokens}"` : "";
56
- if (u.body) {
57
- return `<unknown path="${u.path}" turn="${u.source_turn || u.turn}"${fidelity}${tokens}>${u.body}</unknown>`;
58
- }
59
- return `<unknown path="${u.path}" turn="${u.source_turn || u.turn}"${fidelity}${tokens}/>`;
60
- });
61
- return `${content}\n\n<unknowns>\n${lines.join("\n")}\n</unknowns>`;
72
+ function renderUnknownTag(entry) {
73
+ const attrs =
74
+ typeof entry.attributes === "string"
75
+ ? JSON.parse(entry.attributes)
76
+ : entry.attributes;
77
+ const turn = entry.source_turn ? ` turn="${entry.source_turn}"` : "";
78
+ const visibility = entry.visibility
79
+ ? ` visibility="${entry.visibility}"`
80
+ : "";
81
+ const tokens = entry.aTokens != null ? ` tokens="${entry.aTokens}"` : "";
82
+ const summary =
83
+ typeof attrs?.summary === "string"
84
+ ? ` summary="${attrs.summary.replace(/"/g, "'").slice(0, 80)}"`
85
+ : "";
86
+ const attrStr = `${turn}${summary}${visibility}${tokens}`;
87
+ if (entry.body) {
88
+ return `<unknown path="${entry.path}"${attrStr}>${entry.body}</unknown>`;
62
89
  }
90
+ return `<unknown path="${entry.path}"${attrStr}/>`;
63
91
  }
@@ -1,22 +1,3 @@
1
- // Tool doc for <unknown>. 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
- [
6
- "## <unknown>[specific thing I need to learn]</unknown> - Register gaps for research",
7
- ],
8
- [
9
- 'Example: <unknown path="unknown://answer">contents of answer.txt</unknown>',
10
- "Path form: explicit unknown path for structured tracking.",
11
- ],
12
- [
13
- "* Investigate with Tool Commands",
14
- "Unknowns drive action — get, env, search, ask_user.",
15
- ],
16
- [
17
- '* When resolved or irrelevant, remove with <set path="unknown://..." fidelity="archived"/>',
18
- "Archive instead of delete — preserves the question for context history.",
19
- ],
20
- ];
1
+ import { loadDoc } from "../helpers.js";
21
2
 
22
- export default LINES.map(([text]) => text).join("\n");
3
+ export default loadDoc(import.meta.url, "unknownDoc.md");
@@ -0,0 +1,11 @@
1
+ ## <set path="unknown://{question}">[specific thing I need to learn]</set> - Register gaps for research
2
+ <!-- Use <set> to write unknown entries (not <unknown>). Matches instructions examples. -->
3
+
4
+ Example: <set path="unknown://answer" summary="answer,contents">contents of answer.txt</set>
5
+ <!-- Path form: explicit unknown path for structured tracking. -->
6
+
7
+ * Investigate with Tool Commands
8
+ <!-- Unknowns drive action — get, env, search, ask_user. -->
9
+
10
+ * When resolved or irrelevant, remove with <set path="unknown://..." visibility="archived"/>
11
+ <!-- Archive instead of delete — preserves the question for context history. -->
@@ -1,4 +1,4 @@
1
- # update
1
+ # update {#update_plugin}
2
2
 
3
3
  Lifecycle signal — the model declares it has more work to do.
4
4
 
@@ -1,5 +1,17 @@
1
1
  import docs from "./updateDoc.js";
2
2
 
3
+ const TERMINAL_STATUSES = new Set([200, 204, 422, 500]);
4
+
5
+ const CONTRACT_REMINDER = "Missing update";
6
+
7
+ const EMPTY_RESPONSE_REMINDER =
8
+ "Response empty - Update with status 500 if unable to fulfill request.";
9
+
10
+ function isValidStatus(status) {
11
+ if (TERMINAL_STATUSES.has(status)) return true;
12
+ return Number.isInteger(status) && status >= 100 && status < 200;
13
+ }
14
+
3
15
  export default class Update {
4
16
  #core;
5
17
 
@@ -8,18 +20,84 @@ export default class Update {
8
20
  core.ensureTool();
9
21
  core.registerScheme({ category: "logging" });
10
22
  core.on("handler", this.handler.bind(this));
11
- core.on("promoted", this.full.bind(this));
12
- core.on("demoted", this.summary.bind(this));
23
+ core.on("visible", this.full.bind(this));
24
+ core.on("summarized", this.summary.bind(this));
13
25
  core.filter("instructions.toolDocs", async (docsMap) => {
14
26
  docsMap.update = docs;
15
27
  return docsMap;
16
28
  });
29
+ core.hooks.update = {
30
+ resolve: this.resolve.bind(this),
31
+ };
17
32
  }
18
33
 
19
34
  async handler(entry, rummy) {
20
- const { entries: store, sequence: turn, runId, loopId } = rummy;
21
- const statusPath = await store.slugPath(runId, "update", entry.body);
22
- await store.upsert(runId, turn, statusPath, entry.body, 200, { loopId });
35
+ const status = entry.attributes?.status ?? 102;
36
+ const validation = await rummy.hooks.instructions.validateNavigation(
37
+ status,
38
+ rummy,
39
+ );
40
+ const attributes = validation.ok ? {} : { rejected: true };
41
+ await rummy.update(entry.body, { status, attributes });
42
+ if (!validation.ok) {
43
+ await rummy.hooks.error.log.emit({
44
+ store: rummy.entries,
45
+ runId: rummy.runId,
46
+ turn: rummy.sequence,
47
+ loopId: rummy.loopId,
48
+ message: validation.reason,
49
+ status: 422,
50
+ });
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Classify this turn's update state.
56
+ *
57
+ * Returns { summaryText, updateText }:
58
+ * - summaryText: non-null → model claimed terminal (200/204/422)
59
+ * - updateText: non-null → model is continuing (1xx)
60
+ *
61
+ * Errors (invalid status, missing update) emit via hooks.error.log.
62
+ * The "terminal + turn had errors → not actually terminal" rule
63
+ * lives in the error plugin's verdict, not here.
64
+ */
65
+ async resolve({ recorded, content, runId, turn, loopId, rummy }) {
66
+ const entry = recorded.findLast((e) => e.scheme === "update");
67
+ const status = entry?.attributes?.status ?? 102;
68
+ const rejected = entry?.attributes?.rejected === true;
69
+ const isTerminal = TERMINAL_STATUSES.has(status) && !rejected;
70
+ let summaryText = null;
71
+ let updateText = null;
72
+ if (entry?.body) {
73
+ if (isTerminal) summaryText = entry.body;
74
+ else updateText = entry.body;
75
+ }
76
+
77
+ if (entry && !isValidStatus(status)) {
78
+ await rummy.hooks.error.log.emit({
79
+ store: rummy.entries,
80
+ runId,
81
+ turn,
82
+ loopId,
83
+ message: `Invalid status ${entry.attributes?.status} on update — use 1xx to continue or 200 to conclude.`,
84
+ status: 422,
85
+ });
86
+ }
87
+
88
+ if (!summaryText && !updateText) {
89
+ const empty = !content || content.trim() === "";
90
+ await rummy.hooks.error.log.emit({
91
+ store: rummy.entries,
92
+ runId,
93
+ turn,
94
+ loopId,
95
+ message: empty ? EMPTY_RESPONSE_REMINDER : CONTRACT_REMINDER,
96
+ status: 422,
97
+ });
98
+ }
99
+
100
+ return { summaryText, updateText };
23
101
  }
24
102
 
25
103
  full(entry) {
@@ -1,31 +1,3 @@
1
- // Tool doc for <update>. 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
- [
6
- "## <update>[brief status]</update> - Heartbeat for ongoing work (one per turn, at the end)",
7
- "Header defines position and frequency. Without this, model uses update as inline narration between tools — multiple updates per turn.",
8
- ],
9
- [
10
- "Example: <update>Reading config files</update>",
11
- "Progress checkpoint. Status signal, not a log entry.",
12
- ],
13
- [
14
- "Example: <update>Found 3 issues, fixing first</update>",
15
- "Multi-step progress. Ongoing work.",
16
- ],
17
- [
18
- "* Urgent: ONE <update></update> per turn, AT THE END. Not inline narration between tools.",
19
- "Single-update-per-turn is the missing rule. Model was emitting 3-6 updates per turn as progress commentary.",
20
- ],
21
- [
22
- "* If you'd repeat the same <update></update> as last turn, the work is either stuck or done. Take a different action or <summarize></summarize>.",
23
- "Points at the zombie-loop failure mode directly. Gives the model a trigger (same-text-as-prior-update) and two remedies.",
24
- ],
25
- [
26
- "* YOU MUST keep <update></update> to <= 80 characters",
27
- "Length cap.",
28
- ],
29
- ];
1
+ import { loadDoc } from "../helpers.js";
30
2
 
31
- export default LINES.map(([text]) => text).join("\n");
3
+ export default loadDoc(import.meta.url, "updateDoc.md");