@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
@@ -1,4 +1,4 @@
1
- # set
1
+ # set {#set_plugin}
2
2
 
3
3
  Writes or edits entry content. Handles new files, full overwrites,
4
4
  SEARCH/REPLACE edits, and pattern updates.
@@ -1,10 +1,17 @@
1
- import KnownStore from "../../agent/KnownStore.js";
1
+ import Entries from "../../agent/Entries.js";
2
2
  import { countTokens } from "../../agent/tokens.js";
3
+ import File from "../file/file.js";
3
4
  import Hedberg, { generatePatch } from "../hedberg/hedberg.js";
4
5
  import { storePatternResult } from "../helpers.js";
5
6
  import docs from "./setDoc.js";
6
7
 
7
- const VALID_FIDELITY = { archived: 1, demoted: 1, promoted: 1 };
8
+ const VALID_VISIBILITY = { archived: 1, summarized: 1, visible: 1 };
9
+ const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
10
+
11
+ function isSetProposal(path) {
12
+ const m = LOG_ACTION_RE.exec(path);
13
+ return m?.[1] === "set";
14
+ }
8
15
 
9
16
  // biome-ignore lint/suspicious/noShadowRestrictedNames: tool name is "set"
10
17
  export default class Set {
@@ -14,24 +21,121 @@ export default class Set {
14
21
  this.#core = core;
15
22
  core.registerScheme();
16
23
  core.on("handler", this.handler.bind(this));
17
- core.on("promoted", this.full.bind(this));
18
- core.on("demoted", this.summary.bind(this));
19
- core.on("turn.proposing", this.#materializeRevisions.bind(this));
24
+ core.on("visible", this.full.bind(this));
25
+ core.on("summarized", this.summary.bind(this));
26
+ core.on("proposal.prepare", this.#materializeRevisions.bind(this));
20
27
  core.filter("instructions.toolDocs", async (docsMap) => {
21
28
  docsMap.set = docs;
22
29
  return docsMap;
23
30
  });
31
+ core.filter("proposal.accepting", this.#vetoReadonly.bind(this));
32
+ core.filter("proposal.content", this.#preferExistingBody.bind(this));
33
+ core.on("proposal.accepted", this.#materializeFile.bind(this));
34
+ }
35
+
36
+ async #vetoReadonly(current, ctx) {
37
+ if (current) return current;
38
+ if (!isSetProposal(ctx.path)) return current;
39
+ if (!ctx.attrs?.path) return current;
40
+ const blocked = await File.isReadonly(
41
+ ctx.db,
42
+ ctx.projectId,
43
+ ctx.attrs.path,
44
+ );
45
+ if (!blocked) return current;
46
+ return {
47
+ allow: false,
48
+ outcome: "readonly",
49
+ body: `refused: ${ctx.attrs.path} is readonly`,
50
+ };
51
+ }
52
+
53
+ async #preferExistingBody(defaultBody, ctx) {
54
+ if (!isSetProposal(ctx.path)) return defaultBody;
55
+ const existing = await ctx.entries.getBody(ctx.runId, ctx.path);
56
+ if (existing) return existing;
57
+ return defaultBody;
58
+ }
59
+
60
+ async #materializeFile(ctx) {
61
+ if (!isSetProposal(ctx.path)) return;
62
+ const { attrs, runId, projectId, projectRoot, db, entries } = ctx;
63
+ if (!attrs?.path || !attrs?.merge) return;
64
+
65
+ const existing = await entries.getBody(runId, attrs.path);
66
+ const isNewFile = existing === null;
67
+ const fileBody = isNewFile ? "" : existing;
68
+ const blocks = attrs.merge.split(/(?=<<<<<<< SEARCH)/);
69
+ let patched = fileBody;
70
+ for (const block of blocks) {
71
+ const m = block.match(
72
+ /<<<<<<< SEARCH\n?([\s\S]*?)\n?=======\n?([\s\S]*?)\n?>>>>>>> REPLACE/,
73
+ );
74
+ if (!m) continue;
75
+ if (m[1] === "") {
76
+ patched = m[2];
77
+ } else {
78
+ patched = patched.replace(m[1], m[2]);
79
+ }
80
+ }
81
+ const turn = (await db.get_run_by_id.get({ id: runId })).next_turn;
82
+ // Preserve the file entry's current visibility — a <get>
83
+ // earlier in the run may have promoted it. Updating the
84
+ // body without specifying visibility falls through to
85
+ // the data-category default ("summarized") and wipes
86
+ // the promotion, making the model re-get the file next
87
+ // turn (then cycle-strike out).
88
+ const existingState = await entries.getState(runId, attrs.path);
89
+ await entries.set({
90
+ runId,
91
+ turn,
92
+ path: attrs.path,
93
+ body: patched,
94
+ visibility: existingState?.visibility,
95
+ });
96
+ if (projectRoot) {
97
+ const { writeFile } = await import("node:fs/promises");
98
+ const { join } = await import("node:path");
99
+ await writeFile(join(projectRoot, attrs.path), patched).catch(() => {});
100
+ }
101
+ if (isNewFile && projectId) {
102
+ await File.setConstraint(db, projectId, attrs.path, "active");
103
+ }
24
104
  }
25
105
 
26
106
  async handler(entry, rummy) {
27
107
  const { entries: store, sequence: turn, runId, loopId } = rummy;
28
108
  const attrs = entry.attributes;
29
- const fidelityAttr = VALID_FIDELITY[attrs.fidelity] ? attrs.fidelity : null;
109
+ const visibilityAttr = VALID_VISIBILITY[attrs.visibility]
110
+ ? attrs.visibility
111
+ : null;
30
112
  const rawSummary = typeof attrs.summary === "string" ? attrs.summary : null;
31
113
  const summaryText = rawSummary ? rawSummary.slice(0, 80) : null;
32
114
 
33
- // Pure fidelity/metadata change no body content
34
- if (!entry.body && fidelityAttr && attrs.path) {
115
+ // Invalid visibility value on a body-less set: reject with an
116
+ // error instead of falling through to the write path. Without
117
+ // this guard, a typo like visibility="promoted" (pre-migration
118
+ // terminology) silently body-wiped the target — the fidelity
119
+ // regression that cost us multiple demo runs.
120
+ if (
121
+ !entry.body &&
122
+ attrs.path &&
123
+ attrs.visibility !== undefined &&
124
+ !visibilityAttr
125
+ ) {
126
+ await rummy.hooks.error.log.emit({
127
+ store,
128
+ runId,
129
+ turn,
130
+ loopId,
131
+ message: `Invalid visibility "${attrs.visibility}" on <set path="${attrs.path}"/>. Use visibility="visible|summarized|archived".`,
132
+ status: 400,
133
+ });
134
+ return;
135
+ }
136
+
137
+ // Pure visibility/metadata change — no body content
138
+ if (!entry.body && visibilityAttr && attrs.path) {
35
139
  const target = attrs.path;
36
140
  const matches = await store.getEntriesByPattern(
37
141
  runId,
@@ -39,33 +143,44 @@ export default class Set {
39
143
  attrs.body,
40
144
  );
41
145
  if (matches.length === 0) {
42
- await store.upsert(
146
+ await store.set({
43
147
  runId,
44
148
  turn,
45
- entry.resultPath,
46
- `${target} not found`,
47
- 404,
48
- { fidelity: "archived", loopId },
49
- );
149
+ path: entry.resultPath,
150
+ body: `${target} not found`,
151
+ state: "failed",
152
+ outcome: "not_found",
153
+ visibility: "archived",
154
+ loopId,
155
+ });
50
156
  return;
51
157
  }
52
158
  for (const match of matches) {
53
- await store.setFidelity(runId, match.path, fidelityAttr);
159
+ await store.set({
160
+ runId: runId,
161
+ path: match.path,
162
+ visibility: visibilityAttr,
163
+ });
54
164
  if (summaryText) {
55
- await store.setAttributes(runId, match.path, {
56
- summary: summaryText,
165
+ await store.set({
166
+ runId: runId,
167
+ path: match.path,
168
+ attributes: {
169
+ summary: summaryText,
170
+ },
57
171
  });
58
172
  }
59
173
  }
60
- const label = `set to ${fidelityAttr}`;
61
- await store.upsert(
174
+ const label = `set to ${visibilityAttr}`;
175
+ await store.set({
62
176
  runId,
63
177
  turn,
64
- entry.resultPath,
65
- `${matches.map((m) => m.path).join(", ")} ${label}`,
66
- 200,
67
- { fidelity: "archived", loopId },
68
- );
178
+ path: entry.resultPath,
179
+ body: `${matches.map((m) => m.path).join(", ")} ${label}`,
180
+ state: "resolved",
181
+ visibility: "archived",
182
+ loopId,
183
+ });
69
184
  return;
70
185
  }
71
186
 
@@ -95,18 +210,32 @@ export default class Set {
95
210
  const target = attrs.path;
96
211
  if (!target) return;
97
212
 
98
- const scheme = KnownStore.scheme(target);
213
+ const scheme = Entries.scheme(target);
99
214
  if (scheme === null) {
100
215
  // File write — diff against existing content
101
216
  const existing = await store.getBody(runId, target);
102
- const oldContent = existing ?? "";
103
- const newContent = entry.body || "";
217
+ const oldContent = existing === null ? "" : existing;
218
+ const newContent = entry.body;
104
219
  const udiff = generatePatch(target, oldContent, newContent);
105
220
  const merge = oldContent
106
221
  ? `<<<<<<< SEARCH\n${oldContent}\n=======\n${newContent}\n>>>>>>> REPLACE`
107
222
  : `<<<<<<< SEARCH\n=======\n${newContent}\n>>>>>>> REPLACE`;
108
- await store.upsert(runId, turn, entry.resultPath, oldContent, 202, {
109
- attributes: { file: target, patch: udiff, merge },
223
+ const beforeTokens = oldContent ? countTokens(oldContent) : 0;
224
+ const afterTokens = countTokens(newContent);
225
+ await store.set({
226
+ runId,
227
+ turn,
228
+ path: entry.resultPath,
229
+ body: newContent,
230
+ state: "proposed",
231
+ attributes: {
232
+ path: target,
233
+ patch: udiff,
234
+ merge,
235
+ beforeTokens,
236
+ afterTokens,
237
+ summary: summaryText,
238
+ },
110
239
  loopId,
111
240
  });
112
241
  } else if (attrs.filter || target.includes("*")) {
@@ -116,12 +245,12 @@ export default class Set {
116
245
  target,
117
246
  attrs.filter,
118
247
  );
119
- await store.updateBodyByPattern(
120
- runId,
121
- target,
122
- attrs.filter || null,
123
- entry.body,
124
- );
248
+ await store.set({
249
+ runId: runId,
250
+ path: target,
251
+ body: entry.body,
252
+ bodyFilter: attrs.filter === undefined ? null : attrs.filter,
253
+ });
125
254
  await storePatternResult(
126
255
  store,
127
256
  runId,
@@ -133,42 +262,91 @@ export default class Set {
133
262
  { loopId },
134
263
  );
135
264
  } else {
136
- // Direct scheme write
137
- await store.upsert(runId, turn, target, entry.body, 200, {
138
- fidelity: fidelityAttr || "promoted",
265
+ // Direct scheme write (known://, unknown://, etc.)
266
+ // Same result shape as file writes — diff against existing.
267
+ const existing = await store.getBody(runId, target);
268
+ const oldContent = existing === null ? "" : existing;
269
+ const newContent = entry.body;
270
+ const udiff = generatePatch(target, oldContent, newContent);
271
+ const merge = oldContent
272
+ ? `<<<<<<< SEARCH\n${oldContent}\n=======\n${newContent}\n>>>>>>> REPLACE`
273
+ : `<<<<<<< SEARCH\n=======\n${newContent}\n>>>>>>> REPLACE`;
274
+ const beforeTokens = oldContent ? countTokens(oldContent) : 0;
275
+ const afterTokens = countTokens(newContent);
276
+
277
+ await store.set({
278
+ runId,
279
+ turn,
280
+ path: target,
281
+ body: newContent,
282
+ state: "resolved",
283
+ // Scheme writes default to promoted — the model wrote it, so
284
+ // it's material unless they explicitly demote/archive.
285
+ visibility: visibilityAttr ? visibilityAttr : "visible",
139
286
  attributes: summaryText ? { summary: summaryText } : null,
140
287
  loopId,
141
288
  });
289
+ await store.set({
290
+ runId,
291
+ turn,
292
+ path: entry.resultPath,
293
+ body: newContent,
294
+ state: "resolved",
295
+ loopId,
296
+ attributes: {
297
+ path: target,
298
+ patch: udiff,
299
+ merge,
300
+ beforeTokens,
301
+ afterTokens,
302
+ summary: summaryText,
303
+ },
304
+ });
142
305
  }
143
306
  }
144
307
 
145
- // Apply fidelity after all write operations
146
- if (fidelityAttr && attrs.path) {
308
+ // Apply visibility after all write operations
309
+ if (visibilityAttr && attrs.path) {
147
310
  const target = attrs.path;
148
- const scheme = KnownStore.scheme(target);
311
+ const scheme = Entries.scheme(target);
149
312
  if (scheme !== null) {
150
- await store.setFidelity(runId, target, fidelityAttr);
313
+ await store.set({
314
+ runId: runId,
315
+ path: target,
316
+ visibility: visibilityAttr,
317
+ });
151
318
  }
152
319
  if (summaryText) {
153
- await store.setAttributes(runId, target, { summary: summaryText });
320
+ await store.set({
321
+ runId: runId,
322
+ path: target,
323
+ attributes: { summary: summaryText },
324
+ });
154
325
  }
155
326
  }
156
327
  }
157
328
 
158
329
  full(entry) {
159
330
  const attrs = entry.attributes;
160
- const file = attrs.file || entry.path;
161
- if (attrs.error) return `# set ${file}\n${attrs.error}`;
331
+ const target = attrs.path || entry.path;
332
+ if (attrs.error) return `# set ${target}\n${attrs.error}`;
162
333
  const tokens =
163
334
  attrs.beforeTokens != null
164
335
  ? ` ${attrs.beforeTokens}→${attrs.afterTokens} tokens`
165
336
  : "";
166
- if (!attrs.merge) return `# set ${file}${tokens}`;
167
- return `# set ${file}${tokens}\n${attrs.merge}`;
337
+ if (!attrs.merge) return `# set ${target}${tokens}`;
338
+ return `# set ${target}${tokens}\n${attrs.merge}`;
168
339
  }
169
340
 
170
- summary() {
171
- return "";
341
+ summary(entry) {
342
+ if (!entry.body) return "";
343
+ // Preserve SEARCH/REPLACE merge blocks intact — truncating them
344
+ // drops the before/after the model needs to recognize its edit.
345
+ if (/<<<<<<< SEARCH[\s\S]*>>>>>>> REPLACE/.test(entry.body)) {
346
+ return entry.body;
347
+ }
348
+ const flat = entry.body.replace(/\s+/g, " ").trim();
349
+ return flat.length <= 80 ? flat : `${flat.slice(0, 77)}...`;
172
350
  }
173
351
 
174
352
  async #processEdit(rummy, entry, attrs) {
@@ -177,8 +355,14 @@ export default class Set {
177
355
  const matches = await store.getEntriesByPattern(runId, target, attrs.body);
178
356
 
179
357
  if (matches.length === 0) {
180
- await store.upsert(runId, turn, entry.resultPath, "", 404, {
181
- attributes: { file: target, error: `${target} not found in context` },
358
+ await store.set({
359
+ runId,
360
+ turn,
361
+ path: entry.resultPath,
362
+ body: "",
363
+ state: "failed",
364
+ outcome: "not_found",
365
+ attributes: { path: target, error: `${target} not found in context` },
182
366
  loopId,
183
367
  });
184
368
  return;
@@ -186,37 +370,78 @@ export default class Set {
186
370
 
187
371
  for (const match of matches) {
188
372
  if (match.scheme === null) {
373
+ // Bare file path — apply the edit immediately against the
374
+ // match body so the log carries a concrete before/after
375
+ // merge. #materializeRevisions still runs at turn-end to
376
+ // consolidate the set:// proposal for client acceptance.
189
377
  const canonicalPath = `set://${match.path}`;
190
378
  const revision = Set.#buildRevision(attrs);
191
379
  const existingAttrs = await rummy.getAttributes(canonicalPath);
192
- const revisions = existingAttrs?.revisions || [];
380
+ const revisions = existingAttrs?.revisions
381
+ ? existingAttrs.revisions
382
+ : [];
193
383
  revisions.push(revision);
194
- await store.upsert(runId, turn, canonicalPath, "", 200, {
195
- attributes: { file: match.path, revisions },
384
+ await store.set({
385
+ runId,
386
+ turn,
387
+ path: canonicalPath,
388
+ body: "",
389
+ state: "resolved",
390
+ attributes: { path: match.path, revisions },
391
+ loopId,
392
+ });
393
+ const { patch, searchText, replaceText, warning, error } =
394
+ Set.#applyRevision(match.body, attrs);
395
+ const merge =
396
+ searchText != null
397
+ ? `<<<<<<< SEARCH\n${searchText}\n=======\n${replaceText}\n>>>>>>> REPLACE`
398
+ : null;
399
+ const beforeTokens = match.tokens;
400
+ const afterTokens = patch ? countTokens(patch) : beforeTokens;
401
+ const logState = error ? "failed" : "resolved";
402
+ await store.set({
403
+ runId,
404
+ turn,
405
+ path: entry.resultPath,
406
+ body: merge ?? (patch || `edit to ${match.path}`),
407
+ state: logState,
408
+ outcome: error ? "conflict" : null,
409
+ attributes: {
410
+ path: match.path,
411
+ merge,
412
+ beforeTokens,
413
+ afterTokens,
414
+ warning,
415
+ error,
416
+ },
196
417
  loopId,
197
418
  });
198
- if (KnownStore.normalizePath(entry.resultPath) !== canonicalPath) {
199
- await store.remove(runId, entry.resultPath);
200
- }
201
419
  return;
202
420
  }
203
421
 
204
422
  const { patch, searchText, replaceText, warning, error } =
205
423
  Set.#applyRevision(match.body, attrs);
206
424
 
207
- const status = error ? 409 : 200;
208
- const resultPath = `set://${match.path}`;
425
+ const state = error ? "failed" : "resolved";
426
+ const outcome = error ? "conflict" : null;
209
427
  const udiff = patch ? generatePatch(match.path, match.body, patch) : null;
210
428
  const merge =
211
429
  searchText != null
212
430
  ? `<<<<<<< SEARCH\n${searchText}\n=======\n${replaceText}\n>>>>>>> REPLACE`
213
431
  : null;
214
- const beforeTokens = match.tokens || 0;
432
+ const beforeTokens = match.tokens;
215
433
  const afterTokens = patch ? countTokens(patch) : beforeTokens;
216
434
 
217
- await store.upsert(runId, turn, resultPath, match.body, status, {
435
+ // Log entry at log://turn_N/set/<target> records the action.
436
+ await store.set({
437
+ runId,
438
+ turn,
439
+ path: entry.resultPath,
440
+ body: patch ?? match.body,
441
+ state,
442
+ outcome,
218
443
  attributes: {
219
- file: match.path,
444
+ path: match.path,
220
445
  patch: udiff,
221
446
  merge,
222
447
  beforeTokens,
@@ -227,8 +452,13 @@ export default class Set {
227
452
  loopId,
228
453
  });
229
454
 
230
- if (status === 200 && patch) {
231
- await store.upsert(runId, turn, match.path, patch, match.status, {
455
+ if (state === "resolved" && patch) {
456
+ await store.set({
457
+ runId,
458
+ turn,
459
+ path: match.path,
460
+ body: patch,
461
+ state: match.state,
232
462
  loopId,
233
463
  });
234
464
  }
@@ -246,11 +476,11 @@ export default class Set {
246
476
  : entry.attributes;
247
477
  if (!attrs?.revisions?.length) continue;
248
478
 
249
- const filePath = attrs.file;
250
- const fileEntry = await store.getEntriesByPattern(runId, filePath);
251
- if (fileEntry.length === 0) continue;
479
+ const entryPath = attrs.path;
480
+ const targetEntry = await store.getEntriesByPattern(runId, entryPath);
481
+ if (targetEntry.length === 0) continue;
252
482
 
253
- const original = fileEntry[0].body;
483
+ const original = targetEntry[0].body;
254
484
  let current = original;
255
485
  const mergeBlocks = [];
256
486
  let lastError = null;
@@ -272,18 +502,25 @@ export default class Set {
272
502
  }
273
503
  }
274
504
 
275
- const state = lastError ? 409 : 202;
505
+ const state = lastError ? "failed" : "proposed";
506
+ const outcome = lastError ? "conflict" : null;
276
507
  const udiff =
277
508
  current !== original
278
- ? generatePatch(filePath, original, current)
509
+ ? generatePatch(entryPath, original, current)
279
510
  : null;
280
511
  const merge = mergeBlocks.length > 0 ? mergeBlocks.join("\n") : null;
281
- const beforeTokens = fileEntry[0].tokens || 0;
512
+ const beforeTokens = targetEntry[0].tokens;
282
513
  const afterTokens = current ? countTokens(current) : beforeTokens;
283
514
 
284
- await store.upsert(runId, turn, entry.path, original, state, {
515
+ await store.set({
516
+ runId,
517
+ turn,
518
+ path: entry.path,
519
+ body: current,
520
+ state,
521
+ outcome,
285
522
  attributes: {
286
- file: filePath,
523
+ path: entryPath,
287
524
  patch: udiff,
288
525
  merge,
289
526
  beforeTokens,
@@ -296,9 +533,15 @@ export default class Set {
296
533
  }
297
534
  }
298
535
 
536
+ // `replace` attr is optional in search/replace form — absence means
537
+ // "delete the match"; normalize to empty string at this boundary.
538
+ static #resolveReplace(attrs) {
539
+ return attrs.replace === undefined ? "" : attrs.replace;
540
+ }
541
+
299
542
  static #buildRevision(attrs) {
300
543
  if (attrs.search != null) {
301
- return { search: attrs.search, replace: attrs.replace ?? "" };
544
+ return { search: attrs.search, replace: Set.#resolveReplace(attrs) };
302
545
  }
303
546
  if (attrs.blocks?.length > 0) {
304
547
  return { blocks: attrs.blocks };
@@ -308,7 +551,7 @@ export default class Set {
308
551
 
309
552
  static #applyRevision(body, attrs) {
310
553
  if (attrs.search != null) {
311
- return Hedberg.replace(body, attrs.search, attrs.replace ?? "", {
554
+ return Hedberg.replace(body, attrs.search, Set.#resolveReplace(attrs), {
312
555
  sed: attrs.sed,
313
556
  flags: attrs.flags,
314
557
  });
@@ -1,36 +1,3 @@
1
- // Tool doc for <set>. 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
- '## <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
- =======
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
- ],
34
- ];
1
+ import { loadDoc } from "../helpers.js";
35
2
 
36
- export default LINES.map(([text]) => text).join("\n");
3
+ export default loadDoc(import.meta.url, "setDoc.md");
@@ -0,0 +1,22 @@
1
+ ## <set path="[path/to/file]">[content or edit]</set> - Create, edit, or update a file or entry
2
+
3
+ Example: <set path="known://project/milestones" visibility="summarized" summary="milestone,deadline,2026"/>
4
+ <!-- Visibility control first — most unique capability of set. -->
5
+
6
+ Example: <set path="src/app.js">
7
+ <<<<<<< SEARCH
8
+ old text
9
+ =======
10
+ new text
11
+ >>>>>>> REPLACE
12
+ </set>
13
+ <!-- SEARCH/REPLACE block — primary edit pattern for existing files. -->
14
+
15
+ Example: <set path="src/config.js">s/port = 3000/port = 8080/g;s/We're almost done/We're done./g;</set>
16
+ <!-- Sed syntax: chained s/old/new/ patterns with semicolons. -->
17
+
18
+ Example: <set path="example.md">Full file content here</set>
19
+ <!-- Create: body contents are entire file. -->
20
+
21
+ * YOU MUST NOT use <sh></sh> or <env></env> to list, create, read, or edit files — use <get></get> and <set></set>
22
+ <!-- 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. -->
@@ -1,16 +1,39 @@
1
- # sh
1
+ # sh {#sh_plugin}
2
2
 
3
- Proposes shell command execution for client approval.
3
+ Proposes shell command execution for client approval. Streaming
4
+ producer: the actual stdout/stderr arrive as separate data entries
5
+ after the proposal is accepted.
4
6
 
5
7
  ## Registration
6
8
 
7
9
  - **Tool**: `sh`
8
- - **Category**: `logging`
9
- - **Handler**: Upserts the entry at status 202 (proposed). The client must approve execution.
10
+ - **Scheme**: `sh` — `category: "data"` (channels only; see below)
11
+ - **Handler**: Upserts the proposal entry at status 202 (proposed). The
12
+ client must approve execution.
13
+
14
+ ## Two namespaces per invocation
15
+
16
+ A single `<sh>` emission produces entries in two namespaces — one audit
17
+ record, one data payload:
18
+
19
+ - **Log entry**: `log://turn_N/sh/{slug}` — scheme=`log`, category=`logging`.
20
+ This is the proposal the client sees and resolves. On accept, body is
21
+ rewritten to `ran '{cmd}' (in progress). Output: {dataBase}_1, {dataBase}_2`
22
+ and finalized by `stream/completed` with exit code + duration. Renders
23
+ inside the `<log>` block as `<sh>`.
24
+ - **Data channels**: `sh://turn_N/{slug}_1` (stdout), `sh://turn_N/{slug}_2`
25
+ (stderr) — scheme=`sh`, category=`data`. Created at status=102 on
26
+ proposal acceptance, grow via the `stream` RPC, transition to 200/500
27
+ via `stream/completed`. Render inside the `<context>` block as `<sh>`.
28
+
29
+ The `sh` scheme exists **only** for the data channels. The proposal/log
30
+ entry itself is in the unified `log://` namespace along with every
31
+ other action record. See [scheme_category_split](#scheme_category_split).
10
32
 
11
33
  ## Projection
12
34
 
13
- Shows `sh {command}` followed by the entry body.
35
+ - **Visible**: `# sh {command}\n{body}` (channel body is the captured stream).
36
+ - **Summarized**: empty (the command + path are already shown via attrs).
14
37
 
15
38
  ## Behavior
16
39