@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,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 = { archive: 1, summary: 1, full: 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,25 +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("full", this.full.bind(this));
18
- core.on("summary", 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;
30
- const rawSummary =
31
- typeof attrs.summary === "string" ? attrs.summary : null;
109
+ const visibilityAttr = VALID_VISIBILITY[attrs.visibility]
110
+ ? attrs.visibility
111
+ : null;
112
+ const rawSummary = typeof attrs.summary === "string" ? attrs.summary : null;
32
113
  const summaryText = rawSummary ? rawSummary.slice(0, 80) : null;
33
114
 
34
- // Pure fidelity/metadata change no body content
35
- 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) {
36
139
  const target = attrs.path;
37
140
  const matches = await store.getEntriesByPattern(
38
141
  runId,
@@ -40,34 +143,44 @@ export default class Set {
40
143
  attrs.body,
41
144
  );
42
145
  if (matches.length === 0) {
43
- await store.upsert(
146
+ await store.set({
44
147
  runId,
45
148
  turn,
46
- entry.resultPath,
47
- `${target} not found`,
48
- 404,
49
- { fidelity: "archive", loopId },
50
- );
149
+ path: entry.resultPath,
150
+ body: `${target} not found`,
151
+ state: "failed",
152
+ outcome: "not_found",
153
+ visibility: "archived",
154
+ loopId,
155
+ });
51
156
  return;
52
157
  }
53
158
  for (const match of matches) {
54
- await store.setFidelity(runId, match.path, fidelityAttr);
159
+ await store.set({
160
+ runId: runId,
161
+ path: match.path,
162
+ visibility: visibilityAttr,
163
+ });
55
164
  if (summaryText) {
56
- await store.setAttributes(runId, match.path, {
57
- summary: summaryText,
165
+ await store.set({
166
+ runId: runId,
167
+ path: match.path,
168
+ attributes: {
169
+ summary: summaryText,
170
+ },
58
171
  });
59
172
  }
60
173
  }
61
- const label =
62
- fidelityAttr === "archive" ? "archived" : `set to ${fidelityAttr}`;
63
- await store.upsert(
174
+ const label = `set to ${visibilityAttr}`;
175
+ await store.set({
64
176
  runId,
65
177
  turn,
66
- entry.resultPath,
67
- `${matches.map((m) => m.path).join(", ")} ${label}`,
68
- 200,
69
- { fidelity: "archive", loopId },
70
- );
178
+ path: entry.resultPath,
179
+ body: `${matches.map((m) => m.path).join(", ")} ${label}`,
180
+ state: "resolved",
181
+ visibility: "archived",
182
+ loopId,
183
+ });
71
184
  return;
72
185
  }
73
186
 
@@ -97,18 +210,32 @@ export default class Set {
97
210
  const target = attrs.path;
98
211
  if (!target) return;
99
212
 
100
- const scheme = KnownStore.scheme(target);
213
+ const scheme = Entries.scheme(target);
101
214
  if (scheme === null) {
102
215
  // File write — diff against existing content
103
216
  const existing = await store.getBody(runId, target);
104
- const oldContent = existing ?? "";
105
- const newContent = entry.body || "";
217
+ const oldContent = existing === null ? "" : existing;
218
+ const newContent = entry.body;
106
219
  const udiff = generatePatch(target, oldContent, newContent);
107
220
  const merge = oldContent
108
221
  ? `<<<<<<< SEARCH\n${oldContent}\n=======\n${newContent}\n>>>>>>> REPLACE`
109
222
  : `<<<<<<< SEARCH\n=======\n${newContent}\n>>>>>>> REPLACE`;
110
- await store.upsert(runId, turn, entry.resultPath, oldContent, 202, {
111
- 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
+ },
112
239
  loopId,
113
240
  });
114
241
  } else if (attrs.filter || target.includes("*")) {
@@ -118,12 +245,12 @@ export default class Set {
118
245
  target,
119
246
  attrs.filter,
120
247
  );
121
- await store.updateBodyByPattern(
122
- runId,
123
- target,
124
- attrs.filter || null,
125
- entry.body,
126
- );
248
+ await store.set({
249
+ runId: runId,
250
+ path: target,
251
+ body: entry.body,
252
+ bodyFilter: attrs.filter === undefined ? null : attrs.filter,
253
+ });
127
254
  await storePatternResult(
128
255
  store,
129
256
  runId,
@@ -135,42 +262,91 @@ export default class Set {
135
262
  { loopId },
136
263
  );
137
264
  } else {
138
- // Direct scheme write
139
- await store.upsert(runId, turn, target, entry.body, 200, {
140
- fidelity: fidelityAttr || "full",
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",
141
286
  attributes: summaryText ? { summary: summaryText } : null,
142
287
  loopId,
143
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
+ });
144
305
  }
145
306
  }
146
307
 
147
- // Apply fidelity after all write operations
148
- if (fidelityAttr && attrs.path) {
308
+ // Apply visibility after all write operations
309
+ if (visibilityAttr && attrs.path) {
149
310
  const target = attrs.path;
150
- const scheme = KnownStore.scheme(target);
311
+ const scheme = Entries.scheme(target);
151
312
  if (scheme !== null) {
152
- await store.setFidelity(runId, target, fidelityAttr);
313
+ await store.set({
314
+ runId: runId,
315
+ path: target,
316
+ visibility: visibilityAttr,
317
+ });
153
318
  }
154
319
  if (summaryText) {
155
- await store.setAttributes(runId, target, { summary: summaryText });
320
+ await store.set({
321
+ runId: runId,
322
+ path: target,
323
+ attributes: { summary: summaryText },
324
+ });
156
325
  }
157
326
  }
158
327
  }
159
328
 
160
329
  full(entry) {
161
330
  const attrs = entry.attributes;
162
- const file = attrs.file || entry.path;
163
- 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}`;
164
333
  const tokens =
165
334
  attrs.beforeTokens != null
166
335
  ? ` ${attrs.beforeTokens}→${attrs.afterTokens} tokens`
167
336
  : "";
168
- if (!attrs.merge) return `# set ${file}${tokens}`;
169
- return `# set ${file}${tokens}\n${attrs.merge}`;
337
+ if (!attrs.merge) return `# set ${target}${tokens}`;
338
+ return `# set ${target}${tokens}\n${attrs.merge}`;
170
339
  }
171
340
 
172
341
  summary(entry) {
173
- return entry.attributes.merge || "";
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)}...`;
174
350
  }
175
351
 
176
352
  async #processEdit(rummy, entry, attrs) {
@@ -179,8 +355,14 @@ export default class Set {
179
355
  const matches = await store.getEntriesByPattern(runId, target, attrs.body);
180
356
 
181
357
  if (matches.length === 0) {
182
- await store.upsert(runId, turn, entry.resultPath, "", 404, {
183
- 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` },
184
366
  loopId,
185
367
  });
186
368
  return;
@@ -188,37 +370,78 @@ export default class Set {
188
370
 
189
371
  for (const match of matches) {
190
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.
191
377
  const canonicalPath = `set://${match.path}`;
192
378
  const revision = Set.#buildRevision(attrs);
193
379
  const existingAttrs = await rummy.getAttributes(canonicalPath);
194
- const revisions = existingAttrs?.revisions || [];
380
+ const revisions = existingAttrs?.revisions
381
+ ? existingAttrs.revisions
382
+ : [];
195
383
  revisions.push(revision);
196
- await store.upsert(runId, turn, canonicalPath, "", 200, {
197
- 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
+ },
198
417
  loopId,
199
418
  });
200
- if (KnownStore.normalizePath(entry.resultPath) !== canonicalPath) {
201
- await store.remove(runId, entry.resultPath);
202
- }
203
419
  return;
204
420
  }
205
421
 
206
422
  const { patch, searchText, replaceText, warning, error } =
207
423
  Set.#applyRevision(match.body, attrs);
208
424
 
209
- const status = error ? 409 : 200;
210
- const resultPath = `set://${match.path}`;
425
+ const state = error ? "failed" : "resolved";
426
+ const outcome = error ? "conflict" : null;
211
427
  const udiff = patch ? generatePatch(match.path, match.body, patch) : null;
212
428
  const merge =
213
429
  searchText != null
214
430
  ? `<<<<<<< SEARCH\n${searchText}\n=======\n${replaceText}\n>>>>>>> REPLACE`
215
431
  : null;
216
- const beforeTokens = match.tokens || 0;
432
+ const beforeTokens = match.tokens;
217
433
  const afterTokens = patch ? countTokens(patch) : beforeTokens;
218
434
 
219
- 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,
220
443
  attributes: {
221
- file: match.path,
444
+ path: match.path,
222
445
  patch: udiff,
223
446
  merge,
224
447
  beforeTokens,
@@ -229,8 +452,13 @@ export default class Set {
229
452
  loopId,
230
453
  });
231
454
 
232
- if (status === 200 && patch) {
233
- 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,
234
462
  loopId,
235
463
  });
236
464
  }
@@ -248,11 +476,11 @@ export default class Set {
248
476
  : entry.attributes;
249
477
  if (!attrs?.revisions?.length) continue;
250
478
 
251
- const filePath = attrs.file;
252
- const fileEntry = await store.getEntriesByPattern(runId, filePath);
253
- if (fileEntry.length === 0) continue;
479
+ const entryPath = attrs.path;
480
+ const targetEntry = await store.getEntriesByPattern(runId, entryPath);
481
+ if (targetEntry.length === 0) continue;
254
482
 
255
- const original = fileEntry[0].body;
483
+ const original = targetEntry[0].body;
256
484
  let current = original;
257
485
  const mergeBlocks = [];
258
486
  let lastError = null;
@@ -274,18 +502,25 @@ export default class Set {
274
502
  }
275
503
  }
276
504
 
277
- const state = lastError ? 409 : 202;
505
+ const state = lastError ? "failed" : "proposed";
506
+ const outcome = lastError ? "conflict" : null;
278
507
  const udiff =
279
508
  current !== original
280
- ? generatePatch(filePath, original, current)
509
+ ? generatePatch(entryPath, original, current)
281
510
  : null;
282
511
  const merge = mergeBlocks.length > 0 ? mergeBlocks.join("\n") : null;
283
- const beforeTokens = fileEntry[0].tokens || 0;
512
+ const beforeTokens = targetEntry[0].tokens;
284
513
  const afterTokens = current ? countTokens(current) : beforeTokens;
285
514
 
286
- 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,
287
522
  attributes: {
288
- file: filePath,
523
+ path: entryPath,
289
524
  patch: udiff,
290
525
  merge,
291
526
  beforeTokens,
@@ -298,9 +533,15 @@ export default class Set {
298
533
  }
299
534
  }
300
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
+
301
542
  static #buildRevision(attrs) {
302
543
  if (attrs.search != null) {
303
- return { search: attrs.search, replace: attrs.replace ?? "" };
544
+ return { search: attrs.search, replace: Set.#resolveReplace(attrs) };
304
545
  }
305
546
  if (attrs.blocks?.length > 0) {
306
547
  return { blocks: attrs.blocks };
@@ -310,7 +551,7 @@ export default class Set {
310
551
 
311
552
  static #applyRevision(body, attrs) {
312
553
  if (attrs.search != null) {
313
- return Hedberg.replace(body, attrs.search, attrs.replace ?? "", {
554
+ return Hedberg.replace(body, attrs.search, Set.#resolveReplace(attrs), {
314
555
  sed: attrs.sed,
315
556
  flags: attrs.flags,
316
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="summary" 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/host = 127.0.0.1/host = localhost/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/> or <env/> to list, create, read, or edit files. Use the Tool Commands.",
32
- "Forces file operations through set/get.",
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