@possumtech/rummy 2.1.0 → 2.2.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 (140) hide show
  1. package/.env.example +40 -15
  2. package/.xai.key +1 -0
  3. package/PLUGINS.md +169 -53
  4. package/README.md +38 -32
  5. package/SPEC.md +366 -179
  6. package/bin/digest.js +1097 -0
  7. package/biome/no-fallbacks.grit +2 -2
  8. package/gemini.key +1 -0
  9. package/lang/en.json +10 -1
  10. package/migrations/001_initial_schema.sql +9 -2
  11. package/package.json +19 -8
  12. package/service.js +1 -0
  13. package/src/agent/AgentLoop.js +76 -26
  14. package/src/agent/ContextAssembler.js +2 -0
  15. package/src/agent/Entries.js +238 -60
  16. package/src/agent/ProjectAgent.js +44 -0
  17. package/src/agent/TurnExecutor.js +99 -30
  18. package/src/agent/XmlParser.js +206 -111
  19. package/src/agent/errors.js +35 -0
  20. package/src/agent/known_queries.sql +1 -1
  21. package/src/agent/known_store.sql +3 -42
  22. package/src/agent/materializeContext.js +30 -1
  23. package/src/agent/runs.sql +8 -18
  24. package/src/agent/tokens.js +0 -1
  25. package/src/agent/turns.sql +1 -0
  26. package/src/hooks/Hooks.js +26 -0
  27. package/src/hooks/RummyContext.js +12 -1
  28. package/src/lib/hedberg/README.md +60 -0
  29. package/src/lib/hedberg/hedberg.js +60 -0
  30. package/src/lib/hedberg/marker.js +158 -0
  31. package/src/{plugins → lib}/hedberg/matcher.js +1 -2
  32. package/src/llm/LlmProvider.js +41 -3
  33. package/src/llm/openaiStream.js +17 -0
  34. package/src/plugins/ask_user/ask_user.js +12 -2
  35. package/src/plugins/ask_user/ask_userDoc.md +1 -5
  36. package/src/plugins/budget/README.md +29 -24
  37. package/src/plugins/budget/budget.js +166 -110
  38. package/src/plugins/cli/README.md +3 -4
  39. package/src/plugins/cli/cli.js +31 -5
  40. package/src/plugins/cloudflare/cloudflare.js +136 -0
  41. package/src/plugins/cp/cp.js +41 -4
  42. package/src/plugins/cp/cpDoc.md +5 -6
  43. package/src/plugins/engine/engine.sql +1 -1
  44. package/src/plugins/env/README.md +5 -4
  45. package/src/plugins/env/env.js +7 -4
  46. package/src/plugins/env/envDoc.md +7 -8
  47. package/src/plugins/error/error.js +56 -15
  48. package/src/plugins/file/README.md +12 -3
  49. package/src/plugins/file/file.js +2 -2
  50. package/src/plugins/get/get.js +59 -36
  51. package/src/plugins/get/getDoc.md +10 -34
  52. package/src/plugins/google/google.js +115 -0
  53. package/src/plugins/hedberg/hedberg.js +13 -56
  54. package/src/plugins/helpers.js +66 -12
  55. package/src/plugins/index.js +1 -2
  56. package/src/plugins/instructions/README.md +44 -47
  57. package/src/plugins/instructions/instructions-system.md +44 -0
  58. package/src/plugins/instructions/instructions-user.md +53 -0
  59. package/src/plugins/instructions/instructions.js +58 -189
  60. package/src/plugins/known/README.md +6 -7
  61. package/src/plugins/known/known.js +24 -30
  62. package/src/plugins/log/log.js +41 -32
  63. package/src/plugins/mv/mv.js +40 -1
  64. package/src/plugins/mv/mvDoc.md +1 -8
  65. package/src/plugins/ollama/ollama.js +4 -3
  66. package/src/plugins/openai/openai.js +4 -3
  67. package/src/plugins/openrouter/openrouter.js +14 -4
  68. package/src/plugins/persona/README.md +11 -13
  69. package/src/plugins/persona/default.md +29 -0
  70. package/src/plugins/persona/persona.js +10 -66
  71. package/src/plugins/policy/policy.js +23 -22
  72. package/src/plugins/prompt/README.md +37 -27
  73. package/src/plugins/prompt/prompt.js +13 -19
  74. package/src/plugins/rm/rm.js +18 -0
  75. package/src/plugins/rm/rmDoc.md +5 -6
  76. package/src/plugins/rpc/rpc.js +3 -3
  77. package/src/plugins/set/set.js +205 -323
  78. package/src/plugins/set/setDoc.md +47 -17
  79. package/src/plugins/sh/README.md +6 -5
  80. package/src/plugins/sh/sh.js +8 -5
  81. package/src/plugins/sh/shDoc.md +7 -8
  82. package/src/plugins/skill/README.md +37 -14
  83. package/src/plugins/skill/skill.js +200 -101
  84. package/src/plugins/skill/skillDoc.js +3 -0
  85. package/src/plugins/skill/skillDoc.md +9 -0
  86. package/src/plugins/stream/README.md +7 -6
  87. package/src/plugins/stream/finalize.js +100 -0
  88. package/src/plugins/stream/stream.js +13 -45
  89. package/src/plugins/telemetry/telemetry.js +27 -4
  90. package/src/plugins/think/think.js +2 -3
  91. package/src/plugins/think/thinkDoc.md +2 -4
  92. package/src/plugins/unknown/README.md +1 -1
  93. package/src/plugins/unknown/unknown.js +17 -19
  94. package/src/plugins/update/update.js +4 -51
  95. package/src/plugins/update/updateDoc.md +21 -6
  96. package/src/plugins/xai/xai.js +68 -102
  97. package/src/plugins/yolo/yolo.js +102 -75
  98. package/src/sql/functions/hedmatch.js +1 -1
  99. package/src/sql/functions/hedreplace.js +1 -1
  100. package/src/sql/functions/hedsearch.js +1 -1
  101. package/src/sql/functions/slugify.js +16 -2
  102. package/BENCH_ENVIRONMENT.md +0 -230
  103. package/CLIENT_INTERFACE.md +0 -396
  104. package/last_run.txt +0 -5617
  105. package/scriptify/ask_run.js +0 -77
  106. package/scriptify/cache_probe.js +0 -66
  107. package/scriptify/cache_probe_grok.js +0 -74
  108. package/src/agent/budget.js +0 -33
  109. package/src/agent/config.js +0 -38
  110. package/src/plugins/hedberg/README.md +0 -71
  111. package/src/plugins/hedberg/docs.md +0 -0
  112. package/src/plugins/hedberg/edits.js +0 -55
  113. package/src/plugins/hedberg/normalize.js +0 -17
  114. package/src/plugins/hedberg/sed.js +0 -49
  115. package/src/plugins/instructions/instructions.md +0 -34
  116. package/src/plugins/instructions/instructions_104.md +0 -8
  117. package/src/plugins/instructions/instructions_105.md +0 -39
  118. package/src/plugins/instructions/instructions_106.md +0 -22
  119. package/src/plugins/instructions/instructions_107.md +0 -17
  120. package/src/plugins/instructions/instructions_108.md +0 -0
  121. package/src/plugins/known/knownDoc.js +0 -3
  122. package/src/plugins/known/knownDoc.md +0 -8
  123. package/src/plugins/unknown/unknownDoc.js +0 -3
  124. package/src/plugins/unknown/unknownDoc.md +0 -11
  125. package/turns/cli_1777462658211/turn_001.txt +0 -772
  126. package/turns/cli_1777462658211/turn_002.txt +0 -606
  127. package/turns/cli_1777462658211/turn_003.txt +0 -667
  128. package/turns/cli_1777462658211/turn_004.txt +0 -297
  129. package/turns/cli_1777462658211/turn_005.txt +0 -301
  130. package/turns/cli_1777462658211/turn_006.txt +0 -262
  131. package/turns/cli_1777465095132/turn_001.txt +0 -715
  132. package/turns/cli_1777465095132/turn_002.txt +0 -236
  133. package/turns/cli_1777465095132/turn_003.txt +0 -287
  134. package/turns/cli_1777465095132/turn_004.txt +0 -694
  135. package/turns/cli_1777465095132/turn_005.txt +0 -422
  136. package/turns/cli_1777465095132/turn_006.txt +0 -365
  137. package/turns/cli_1777465095132/turn_007.txt +0 -885
  138. package/turns/cli_1777465095132/turn_008.txt +0 -1277
  139. package/turns/cli_1777465095132/turn_009.txt +0 -736
  140. /package/src/{plugins → lib}/hedberg/patterns.js +0 -0
@@ -1,8 +1,8 @@
1
1
  import Entries from "../../agent/Entries.js";
2
2
  import { countTokens } from "../../agent/tokens.js";
3
+ import Hedberg, { generatePatch } from "../../lib/hedberg/hedberg.js";
3
4
  import File from "../file/file.js";
4
- import Hedberg, { generatePatch } from "../hedberg/hedberg.js";
5
- import { storePatternResult } from "../helpers.js";
5
+ import { SUMMARY_MAX_CHARS, storePatternResult } from "../helpers.js";
6
6
  import docs from "./setDoc.js";
7
7
 
8
8
  const VALID_VISIBILITY = { archived: 1, summarized: 1, visible: 1 };
@@ -13,6 +13,18 @@ function isSetProposal(path) {
13
13
  return m?.[1] === "set";
14
14
  }
15
15
 
16
+ // Cap the size of the current-body context surfaced on conflict. Big
17
+ // enough for typical known:// entries (plans, notes) and a useful slice
18
+ // of files; small enough that a 100k-line file doesn't blow the budget
19
+ // on every conflict. The model can `<get>` the path for the full body.
20
+ const CONFLICT_FEEDBACK_MAX_CHARS = 4000;
21
+ function truncateForFeedback(body) {
22
+ if (body == null) return null;
23
+ if (body.length <= CONFLICT_FEEDBACK_MAX_CHARS) return body;
24
+ const head = body.slice(0, CONFLICT_FEEDBACK_MAX_CHARS);
25
+ return `${head}\n[truncated; ${body.length - CONFLICT_FEEDBACK_MAX_CHARS} more chars — <get> the path for full body]`;
26
+ }
27
+
16
28
  // biome-ignore lint/suspicious/noShadowRestrictedNames: tool name is "set"
17
29
  export default class Set {
18
30
  #core;
@@ -23,13 +35,15 @@ export default class Set {
23
35
  core.on("handler", this.handler.bind(this));
24
36
  core.on("visible", this.full.bind(this));
25
37
  core.on("summarized", this.summary.bind(this));
26
- core.on("proposal.prepare", this.#materializeRevisions.bind(this));
27
38
  core.filter("instructions.toolDocs", async (docsMap) => {
28
39
  docsMap.set = docs;
29
40
  return docsMap;
30
41
  });
31
42
  core.filter("proposal.accepting", this.#vetoReadonly.bind(this));
32
43
  core.filter("proposal.content", this.#preferExistingBody.bind(this));
44
+ // Materialization is shape-coupled (attrs.path + attrs.patched), not
45
+ // path-coupled. Any plugin emitting a proposal in that shape
46
+ // (set, cp, future tools) gets fs materialization for free.
33
47
  core.on("proposal.accepted", this.#materializeFile.bind(this));
34
48
  }
35
49
 
@@ -58,35 +72,30 @@ export default class Set {
58
72
  }
59
73
 
60
74
  async #materializeFile(ctx) {
61
- if (!isSetProposal(ctx.path)) return;
62
75
  const { attrs, runId, projectId, projectRoot, db, entries } = ctx;
63
- if (!attrs?.path || !attrs?.merge) return;
76
+ // Shape gate, not path gate: any accepted proposal whose
77
+ // attributes describe a file materialization (target path +
78
+ // authoritative patched body) lands a fresh file body and writes
79
+ // to disk. Lets cp/set/future tools share one materializer.
80
+ if (!attrs?.path || attrs?.patched == null) return;
64
81
 
65
82
  const existing = await entries.getBody(runId, attrs.path);
66
83
  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
- }
84
+ const patched = attrs.patched;
81
85
  const turn = (await db.get_run_by_id.get({ id: runId })).next_turn;
82
- // Preserve current visibility; default would wipe an earlier <get>'s promotion.
86
+ // Visibility precedence: explicit attrs.visibility (mv/cp pass
87
+ // the model's tag attribute through) > current entry visibility
88
+ // (preserves an earlier <get>'s promotion) > scheme default.
83
89
  const existingState = await entries.getState(runId, attrs.path);
90
+ const visibility = attrs.visibility
91
+ ? attrs.visibility
92
+ : existingState?.visibility;
84
93
  await entries.set({
85
94
  runId,
86
95
  turn,
87
96
  path: attrs.path,
88
97
  body: patched,
89
- visibility: existingState?.visibility,
98
+ visibility,
90
99
  });
91
100
  if (projectRoot) {
92
101
  const { writeFile, mkdir } = await import("node:fs/promises");
@@ -98,7 +107,7 @@ export default class Set {
98
107
  await writeFile(targetPath, patched);
99
108
  }
100
109
  if (isNewFile && projectId) {
101
- await File.setConstraint(db, projectId, attrs.path, "active");
110
+ await File.setConstraint(db, projectId, attrs.path, "add");
102
111
  }
103
112
  }
104
113
 
@@ -108,8 +117,27 @@ export default class Set {
108
117
  const visibilityAttr = VALID_VISIBILITY[attrs.visibility]
109
118
  ? attrs.visibility
110
119
  : null;
111
- const rawSummary = typeof attrs.summary === "string" ? attrs.summary : null;
112
- const summaryText = rawSummary ? rawSummary.slice(0, 80) : null;
120
+ const rawTags = typeof attrs.tags === "string" ? attrs.tags : null;
121
+ const tagsText = rawTags ? rawTags.slice(0, 80) : null;
122
+
123
+ // log:// is the immutable record of what happened. Visibility/metadata
124
+ // updates are fine (no body); rewriting the body destroys history.
125
+ // Models reach for this when the Demote example pattern primes
126
+ // `<set ... visibility="summarized">` and they tack on a body line —
127
+ // 405 here teaches the shape that's actually allowed.
128
+ if (attrs.path?.startsWith("log://") && entry.body) {
129
+ await store.set({
130
+ runId,
131
+ turn,
132
+ loopId,
133
+ path: entry.resultPath,
134
+ body: `log:// is immutable. To demote: <set path="${attrs.path}" visibility="summarized"/> (no body).`,
135
+ state: "failed",
136
+ outcome: "method_not_allowed",
137
+ attributes: { path: attrs.path },
138
+ });
139
+ return;
140
+ }
113
141
 
114
142
  // Reject invalid visibility on body-less set; otherwise a typo silently wipes the body.
115
143
  if (
@@ -123,7 +151,7 @@ export default class Set {
123
151
  turn,
124
152
  loopId,
125
153
  path: entry.resultPath,
126
- body: `Invalid visibility "${attrs.visibility}" on <set path="${attrs.path}"/>. Use visibility="visible|summarized|archived".`,
154
+ body: `Invalid visibility "${attrs.visibility}"`,
127
155
  state: "failed",
128
156
  outcome: "validation",
129
157
  attributes: { path: attrs.path },
@@ -131,6 +159,48 @@ export default class Set {
131
159
  return;
132
160
  }
133
161
 
162
+ // Refuse parse-error edits (e.g., malformed sed). Without this the
163
+ // XmlParser would have either silently produced a corrupted edit
164
+ // or fallen through to body-replace, overwriting the target with
165
+ // the literal sed text. Surfacing the error gives the model a
166
+ // concrete signal it can adapt to.
167
+ if (attrs.error) {
168
+ await store.set({
169
+ runId,
170
+ turn,
171
+ loopId,
172
+ path: entry.resultPath,
173
+ body: attrs.error,
174
+ state: "failed",
175
+ outcome: "validation",
176
+ attributes: { path: attrs.path, error: attrs.error },
177
+ });
178
+ return;
179
+ }
180
+
181
+ // Manifest: universal preview gate. Fires before any operational
182
+ // branch so visibility flips, SEARCH/REPLACE edits, sed substitutions,
183
+ // pattern writes, and direct writes all support
184
+ // "list-without-doing" with the same flag.
185
+ if (attrs.manifest !== undefined && attrs.path) {
186
+ const matches = await store.getEntriesByPattern(
187
+ runId,
188
+ attrs.path,
189
+ attrs.body,
190
+ );
191
+ await storePatternResult(
192
+ store,
193
+ runId,
194
+ turn,
195
+ "set",
196
+ attrs.path,
197
+ attrs.body,
198
+ matches,
199
+ { manifest: true, loopId, attributes: { path: attrs.path } },
200
+ );
201
+ return;
202
+ }
203
+
134
204
  // Pure visibility/metadata change — no body content
135
205
  if (!entry.body && visibilityAttr && attrs.path) {
136
206
  const target = attrs.path;
@@ -158,12 +228,12 @@ export default class Set {
158
228
  path: match.path,
159
229
  visibility: visibilityAttr,
160
230
  });
161
- if (summaryText) {
231
+ if (tagsText) {
162
232
  await store.set({
163
233
  runId: runId,
164
234
  path: match.path,
165
235
  attributes: {
166
- summary: summaryText,
236
+ tags: tagsText,
167
237
  },
168
238
  });
169
239
  }
@@ -181,42 +251,68 @@ export default class Set {
181
251
  return;
182
252
  }
183
253
 
184
- // Edit: sed patterns or SEARCH/REPLACE blocks
185
- if (attrs.blocks || attrs.search != null) {
186
- await this.#processEdit(rummy, entry, attrs);
187
- } else if (attrs.manifest && attrs.path) {
188
- // Manifest: list paths and token costs without performing the operation.
189
- const matches = await store.getEntriesByPattern(
190
- runId,
191
- attrs.path,
192
- attrs.body,
254
+ // Build the new content. Either from the marker-parsed operation
255
+ // list (NEW / PREPEND / APPEND / REPLACE / DELETE / SEARCH+REPLACE)
256
+ // or from the plain body (full-replace shorthand).
257
+ const target = attrs.path;
258
+ if (!target) return;
259
+ let newContent;
260
+ if (attrs.operations) {
261
+ const existing = await store.getBody(runId, target);
262
+ const requiresExisting = attrs.operations.some(
263
+ (op) => op.op === "search_replace" || op.op === "delete",
193
264
  );
194
- await storePatternResult(
195
- store,
196
- runId,
197
- turn,
198
- "set",
199
- attrs.path,
200
- attrs.body,
201
- matches,
202
- { manifest: true, loopId },
265
+ if (requiresExisting && existing === null) {
266
+ await store.set({
267
+ runId,
268
+ turn,
269
+ loopId,
270
+ path: entry.resultPath,
271
+ body: `${target} not found in context`,
272
+ state: "failed",
273
+ outcome: "not_found",
274
+ attributes: {
275
+ path: target,
276
+ error: `${target} not found in context`,
277
+ },
278
+ });
279
+ return;
280
+ }
281
+ const result = Set.#applyOperations(
282
+ existing == null ? "" : existing,
283
+ attrs.operations,
203
284
  );
204
- return;
205
- } else {
206
- // Write content
207
- const target = attrs.path;
208
- if (!target) return;
285
+ if (result.error) {
286
+ await store.set({
287
+ runId,
288
+ turn,
289
+ loopId,
290
+ path: entry.resultPath,
291
+ body: existing == null ? "" : existing,
292
+ state: "failed",
293
+ outcome: "conflict",
294
+ attributes: {
295
+ path: target,
296
+ error: result.error,
297
+ attempted: result.attempted,
298
+ currentBody: truncateForFeedback(existing),
299
+ },
300
+ });
301
+ return;
302
+ }
303
+ newContent = result.body;
304
+ } else if (entry.body) {
305
+ newContent = entry.body;
306
+ }
209
307
 
308
+ if (newContent !== undefined) {
210
309
  const scheme = Entries.scheme(target);
211
310
  if (scheme === null) {
212
- // File write — diff against existing content
311
+ // File write — emit a "proposed" entry; #materializeFile
312
+ // writes to disk on accept.
213
313
  const existing = await store.getBody(runId, target);
214
- const oldContent = existing === null ? "" : existing;
215
- const newContent = entry.body;
314
+ const oldContent = existing == null ? "" : existing;
216
315
  const udiff = generatePatch(target, oldContent, newContent);
217
- const merge = oldContent
218
- ? `<<<<<<< SEARCH\n${oldContent}\n=======\n${newContent}\n>>>>>>> REPLACE`
219
- : `<<<<<<< SEARCH\n=======\n${newContent}\n>>>>>>> REPLACE`;
220
316
  const beforeTokens = oldContent ? countTokens(oldContent) : 0;
221
317
  const afterTokens = countTokens(newContent);
222
318
  await store.set({
@@ -228,15 +324,17 @@ export default class Set {
228
324
  attributes: {
229
325
  path: target,
230
326
  patch: udiff,
231
- merge,
327
+ patched: newContent,
232
328
  beforeTokens,
233
329
  afterTokens,
234
- summary: summaryText,
330
+ tags: tagsText,
235
331
  },
236
332
  loopId,
237
333
  });
238
334
  } else if (attrs.filter || target.includes("*")) {
239
- // Pattern update
335
+ // Pattern body-update: write the same body to every matching
336
+ // entry. Operations don't apply here (this is a bulk
337
+ // metadata-flavored body assignment).
240
338
  const matches = await store.getEntriesByPattern(
241
339
  runId,
242
340
  target,
@@ -245,7 +343,7 @@ export default class Set {
245
343
  await store.set({
246
344
  runId: runId,
247
345
  path: target,
248
- body: entry.body,
346
+ body: newContent,
249
347
  bodyFilter: attrs.filter === undefined ? null : attrs.filter,
250
348
  });
251
349
  await storePatternResult(
@@ -261,12 +359,8 @@ export default class Set {
261
359
  } else {
262
360
  // Direct scheme write; same diff-against-existing shape as file writes.
263
361
  const existing = await store.getBody(runId, target);
264
- const oldContent = existing === null ? "" : existing;
265
- const newContent = entry.body;
362
+ const oldContent = existing == null ? "" : existing;
266
363
  const udiff = generatePatch(target, oldContent, newContent);
267
- const merge = oldContent
268
- ? `<<<<<<< SEARCH\n${oldContent}\n=======\n${newContent}\n>>>>>>> REPLACE`
269
- : `<<<<<<< SEARCH\n=======\n${newContent}\n>>>>>>> REPLACE`;
270
364
  const beforeTokens = oldContent ? countTokens(oldContent) : 0;
271
365
  const afterTokens = countTokens(newContent);
272
366
 
@@ -276,9 +370,8 @@ export default class Set {
276
370
  path: target,
277
371
  body: newContent,
278
372
  state: "resolved",
279
- // Scheme writes default visible; the model wrote it.
280
373
  visibility: visibilityAttr ? visibilityAttr : "visible",
281
- attributes: summaryText ? { summary: summaryText } : null,
374
+ attributes: tagsText ? { tags: tagsText } : null,
282
375
  loopId,
283
376
  });
284
377
  await store.set({
@@ -291,10 +384,9 @@ export default class Set {
291
384
  attributes: {
292
385
  path: target,
293
386
  patch: udiff,
294
- merge,
295
387
  beforeTokens,
296
388
  afterTokens,
297
- summary: summaryText,
389
+ tags: tagsText,
298
390
  },
299
391
  });
300
392
  }
@@ -311,11 +403,11 @@ export default class Set {
311
403
  visibility: visibilityAttr,
312
404
  });
313
405
  }
314
- if (summaryText) {
406
+ if (tagsText) {
315
407
  await store.set({
316
408
  runId: runId,
317
409
  path: target,
318
- attributes: { summary: summaryText },
410
+ attributes: { tags: tagsText },
319
411
  });
320
412
  }
321
413
  }
@@ -324,270 +416,60 @@ export default class Set {
324
416
  full(entry) {
325
417
  const attrs = entry.attributes;
326
418
  const target = attrs.path || entry.path;
327
- if (attrs.error) return `# set ${target}\n${attrs.error}`;
419
+ if (attrs.error) {
420
+ const lines = [`# set ${target}`, attrs.error];
421
+ if (attrs.attempted) {
422
+ lines.push("", "--- attempted ---", attrs.attempted);
423
+ }
424
+ if (attrs.currentBody != null) {
425
+ lines.push("", `--- current body of ${target} ---`, attrs.currentBody);
426
+ }
427
+ return lines.join("\n");
428
+ }
328
429
  const tokens =
329
430
  attrs.beforeTokens != null
330
431
  ? ` ${attrs.beforeTokens}→${attrs.afterTokens} tokens`
331
432
  : "";
332
- if (!attrs.merge) return `# set ${target}${tokens}`;
333
- return `# set ${target}${tokens}\n${attrs.merge}`;
433
+ if (!attrs.patch) return `# set ${target}${tokens}`;
434
+ return `# set ${target}${tokens}\n${attrs.patch}`;
334
435
  }
335
436
 
336
437
  summary(entry) {
337
438
  if (!entry.body) return "";
338
- // Preserve SEARCH/REPLACE blocks intact; truncation strips before/after the model needs.
339
- if (/<<<<<<< SEARCH[\s\S]*>>>>>>> REPLACE/.test(entry.body)) {
340
- return entry.body;
341
- }
342
- const flat = entry.body.replace(/\s+/g, " ").trim();
343
- return flat.length <= 80 ? flat : `${flat.slice(0, 77)}...`;
344
- }
345
-
346
- async #processEdit(rummy, entry, attrs) {
347
- const { entries: store, sequence: turn, runId, loopId } = rummy;
348
- const target = attrs.path;
349
- const matches = await store.getEntriesByPattern(runId, target, attrs.body);
350
-
351
- if (matches.length === 0) {
352
- await store.set({
353
- runId,
354
- turn,
355
- path: entry.resultPath,
356
- body: "",
357
- state: "failed",
358
- outcome: "not_found",
359
- attributes: { path: target, error: `${target} not found in context` },
360
- loopId,
361
- });
362
- return;
363
- }
364
-
365
- for (const match of matches) {
366
- if (match.scheme === null) {
367
- // Bare file: apply edit immediately so log carries before/after merge.
368
- const canonicalPath = `set://${match.path}`;
369
- const revision = Set.#buildRevision(attrs);
370
- const existingAttrs = await rummy.getAttributes(canonicalPath);
371
- const revisions = existingAttrs?.revisions
372
- ? existingAttrs.revisions
373
- : [];
374
- revisions.push(revision);
375
- await store.set({
376
- runId,
377
- turn,
378
- path: canonicalPath,
379
- body: "",
380
- state: "resolved",
381
- attributes: { path: match.path, revisions },
382
- loopId,
383
- });
384
- const { patch, searchText, replaceText, warning, error } =
385
- Set.#applyRevision(match.body, attrs);
386
- const merge =
387
- searchText != null
388
- ? `<<<<<<< SEARCH\n${searchText}\n=======\n${replaceText}\n>>>>>>> REPLACE`
389
- : null;
390
- const beforeTokens = match.tokens;
391
- const afterTokens = patch ? countTokens(patch) : beforeTokens;
392
- const logState = error ? "failed" : "resolved";
393
- await store.set({
394
- runId,
395
- turn,
396
- path: entry.resultPath,
397
- body: merge ?? (patch || `edit to ${match.path}`),
398
- state: logState,
399
- outcome: error ? "conflict" : null,
400
- attributes: {
401
- path: match.path,
402
- merge,
403
- beforeTokens,
404
- afterTokens,
405
- warning,
406
- error,
407
- },
408
- loopId,
409
- });
410
- return;
411
- }
412
-
413
- const { patch, searchText, replaceText, warning, error } =
414
- Set.#applyRevision(match.body, attrs);
415
-
416
- const state = error ? "failed" : "resolved";
417
- const outcome = error ? "conflict" : null;
418
- const udiff = patch ? generatePatch(match.path, match.body, patch) : null;
419
- const merge =
420
- searchText != null
421
- ? `<<<<<<< SEARCH\n${searchText}\n=======\n${replaceText}\n>>>>>>> REPLACE`
422
- : null;
423
- const beforeTokens = match.tokens;
424
- const afterTokens = patch ? countTokens(patch) : beforeTokens;
425
-
426
- // Log entry at log://turn_N/set/<target> records the action.
427
- await store.set({
428
- runId,
429
- turn,
430
- path: entry.resultPath,
431
- body: patch ?? match.body,
432
- state,
433
- outcome,
434
- attributes: {
435
- path: match.path,
436
- patch: udiff,
437
- merge,
438
- beforeTokens,
439
- afterTokens,
440
- warning,
441
- error,
442
- },
443
- loopId,
444
- });
445
-
446
- if (state === "resolved" && patch) {
447
- await store.set({
448
- runId,
449
- turn,
450
- path: match.path,
451
- body: patch,
452
- state: match.state,
453
- loopId,
454
- });
455
- }
456
- }
439
+ // Contract: summarized projections are SUMMARY_MAX_CHARS. The
440
+ // merge body for an edit can be many KB; truncate. The model
441
+ // reads the full body via promotion to visible if it needs the
442
+ // edit's exact content.
443
+ return entry.body.slice(0, SUMMARY_MAX_CHARS);
457
444
  }
458
445
 
459
- async #materializeRevisions({ rummy }) {
460
- const { entries: store, sequence: turn, runId, loopId } = rummy;
461
- const setEntries = await store.getEntriesByPattern(runId, "set://*");
462
-
463
- for (const entry of setEntries) {
464
- const attrs =
465
- typeof entry.attributes === "string"
466
- ? JSON.parse(entry.attributes)
467
- : entry.attributes;
468
- if (!attrs?.revisions?.length) continue;
469
-
470
- const entryPath = attrs.path;
471
- const targetEntry = await store.getEntriesByPattern(runId, entryPath);
472
- if (targetEntry.length === 0) continue;
473
-
474
- const original = targetEntry[0].body;
475
- let current = original;
476
- const mergeBlocks = [];
477
- let lastError = null;
478
- let lastWarning = null;
479
-
480
- for (const rev of attrs.revisions) {
481
- if (!rev) continue;
482
- const { patch, searchText, replaceText, warning, error } =
483
- Set.#applyRevision(current, rev);
484
-
485
- if (error) lastError = error;
486
- else if (patch) current = patch;
487
- if (warning) lastWarning = warning;
488
-
489
- if (searchText != null) {
490
- mergeBlocks.push(
491
- `<<<<<<< SEARCH\n${searchText}\n=======\n${replaceText}\n>>>>>>> REPLACE`,
492
- );
446
+ // Walk the parsed marker operation list against a starting body, returning
447
+ // the final body or the first error. SEARCH/REPLACE and DELETE go through
448
+ // Hedberg.replace (fuzzy whitespace match); NEW/REPLACE/PREPEND/APPEND
449
+ // are direct string operations.
450
+ static #applyOperations(currentBody, operations) {
451
+ let body = currentBody;
452
+ for (const op of operations) {
453
+ if (op.op === "new" || op.op === "replace") {
454
+ body = op.content;
455
+ } else if (op.op === "append") {
456
+ body = body + op.content;
457
+ } else if (op.op === "prepend") {
458
+ body = op.content + body;
459
+ } else if (op.op === "delete") {
460
+ const result = Hedberg.replace(body, op.content, "");
461
+ if (result.error) {
462
+ return { body, error: result.error, attempted: op.content };
493
463
  }
464
+ body = result.patch;
465
+ } else if (op.op === "search_replace") {
466
+ const result = Hedberg.replace(body, op.search, op.replace);
467
+ if (result.error) {
468
+ return { body, error: result.error, attempted: op.search };
469
+ }
470
+ body = result.patch;
494
471
  }
495
-
496
- const state = lastError ? "failed" : "proposed";
497
- const outcome = lastError ? "conflict" : null;
498
- const udiff =
499
- current !== original
500
- ? generatePatch(entryPath, original, current)
501
- : null;
502
- const merge = mergeBlocks.length > 0 ? mergeBlocks.join("\n") : null;
503
- const beforeTokens = targetEntry[0].tokens;
504
- const afterTokens = current ? countTokens(current) : beforeTokens;
505
-
506
- await store.set({
507
- runId,
508
- turn,
509
- path: entry.path,
510
- body: current,
511
- state,
512
- outcome,
513
- attributes: {
514
- path: entryPath,
515
- patch: udiff,
516
- merge,
517
- beforeTokens,
518
- afterTokens,
519
- warning: lastWarning,
520
- error: lastError,
521
- },
522
- loopId,
523
- });
524
- }
525
- }
526
-
527
- // Missing `replace` = delete the match; normalize to empty string.
528
- static #resolveReplace(attrs) {
529
- return attrs.replace === undefined ? "" : attrs.replace;
530
- }
531
-
532
- static #buildRevision(attrs) {
533
- if (attrs.search != null) {
534
- return { search: attrs.search, replace: Set.#resolveReplace(attrs) };
535
- }
536
- if (attrs.blocks?.length > 0) {
537
- return { blocks: attrs.blocks };
538
- }
539
- return null;
540
- }
541
-
542
- static #applyRevision(body, attrs) {
543
- if (attrs.search != null) {
544
- return Hedberg.replace(body, attrs.search, Set.#resolveReplace(attrs), {
545
- sed: attrs.sed,
546
- flags: attrs.flags,
547
- });
548
- }
549
- if (attrs.blocks?.length > 0 && attrs.blocks[0].search === null) {
550
- return {
551
- patch: attrs.blocks[0].replace,
552
- searchText: null,
553
- replaceText: attrs.blocks[0].replace,
554
- warning: null,
555
- error: null,
556
- };
557
- }
558
- if (body && attrs.blocks?.length > 0) {
559
- if (attrs.blocks.length === 1) {
560
- const block = attrs.blocks[0];
561
- return Hedberg.replace(body, block.search, block.replace, {
562
- sed: block.sed,
563
- flags: block.flags,
564
- });
565
- }
566
- let current = body;
567
- let lastWarning = null;
568
- for (const block of attrs.blocks) {
569
- const result = Hedberg.replace(current, block.search, block.replace, {
570
- sed: block.sed,
571
- flags: block.flags,
572
- });
573
- if (result.error) return result;
574
- if (result.warning) lastWarning = result.warning;
575
- if (result.patch) current = result.patch;
576
- }
577
- return {
578
- patch: current !== body ? current : null,
579
- searchText: null,
580
- replaceText: null,
581
- warning: lastWarning,
582
- error: null,
583
- };
584
472
  }
585
- return {
586
- patch: null,
587
- searchText: null,
588
- replaceText: null,
589
- warning: null,
590
- error: null,
591
- };
473
+ return { body, error: null, attempted: null };
592
474
  }
593
475
  }