@possumtech/rummy 0.4.0 → 2.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 (153) hide show
  1. package/.env.example +21 -4
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +850 -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 +6 -4
  11. package/service.js +50 -9
  12. package/src/agent/AgentLoop.js +460 -331
  13. package/src/agent/ContextAssembler.js +4 -2
  14. package/src/agent/Entries.js +655 -0
  15. package/src/agent/ProjectAgent.js +30 -18
  16. package/src/agent/TurnExecutor.js +232 -379
  17. package/src/agent/XmlParser.js +242 -67
  18. package/src/agent/budget.js +56 -0
  19. package/src/agent/errors.js +22 -0
  20. package/src/agent/httpStatus.js +39 -0
  21. package/src/agent/known_checks.sql +8 -4
  22. package/src/agent/known_queries.sql +9 -13
  23. package/src/agent/known_store.sql +275 -118
  24. package/src/agent/materializeContext.js +102 -0
  25. package/src/agent/runs.sql +10 -7
  26. package/src/agent/schemes.sql +14 -3
  27. package/src/agent/turns.sql +9 -9
  28. package/src/hooks/HookRegistry.js +6 -5
  29. package/src/hooks/Hooks.js +44 -3
  30. package/src/hooks/PluginContext.js +35 -21
  31. package/src/{server → hooks}/RpcRegistry.js +2 -1
  32. package/src/hooks/RummyContext.js +140 -37
  33. package/src/hooks/ToolRegistry.js +36 -35
  34. package/src/llm/LlmProvider.js +64 -90
  35. package/src/llm/errors.js +21 -0
  36. package/src/plugins/ask_user/README.md +1 -1
  37. package/src/plugins/ask_user/ask_user.js +37 -12
  38. package/src/plugins/ask_user/ask_userDoc.js +2 -23
  39. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  40. package/src/plugins/budget/README.md +27 -23
  41. package/src/plugins/budget/budget.js +261 -69
  42. package/src/plugins/cp/README.md +2 -2
  43. package/src/plugins/cp/cp.js +31 -13
  44. package/src/plugins/cp/cpDoc.js +2 -23
  45. package/src/plugins/cp/cpDoc.md +7 -0
  46. package/src/plugins/engine/README.md +2 -2
  47. package/src/plugins/engine/engine.sql +4 -4
  48. package/src/plugins/engine/turn_context.sql +10 -10
  49. package/src/plugins/env/README.md +20 -5
  50. package/src/plugins/env/env.js +47 -8
  51. package/src/plugins/env/envDoc.js +2 -23
  52. package/src/plugins/env/envDoc.md +13 -0
  53. package/src/plugins/error/README.md +16 -0
  54. package/src/plugins/error/error.js +151 -0
  55. package/src/plugins/file/README.md +6 -6
  56. package/src/plugins/file/file.js +15 -7
  57. package/src/plugins/get/README.md +1 -1
  58. package/src/plugins/get/get.js +125 -49
  59. package/src/plugins/get/getDoc.js +2 -43
  60. package/src/plugins/get/getDoc.md +36 -0
  61. package/src/plugins/hedberg/README.md +1 -2
  62. package/src/plugins/hedberg/hedberg.js +8 -4
  63. package/src/plugins/hedberg/matcher.js +16 -17
  64. package/src/plugins/hedberg/normalize.js +0 -48
  65. package/src/plugins/helpers.js +43 -3
  66. package/src/plugins/index.js +146 -123
  67. package/src/plugins/instructions/README.md +35 -9
  68. package/src/plugins/instructions/instructions.js +126 -12
  69. package/src/plugins/instructions/instructions.md +25 -0
  70. package/src/plugins/instructions/instructions_104.md +7 -0
  71. package/src/plugins/instructions/instructions_105.md +46 -0
  72. package/src/plugins/instructions/instructions_106.md +0 -0
  73. package/src/plugins/instructions/instructions_107.md +0 -0
  74. package/src/plugins/instructions/instructions_108.md +8 -0
  75. package/src/plugins/instructions/protocol.js +12 -0
  76. package/src/plugins/known/README.md +2 -2
  77. package/src/plugins/known/known.js +77 -45
  78. package/src/plugins/known/knownDoc.js +2 -29
  79. package/src/plugins/known/knownDoc.md +8 -0
  80. package/src/plugins/log/README.md +48 -0
  81. package/src/plugins/log/log.js +109 -0
  82. package/src/plugins/mv/README.md +2 -2
  83. package/src/plugins/mv/mv.js +57 -24
  84. package/src/plugins/mv/mvDoc.js +2 -29
  85. package/src/plugins/mv/mvDoc.md +10 -0
  86. package/src/plugins/ollama/README.md +15 -0
  87. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  88. package/src/plugins/openai/README.md +17 -0
  89. package/src/plugins/openai/openai.js +120 -0
  90. package/src/plugins/openrouter/README.md +27 -0
  91. package/src/plugins/openrouter/openrouter.js +121 -0
  92. package/src/plugins/persona/README.md +20 -0
  93. package/src/plugins/persona/persona.js +9 -16
  94. package/src/plugins/policy/README.md +21 -0
  95. package/src/plugins/policy/policy.js +29 -14
  96. package/src/plugins/prompt/README.md +1 -1
  97. package/src/plugins/prompt/prompt.js +63 -18
  98. package/src/plugins/rm/README.md +1 -1
  99. package/src/plugins/rm/rm.js +58 -14
  100. package/src/plugins/rm/rmDoc.js +2 -24
  101. package/src/plugins/rm/rmDoc.md +13 -0
  102. package/src/plugins/rpc/README.md +2 -2
  103. package/src/plugins/rpc/rpc.js +515 -296
  104. package/src/plugins/set/README.md +1 -1
  105. package/src/plugins/set/set.js +318 -77
  106. package/src/plugins/set/setDoc.js +2 -35
  107. package/src/plugins/set/setDoc.md +22 -0
  108. package/src/plugins/sh/README.md +28 -5
  109. package/src/plugins/sh/sh.js +52 -8
  110. package/src/plugins/sh/shDoc.js +2 -23
  111. package/src/plugins/sh/shDoc.md +13 -0
  112. package/src/plugins/skill/README.md +23 -0
  113. package/src/plugins/skill/skill.js +14 -17
  114. package/src/plugins/stream/README.md +101 -0
  115. package/src/plugins/stream/stream.js +290 -0
  116. package/src/plugins/telemetry/README.md +1 -1
  117. package/src/plugins/telemetry/telemetry.js +148 -74
  118. package/src/plugins/think/README.md +1 -1
  119. package/src/plugins/think/think.js +14 -1
  120. package/src/plugins/think/thinkDoc.js +2 -17
  121. package/src/plugins/think/thinkDoc.md +7 -0
  122. package/src/plugins/unknown/README.md +3 -3
  123. package/src/plugins/unknown/unknown.js +56 -21
  124. package/src/plugins/unknown/unknownDoc.js +2 -25
  125. package/src/plugins/unknown/unknownDoc.md +11 -0
  126. package/src/plugins/update/README.md +1 -1
  127. package/src/plugins/update/update.js +67 -5
  128. package/src/plugins/update/updateDoc.js +2 -27
  129. package/src/plugins/update/updateDoc.md +8 -0
  130. package/src/plugins/xai/README.md +23 -0
  131. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  132. package/src/server/ClientConnection.js +64 -37
  133. package/src/server/SocketServer.js +23 -10
  134. package/src/server/protocol.js +11 -0
  135. package/src/sql/functions/slugify.js +13 -1
  136. package/src/sql/v_model_context.sql +27 -31
  137. package/src/sql/v_run_log.sql +9 -14
  138. package/EXCEPTIONS.md +0 -46
  139. package/src/agent/KnownStore.js +0 -338
  140. package/src/agent/ResponseHealer.js +0 -188
  141. package/src/llm/OpenAiClient.js +0 -100
  142. package/src/llm/OpenRouterClient.js +0 -100
  143. package/src/plugins/budget/recovery.js +0 -47
  144. package/src/plugins/instructions/preamble.md +0 -37
  145. package/src/plugins/performed/README.md +0 -15
  146. package/src/plugins/performed/performed.js +0 -45
  147. package/src/plugins/previous/README.md +0 -16
  148. package/src/plugins/previous/previous.js +0 -60
  149. package/src/plugins/progress/README.md +0 -16
  150. package/src/plugins/progress/progress.js +0 -26
  151. package/src/plugins/summarize/README.md +0 -19
  152. package/src/plugins/summarize/summarize.js +0 -32
  153. package/src/plugins/summarize/summarizeDoc.js +0 -28
@@ -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));
@@ -24,14 +30,15 @@ export default class Telemetry {
24
30
 
25
31
  async #onRpcStarted({ method, id, params }) {
26
32
  this.#starts.set(id, Date.now());
27
- const summary =
28
- method === "ask" || method === "act"
29
- ? `prompt="${(params?.prompt || "").slice(0, 60)}"`
30
- : method === "run/abort"
31
- ? `run=${params?.run}`
32
- : method === "run/resolve"
33
- ? `run=${params?.run} action=${params?.resolution?.action}`
34
- : "";
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
+ }
35
42
  console.log(`[RPC] → ${method}(${id})${summary ? ` ${summary}` : ""}`);
36
43
 
37
44
  if (method === "ask" || method === "act") {
@@ -44,11 +51,13 @@ export default class Telemetry {
44
51
  ? `${((Date.now() - this.#starts.get(id)) / 1000).toFixed(1)}s`
45
52
  : "";
46
53
  this.#starts.delete(id);
47
- const summary = result?.run
48
- ? `run=${result.run} status=${result.status || "ok"}`
49
- : result?.status
50
- ? `status=${result.status}`
51
- : "";
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
+ }
52
61
  console.log(
53
62
  `[RPC] ← ${method}(${id}) ${elapsed}${summary ? ` ${summary}` : ""}`,
54
63
  );
@@ -59,7 +68,8 @@ export default class Telemetry {
59
68
  ? `${((Date.now() - this.#starts.get(id)) / 1000).toFixed(1)}s`
60
69
  : "";
61
70
  this.#starts.delete(id);
62
- console.error(`[RPC] (${id}) ${elapsed} ${error?.message || error}`);
71
+ const detail = error?.message ? error.message : error;
72
+ console.error(`[RPC] ✗ (${id}) ${elapsed} ${detail}`);
63
73
  }
64
74
 
65
75
  async #onStepCompleted(payload) {
@@ -81,95 +91,150 @@ export default class Telemetry {
81
91
  userMsg,
82
92
  }) {
83
93
  const { entries: store, runId, loopId } = rummy;
94
+ // Audit schemes are system-only writes (see initPlugins).
95
+ const systemOpts = { loopId, visibility: "archived", writer: "system" };
84
96
 
85
97
  // assistant://N — the model's raw response
86
- await store.upsert(runId, turn, `assistant://${turn}`, content, 200, {
87
- loopId,
88
- fidelity: "archive",
98
+ await store.set({
99
+ runId,
100
+ turn,
101
+ path: `assistant://${turn}`,
102
+ body: content,
103
+ state: "resolved",
104
+ ...systemOpts,
89
105
  });
90
106
 
91
107
  // system://N, user://N — assembled messages as audit
92
108
  if (systemMsg) {
93
- await store.upsert(runId, turn, `system://${turn}`, systemMsg, 200, {
94
- loopId,
95
- fidelity: "archive",
109
+ await store.set({
110
+ runId,
111
+ turn,
112
+ path: `system://${turn}`,
113
+ body: systemMsg,
114
+ state: "resolved",
115
+ ...systemOpts,
96
116
  });
97
117
  }
98
118
  if (userMsg) {
99
- await store.upsert(runId, turn, `user://${turn}`, userMsg, 200, {
100
- loopId,
101
- fidelity: "archive",
119
+ await store.set({
120
+ runId,
121
+ turn,
122
+ path: `user://${turn}`,
123
+ body: userMsg,
124
+ state: "resolved",
125
+ ...systemOpts,
102
126
  });
103
127
  }
104
128
 
105
129
  // model://N — raw API response diagnostics
106
- await store.upsert(
130
+ await store.set({
107
131
  runId,
108
132
  turn,
109
- `model://${turn}`,
110
- JSON.stringify({
133
+ path: `model://${turn}`,
134
+ body: JSON.stringify({
111
135
  keys: responseMessage ? Object.keys(responseMessage) : [],
112
- reasoning_content: responseMessage?.reasoning_content || null,
136
+ reasoning_content: responseMessage?.reasoning_content
137
+ ? responseMessage.reasoning_content
138
+ : null,
113
139
  content: content.slice(0, 4096),
114
- usage: result.usage || null,
115
- model: result.model || null,
140
+ usage: result.usage ? result.usage : null,
141
+ model: result.model ? result.model : null,
116
142
  }),
117
- 200,
118
- { loopId, fidelity: "archive" },
119
- );
143
+ state: "resolved",
144
+ ...systemOpts,
145
+ });
120
146
 
121
147
  // reasoning://N
122
148
  if (responseMessage?.reasoning_content) {
123
- await store.upsert(
149
+ await store.set({
124
150
  runId,
125
151
  turn,
126
- `reasoning://${turn}`,
127
- responseMessage.reasoning_content,
128
- 200,
129
- { loopId, fidelity: "archive" },
130
- );
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
+ }
131
162
  }
132
163
 
133
- // content://N — unparsed text
164
+ // content://N — unparsed text. 400 Bad Request because anything in
165
+ // unparsed is text the parser couldn't dispatch (malformed XML, native
166
+ // tool call attempts, reasoning bleed). Visible to the model so it
167
+ // sees the rejection on its next turn and can correct.
134
168
  if (unparsed) {
135
- await store.upsert(runId, turn, `content://${turn}`, unparsed, 200, {
169
+ await store.set({
170
+ runId,
171
+ turn,
172
+ path: `content://${turn}`,
173
+ body: unparsed,
174
+ state: "failed",
175
+ outcome: "unparsed",
136
176
  loopId,
137
- fidelity: "archive",
177
+ visibility: "visible",
178
+ writer: "system",
138
179
  });
139
180
  }
140
181
 
141
- // Commit usage stats
142
- const usage = result.usage || {};
143
- const cachedTokens =
144
- usage.cached_tokens ||
145
- usage.prompt_tokens_details?.cached_tokens ||
146
- usage.input_tokens_details?.cached_tokens ||
147
- usage.cache_read_input_tokens ||
148
- 0;
149
- const reasoningTokens =
150
- usage.reasoning_tokens ||
151
- usage.completion_tokens_details?.reasoning_tokens ||
152
- usage.output_tokens_details?.reasoning_tokens ||
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;
157
- 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({
158
216
  id: rummy.turnId,
159
217
  context_tokens: actualContextTokens,
160
- reasoning_content: responseMessage?.reasoning_content || null,
161
- prompt_tokens: usage.prompt_tokens ?? 0,
162
- cached_tokens: cachedTokens ?? 0,
163
- completion_tokens: usage.completion_tokens ?? 0,
164
- reasoning_tokens: reasoningTokens ?? 0,
165
- total_tokens: usage.total_tokens ?? 0,
166
- 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),
167
227
  });
168
228
  }
169
229
 
170
230
  async #logMessages(messages, context) {
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;
171
236
  this.#turnLog.push(
172
- `\n${"=".repeat(60)}\nTURN — model=${context.model} run=${context.runId}\n${"=".repeat(60)}`,
237
+ `\n${"=".repeat(60)}\nTURN ${turnLabel} — model=${context.model} run=${this.#currentRunAlias}\n${"=".repeat(60)}`,
173
238
  );
174
239
  for (const msg of messages) {
175
240
  const label = msg.role.toUpperCase();
@@ -184,20 +249,29 @@ export default class Telemetry {
184
249
 
185
250
  async #logResponse(response) {
186
251
  const msg = response.choices?.[0]?.message;
187
- 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}`);
188
254
  if (msg?.reasoning_content) {
189
255
  this.#turnLog.push(`\n--- REASONING ---\n${msg.reasoning_content}`);
190
256
  }
191
- const usage = response.usage || {};
257
+ const usage = response.usage ? response.usage : {};
192
258
  this.#turnLog.push(`\n--- USAGE ---\n${JSON.stringify(usage)}`);
193
259
  this.#flush();
260
+ this.#writeTurnFile();
194
261
  return response;
195
262
  }
196
263
 
197
- #flush() {
264
+ async #flush() {
198
265
  if (!this.#lastRunPath || this.#turnLog.length === 0) return;
199
- writeFile(this.#lastRunPath, `${this.#turnLog.join("\n")}\n`).catch(
200
- () => {},
201
- );
266
+ await writeFile(this.#lastRunPath, `${this.#turnLog.join("\n")}\n`);
267
+ }
268
+
269
+ async #writeTurnFile() {
270
+ if (!this.#turnsDir || !this.#currentRunAlias || this.#currentTurn == null)
271
+ return;
272
+ const runDir = join(this.#turnsDir, this.#currentRunAlias);
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`);
202
276
  }
203
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.
@@ -1,7 +1,8 @@
1
1
  import docs from "./thinkDoc.js";
2
2
 
3
3
  const THINK_ENABLED = process.env.RUMMY_THINK;
4
- if (THINK_ENABLED === undefined) throw new Error("RUMMY_THINK must be set (1 or 0)");
4
+ if (THINK_ENABLED === undefined)
5
+ throw new Error("RUMMY_THINK must be set (1 or 0)");
5
6
 
6
7
  export default class Think {
7
8
  constructor(core) {
@@ -13,5 +14,17 @@ export default class Think {
13
14
  return docsMap;
14
15
  });
15
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
+ });
16
29
  }
17
30
  }
@@ -1,18 +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
- [
6
- "## <think>[reasoning]</think> - Think before acting",
7
- ],
8
- [
9
- "* Use <think> before any other tools to plan your approach",
10
- "Positioning: think first, then act. Prevents degenerate tool-call storms.",
11
- ],
12
- [
13
- "* Reasoning inside <think> is private — it does not appear in your context",
14
- "Frees the model to reason without consuming context budget.",
15
- ],
16
- ];
1
+ import { loadDoc } from "../helpers.js";
17
2
 
18
- 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.
@@ -1,5 +1,3 @@
1
- import docs from "./unknownDoc.js";
2
-
3
1
  export default class Unknown {
4
2
  #core;
5
3
 
@@ -10,13 +8,10 @@ export default class Unknown {
10
8
  category: "unknown",
11
9
  });
12
10
  core.on("handler", this.handler.bind(this));
13
- core.on("full", this.full.bind(this));
14
- core.on("summary", this.summary.bind(this));
15
- core.filter("assembly.system", this.assembleUnknowns.bind(this), 300);
16
- core.filter("instructions.toolDocs", async (docsMap) => {
17
- docsMap.unknown = docs;
18
- return docsMap;
19
- });
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);
14
+ core.markHidden();
20
15
  }
21
16
 
22
17
  async handler(entry, rummy) {
@@ -25,32 +20,72 @@ export default class Unknown {
25
20
  // Deduplicate — if this exact body already exists, skip
26
21
  const existingValues = await store.getUnknownValues(runId);
27
22
  if (existingValues.has(entry.body)) {
28
- 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
+ });
29
30
  return;
30
31
  }
31
32
 
32
- // Generate slug path and upsert
33
- const unknownPath = await store.slugPath(runId, "unknown", entry.body);
34
- await store.upsert(runId, turn, unknownPath, entry.body, 200, { loopId });
33
+ // Generate slug path and upsert. Summary (if provided) becomes the
34
+ // path so the model can round-trip it via <get>; body is the fallback.
35
+ const unknownPath = await store.slugPath(
36
+ runId,
37
+ "unknown",
38
+ entry.body,
39
+ entry.attributes?.summary,
40
+ );
41
+ await store.set({
42
+ runId,
43
+ turn,
44
+ path: unknownPath,
45
+ body: entry.body,
46
+ state: "resolved",
47
+ loopId,
48
+ });
35
49
  }
36
50
 
37
51
  full(entry) {
38
- return `# unknown\n${entry.body}`;
52
+ return entry.body;
39
53
  }
40
54
 
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.
41
58
  summary(entry) {
42
- return this.full(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]`;
43
62
  }
44
63
 
45
64
  async assembleUnknowns(content, ctx) {
46
65
  const entries = ctx.rows.filter((r) => r.category === "unknown");
47
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
+ }
48
71
 
49
- const lines = entries.map((u) => {
50
- const fidelity = u.fidelity ? ` fidelity="${u.fidelity}"` : "";
51
- const tokens = u.tokens ? ` tokens="${u.tokens}"` : "";
52
- return `<unknown path="${u.path}" turn="${u.source_turn || u.turn}"${fidelity}${tokens}>${u.body}</unknown>`;
53
- });
54
- 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>`;
55
89
  }
90
+ return `<unknown path="${entry.path}"${attrStr}/>`;
56
91
  }
@@ -1,26 +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> - Track open questions",
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
- "Example: <unknown>which database adapter is configured</unknown>",
14
- "Body form: question as body, path auto-generated.",
15
- ],
16
- [
17
- "* Investigate with Tool Commands",
18
- "Unknowns drive action — get, env, search, ask_user.",
19
- ],
20
- [
21
- '* When resolved or irrelevant, remove with <set path="unknown://..." fidelity="archive"/>',
22
- "Archive instead of delete — preserves the question for context history.",
23
- ],
24
- ];
1
+ import { loadDoc } from "../helpers.js";
25
2
 
26
- 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,68 @@ 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("full", this.full.bind(this));
12
- core.on("summary", 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
+ await rummy.update(entry.body, { status });
37
+ }
38
+
39
+ /**
40
+ * Classify this turn's update state.
41
+ *
42
+ * Returns { summaryText, updateText }:
43
+ * - summaryText: non-null → model claimed terminal (200/204/422)
44
+ * - updateText: non-null → model is continuing (1xx)
45
+ *
46
+ * Errors (invalid status, missing update) emit via hooks.error.log.
47
+ * The "terminal + turn had errors → not actually terminal" rule
48
+ * lives in the error plugin's verdict, not here.
49
+ */
50
+ async resolve({ recorded, content, runId, turn, loopId, rummy }) {
51
+ const entry = recorded.findLast((e) => e.scheme === "update");
52
+ const status = entry?.attributes?.status ?? 102;
53
+ const isTerminal = TERMINAL_STATUSES.has(status);
54
+ let summaryText = null;
55
+ let updateText = null;
56
+ if (entry?.body) {
57
+ if (isTerminal) summaryText = entry.body;
58
+ else updateText = entry.body;
59
+ }
60
+
61
+ if (entry && !isValidStatus(status)) {
62
+ await rummy.hooks.error.log.emit({
63
+ store: rummy.entries,
64
+ runId,
65
+ turn,
66
+ loopId,
67
+ message: `Invalid status ${entry.attributes?.status} on update — use 1xx to continue or 200 to conclude.`,
68
+ status: 422,
69
+ });
70
+ }
71
+
72
+ if (!summaryText && !updateText) {
73
+ const empty = !content || content.trim() === "";
74
+ await rummy.hooks.error.log.emit({
75
+ store: rummy.entries,
76
+ runId,
77
+ turn,
78
+ loopId,
79
+ message: empty ? EMPTY_RESPONSE_REMINDER : CONTRACT_REMINDER,
80
+ status: 422,
81
+ });
82
+ }
83
+
84
+ return { summaryText, updateText };
23
85
  }
24
86
 
25
87
  full(entry) {