@possumtech/rummy 0.2.8 → 0.3.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 (114) hide show
  1. package/.env.example +13 -2
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +422 -188
  4. package/SPEC.md +440 -106
  5. package/migrations/001_initial_schema.sql +5 -3
  6. package/package.json +17 -5
  7. package/service.js +5 -3
  8. package/src/agent/AgentLoop.js +252 -55
  9. package/src/agent/ContextAssembler.js +20 -4
  10. package/src/agent/KnownStore.js +82 -25
  11. package/src/agent/ProjectAgent.js +4 -1
  12. package/src/agent/ResponseHealer.js +86 -32
  13. package/src/agent/TurnExecutor.js +542 -207
  14. package/src/agent/XmlParser.js +77 -41
  15. package/src/agent/known_store.sql +68 -4
  16. package/src/agent/schemes.sql +3 -0
  17. package/src/agent/tokens.js +7 -21
  18. package/src/agent/turns.sql +15 -1
  19. package/src/hooks/HookRegistry.js +7 -0
  20. package/src/hooks/Hooks.js +15 -0
  21. package/src/hooks/PluginContext.js +14 -1
  22. package/src/hooks/RummyContext.js +16 -4
  23. package/src/hooks/ToolRegistry.js +77 -19
  24. package/src/llm/LlmProvider.js +27 -8
  25. package/src/llm/OpenAiClient.js +20 -0
  26. package/src/llm/OpenRouterClient.js +24 -2
  27. package/src/llm/XaiClient.js +47 -2
  28. package/src/plugins/ask_user/README.md +4 -4
  29. package/src/plugins/ask_user/ask_user.js +5 -5
  30. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  31. package/src/plugins/budget/README.md +31 -0
  32. package/src/plugins/budget/budget.js +55 -0
  33. package/src/plugins/cp/README.md +5 -4
  34. package/src/plugins/cp/cp.js +10 -6
  35. package/src/plugins/cp/cpDoc.js +29 -0
  36. package/src/plugins/engine/engine.sql +1 -8
  37. package/src/plugins/engine/turn_context.sql +4 -9
  38. package/src/plugins/env/README.md +3 -4
  39. package/src/plugins/env/env.js +5 -5
  40. package/src/plugins/env/envDoc.js +29 -0
  41. package/src/plugins/file/README.md +9 -12
  42. package/src/plugins/file/file.js +34 -35
  43. package/src/plugins/get/README.md +2 -2
  44. package/src/plugins/get/get.js +77 -6
  45. package/src/plugins/get/getDoc.js +51 -0
  46. package/src/plugins/hedberg/hedberg.js +2 -1
  47. package/src/plugins/hedberg/matcher.js +10 -29
  48. package/src/plugins/hedberg/normalize.js +28 -0
  49. package/src/plugins/hedberg/patterns.js +25 -27
  50. package/src/plugins/hedberg/sed.js +17 -10
  51. package/src/plugins/index.js +66 -14
  52. package/src/plugins/instructions/README.md +6 -2
  53. package/src/plugins/instructions/instructions.js +20 -4
  54. package/src/plugins/instructions/preamble.md +19 -5
  55. package/src/plugins/known/README.md +10 -7
  56. package/src/plugins/known/known.js +23 -17
  57. package/src/plugins/known/knownDoc.js +34 -0
  58. package/src/plugins/mv/README.md +5 -4
  59. package/src/plugins/mv/mv.js +27 -6
  60. package/src/plugins/mv/mvDoc.js +45 -0
  61. package/src/plugins/performed/README.md +15 -0
  62. package/src/plugins/performed/performed.js +45 -0
  63. package/src/plugins/persona/persona.js +78 -0
  64. package/src/plugins/previous/README.md +3 -2
  65. package/src/plugins/previous/previous.js +33 -24
  66. package/src/plugins/progress/README.md +1 -2
  67. package/src/plugins/progress/progress.js +33 -21
  68. package/src/plugins/prompt/README.md +5 -5
  69. package/src/plugins/prompt/prompt.js +15 -17
  70. package/src/plugins/rm/README.md +4 -4
  71. package/src/plugins/rm/rm.js +32 -20
  72. package/src/plugins/rm/rmDoc.js +30 -0
  73. package/src/plugins/rpc/README.md +15 -28
  74. package/src/plugins/rpc/rpc.js +42 -77
  75. package/src/plugins/set/README.md +13 -12
  76. package/src/plugins/set/set.js +107 -16
  77. package/src/plugins/set/setDoc.js +49 -0
  78. package/src/plugins/sh/README.md +4 -4
  79. package/src/plugins/sh/sh.js +5 -5
  80. package/src/plugins/sh/shDoc.js +29 -0
  81. package/src/plugins/{skills/skills.js → skill/skill.js} +10 -51
  82. package/src/plugins/summarize/README.md +6 -5
  83. package/src/plugins/summarize/summarize.js +7 -6
  84. package/src/plugins/summarize/summarizeDoc.js +33 -0
  85. package/src/plugins/telemetry/telemetry.js +16 -9
  86. package/src/plugins/think/README.md +20 -0
  87. package/src/plugins/think/think.js +5 -0
  88. package/src/plugins/unknown/README.md +6 -5
  89. package/src/plugins/unknown/unknown.js +12 -9
  90. package/src/plugins/unknown/unknownDoc.js +31 -0
  91. package/src/plugins/update/README.md +3 -8
  92. package/src/plugins/update/update.js +7 -6
  93. package/src/plugins/update/updateDoc.js +33 -0
  94. package/src/server/ClientConnection.js +59 -45
  95. package/src/server/RpcRegistry.js +52 -4
  96. package/src/sql/v_model_context.sql +10 -25
  97. package/src/plugins/ask_user/docs.md +0 -2
  98. package/src/plugins/cp/docs.md +0 -2
  99. package/src/plugins/current/README.md +0 -14
  100. package/src/plugins/current/current.js +0 -47
  101. package/src/plugins/env/docs.md +0 -4
  102. package/src/plugins/get/docs.md +0 -10
  103. package/src/plugins/known/docs.md +0 -3
  104. package/src/plugins/mv/docs.md +0 -2
  105. package/src/plugins/rm/docs.md +0 -6
  106. package/src/plugins/set/docs.md +0 -6
  107. package/src/plugins/sh/docs.md +0 -2
  108. package/src/plugins/skills/README.md +0 -25
  109. package/src/plugins/store/README.md +0 -20
  110. package/src/plugins/store/docs.md +0 -6
  111. package/src/plugins/store/store.js +0 -63
  112. package/src/plugins/summarize/docs.md +0 -4
  113. package/src/plugins/unknown/docs.md +0 -5
  114. package/src/plugins/update/docs.md +0 -4
@@ -5,9 +5,8 @@ Removes entries by path or glob pattern.
5
5
  ## Registration
6
6
 
7
7
  - **Tool**: `rm`
8
- - **Modes**: ask, act
9
- - **Category**: act
10
- - **Handler**: Matches entries by pattern. K/V entries are removed immediately (`pass`); file entries produce `proposed` state for client approval.
8
+ - **Category**: `logging`
9
+ - **Handler**: Matches entries by pattern. Scheme entries are removed immediately (status 200); file entries produce status 202 (proposed) for client approval.
11
10
 
12
11
  ## Projection
13
12
 
@@ -15,4 +14,5 @@ Shows `rm {path}`.
15
14
 
16
15
  ## Behavior
17
16
 
18
- Supports glob patterns and body filters via `getEntriesByPattern`. Each matched entry is processed independently.
17
+ Supports glob patterns and body filters via `getEntriesByPattern`. Each
18
+ matched entry is processed independently.
@@ -1,5 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
1
  import KnownStore from "../../agent/KnownStore.js";
2
+ import docs from "./rmDoc.js";
3
3
 
4
4
  export default class Rm {
5
5
  #core;
@@ -10,10 +10,10 @@ export default class Rm {
10
10
  core.on("handler", this.handler.bind(this));
11
11
  core.on("full", this.full.bind(this));
12
12
  core.on("summary", this.summary.bind(this));
13
- const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
14
- core.filter("instructions.toolDocs", async (content) =>
15
- content ? `${content}\n\n${docs}` : docs,
16
- );
13
+ core.filter("instructions.toolDocs", async (docsMap) => {
14
+ docsMap.rm = docs;
15
+ return docsMap;
16
+ });
17
17
  }
18
18
 
19
19
  async handler(entry, rummy) {
@@ -41,25 +41,37 @@ export default class Rm {
41
41
  return;
42
42
  }
43
43
 
44
- for (const match of matches) {
45
- const resultPath = `rm://${match.path}`;
46
- if (match.scheme === null) {
47
- await store.upsert(runId, turn, resultPath, match.path, 202, {
48
- attributes: { path: match.path },
49
- loopId,
50
- });
51
- } else {
52
- await store.remove(runId, match.path);
53
- await store.upsert(runId, turn, resultPath, match.path, 200, {
54
- attributes: { path: match.path },
55
- loopId,
56
- });
57
- }
44
+ const fileMatches = matches.filter((m) => m.scheme === null);
45
+ const schemeMatches = matches.filter((m) => m.scheme !== null);
46
+
47
+ // Scheme entries: remove all, write one aggregate result entry
48
+ for (const match of schemeMatches) await store.remove(runId, match.path);
49
+ if (schemeMatches.length > 0) {
50
+ const paths = schemeMatches.map((m) => m.path).join("\n");
51
+ await store.upsert(runId, turn, entry.resultPath, paths, 200, {
52
+ attributes: { path: target },
53
+ loopId,
54
+ });
55
+ }
56
+
57
+ // File entries: individual 202 proposals (require user resolution)
58
+ if (fileMatches.length > 0 && schemeMatches.length > 0)
59
+ await store.remove(runId, entry.resultPath);
60
+ for (const match of fileMatches) {
61
+ const resultPath =
62
+ schemeMatches.length === 0 && fileMatches.length === 1
63
+ ? entry.resultPath
64
+ : await store.dedup(runId, "rm", match.path, turn);
65
+ await store.upsert(runId, turn, resultPath, match.path, 202, {
66
+ attributes: { path: match.path },
67
+ loopId,
68
+ });
58
69
  }
59
70
  }
60
71
 
61
72
  full(entry) {
62
- return `# rm ${entry.attributes.path || entry.path}`;
73
+ const header = `# rm ${entry.attributes.path || entry.path}`;
74
+ return entry.body ? `${header}\n${entry.body}` : header;
63
75
  }
64
76
 
65
77
  summary(entry) {
@@ -0,0 +1,30 @@
1
+ // Tool doc for <rm>. 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
+ // --- Syntax: path attr, self-closing
6
+ ['## <rm path="[path]"/> - Remove a file or entry'],
7
+
8
+ // --- Examples: file, known (with slug path), preview safety
9
+ ['Example: <rm path="src/config.js"/>', "File removal. Simplest form."],
10
+ [
11
+ 'Example: <rm path="known://config/deprecated_service"/>',
12
+ "Shows topic-hierarchy path convention. Paths are category/key, not sentence slugs.",
13
+ ],
14
+ [
15
+ 'Example: <rm path="known://temp_*" preview/>',
16
+ "Preview before deleting. Glob pattern. Safety pattern for bulk operations.",
17
+ ],
18
+
19
+ // --- Constraints
20
+ [
21
+ '* Permanent. Prefer <set fidelity="archive"/> to preserve for later retrieval',
22
+ "Nudges toward archive over rm. Archive keeps the key; rm deletes permanently.",
23
+ ],
24
+ [
25
+ "* Paths accept patterns — use `preview` to check matches first",
26
+ "Reinforces preview safety pattern. Prevents accidental bulk deletion.",
27
+ ],
28
+ ];
29
+
30
+ export default LINES.map(([text]) => text).join("\n");
@@ -1,45 +1,32 @@
1
1
  # rpc
2
2
 
3
- Registers all core RPC methods and dispatches client operations through the tool handler chain.
3
+ Registers core RPC methods and provides automatic tool dispatch for
4
+ all registered tools.
4
5
 
5
6
  ## Registration
6
7
 
7
- - **No tool handler** — this plugin registers RPC methods on `hooks.rpc.registry`, not tool handlers.
8
+ - **No tool handler** — registers RPC methods on `hooks.rpc.registry`.
9
+ - **Tool fallback** — any registered tool is automatically callable via
10
+ RPC without explicit registration. Third-party plugins get RPC for free.
8
11
 
9
12
  ## RPC Methods
10
13
 
11
14
  ### Protocol
12
- - `ping` — liveness check.
13
- - `discover` — returns method/notification catalog.
14
- - `init` — initialize project (sets projectId, projectRoot, configPath).
15
+ - `ping`, `discover`, `init`
15
16
 
16
17
  ### Models
17
- - `getModels`, `addModel`, `removeModel` — CRUD for model aliases.
18
+ - `getModels`, `addModel`, `removeModel`
18
19
 
19
- ### Entry Operations
20
- - `read` — promote entry to full state, or create persistent file constraint.
21
- - `store` — demote entry to stored state, or manage file constraints (ignore/clear).
22
- - `write` create/update entry. K/V paths write directly; file paths dispatch through `set` handler.
23
- - `delete` — remove entry via `rm` handler dispatch.
20
+ ### Entry Operations (all dispatch through tool handler chain)
21
+ - `get` — promote entry; with `persist` flag, also sets file constraint.
22
+ - `store` — demote entry or manage file constraints (not a model tool).
23
+ - All other registered tools auto-dispatched via tool fallback.
24
24
  - `getEntries` — query entries by glob pattern.
25
25
 
26
26
  ### Runs
27
- - `startRun` — pre-create a run with model/config.
28
- - `ask` non-mutating model query.
29
- - `act` — mutating model directive.
30
- - `run/resolve` — resolve a proposed entry (accept/reject).
31
- - `run/abort` — abort an in-flight run.
32
- - `run/rename` — rename a run alias.
33
- - `run/inject` — inject a message into an idle or active run.
34
- - `run/config` — update run parameters (temperature, persona, context_limit, model).
35
- - `getRuns`, `getRun` — query run list and full run detail.
27
+ - `startRun`, `ask`, `act`
28
+ - `run/resolve`, `run/abort`, `run/rename`, `run/inject`, `run/config`
29
+ - `getRuns`, `getRun`
36
30
 
37
31
  ### Notifications
38
- - `run/state` turn state update with history, unknowns, proposed, telemetry.
39
- - `run/progress` — turn status (thinking/processing).
40
- - `ui/render` — streaming output.
41
- - `ui/notify` — toast notification.
42
-
43
- ## Behavior
44
-
45
- Client operations (read, write, delete, store) build a `RummyContext` for the target run and dispatch through the same handler chain as model operations via `dispatchTool`.
32
+ - `run/state`, `run/progress`, `ui/render`, `ui/notify`
@@ -1,4 +1,3 @@
1
- import KnownStore from "../../agent/KnownStore.js";
2
1
  import msg from "../../agent/messages.js";
3
2
  import RummyContext from "../../hooks/RummyContext.js";
4
3
  import File from "../file/file.js";
@@ -90,15 +89,15 @@ export default class Rpc {
90
89
 
91
90
  // --- Entry operations (same dispatch as model) ---
92
91
 
92
+ // Override: get has persist flag for file constraint management
93
93
  r.register("get", {
94
94
  handler: async (params, ctx) => {
95
95
  if (!params.path) throw new Error("path is required");
96
96
 
97
97
  if (params.persist) {
98
98
  const visibility = params.readonly ? "readonly" : "active";
99
- return File.activate(
99
+ await File.setConstraint(
100
100
  ctx.db,
101
- ctx.projectAgent.entries,
102
101
  ctx.projectId,
103
102
  params.path,
104
103
  visibility,
@@ -115,101 +114,50 @@ export default class Rpc {
115
114
  description: "Promote entry to full state.",
116
115
  params: {
117
116
  path: "string — file path or glob pattern",
118
- run: "string? — run alias (required without persist)",
119
- persist: "boolean? — create file constraint",
117
+ run: "string — run alias",
118
+ persist: "boolean? — also create file constraint",
120
119
  readonly: "boolean? — with persist, set readonly instead of active",
121
120
  },
122
121
  requiresInit: true,
123
122
  });
124
123
 
124
+ // store is not a tool — it manages file constraints
125
125
  r.register("store", {
126
126
  handler: async (params, ctx) => {
127
127
  if (!params.path) throw new Error("path is required");
128
128
 
129
129
  if (params.clear) {
130
- return File.drop(ctx.db, ctx.projectId, params.path);
130
+ await File.dropConstraint(ctx.db, ctx.projectId, params.path);
131
+ return { status: "ok" };
131
132
  }
132
133
  if (params.persist) {
133
- if (params.ignore) {
134
- return File.ignore(
135
- ctx.db,
136
- ctx.projectAgent.entries,
137
- ctx.projectId,
138
- params.path,
139
- );
140
- }
141
- return File.drop(ctx.db, ctx.projectId, params.path);
134
+ const visibility = params.ignore ? "ignore" : "active";
135
+ await File.setConstraint(
136
+ ctx.db,
137
+ ctx.projectId,
138
+ params.path,
139
+ visibility,
140
+ );
142
141
  }
143
142
 
144
143
  if (!params.run) throw new Error("run is required");
145
- const { rummy } = await buildRunContext(hooks, ctx, params.run);
146
- await dispatchTool(hooks, rummy, "store", params.path, "", {
147
- path: params.path,
148
- });
144
+ const runRow = await ctx.db.get_run_by_alias.get({ alias: params.run });
145
+ if (!runRow) throw new Error(`Run not found: ${params.run}`);
146
+ const store = ctx.projectAgent.entries;
147
+ await store.demoteByPattern(runRow.id, params.path, null);
149
148
  return { status: "ok" };
150
149
  },
151
150
  description: "Demote entry to stored state.",
152
151
  params: {
153
152
  path: "string — file path or glob pattern",
154
153
  run: "string? — run alias (required without persist)",
155
- persist: "boolean? — create file constraint",
154
+ persist: "boolean? — also create file constraint",
156
155
  ignore: "boolean? — with persist, exclude from scan",
157
156
  clear: "boolean? — remove existing constraint",
158
157
  },
159
158
  requiresInit: true,
160
159
  });
161
160
 
162
- r.register("set", {
163
- handler: async (params, ctx) => {
164
- if (!params.path) throw new Error("path is required");
165
- if (!params.run) throw new Error("run is required");
166
- const { rummy } = await buildRunContext(hooks, ctx, params.run);
167
-
168
- const scheme = KnownStore.scheme(params.path);
169
- if (scheme) {
170
- await rummy.set({
171
- path: params.path,
172
- body: params.body,
173
- status: params.status || 200,
174
- attributes: params.attributes,
175
- });
176
- } else {
177
- await dispatchTool(hooks, rummy, "set", params.path, params.body, {
178
- path: params.path,
179
- ...params.attributes,
180
- });
181
- }
182
- return { status: "ok" };
183
- },
184
- description: "Create or update an entry.",
185
- params: {
186
- run: "string — run alias",
187
- path: "string — entry path",
188
- body: "string? — entry content",
189
- status: "number? — HTTP status code (default: 200)",
190
- attributes: "object? — JSON attributes",
191
- },
192
- requiresInit: true,
193
- });
194
-
195
- r.register("rm", {
196
- handler: async (params, ctx) => {
197
- if (!params.path) throw new Error("path is required");
198
- if (!params.run) throw new Error("run is required");
199
- const { rummy } = await buildRunContext(hooks, ctx, params.run);
200
- await dispatchTool(hooks, rummy, "rm", params.path, "", {
201
- path: params.path,
202
- });
203
- return { status: "ok" };
204
- },
205
- description: "Remove an entry.",
206
- params: {
207
- run: "string — run alias",
208
- path: "string — entry path",
209
- },
210
- requiresInit: true,
211
- });
212
-
213
161
  r.register("getEntries", {
214
162
  handler: async (params, ctx) => {
215
163
  let run;
@@ -283,7 +231,9 @@ export default class Rpc {
283
231
  temperature: params.temperature ?? null,
284
232
  persona: params.persona ?? null,
285
233
  contextLimit: params.contextLimit,
286
- noContext: params.noContext,
234
+ noRepo: params.noRepo,
235
+ noInteraction: params.noInteraction,
236
+ noWeb: params.noWeb,
287
237
  fork: params.fork,
288
238
  },
289
239
  );
@@ -297,7 +247,9 @@ export default class Rpc {
297
247
  temperature: "number?",
298
248
  persona: "string?",
299
249
  contextLimit: "number?",
300
- noContext: "boolean?",
250
+ noRepo: "boolean?",
251
+ noInteraction: "boolean? — disable ask_user tool",
252
+ noWeb: "boolean? — disable search and URL fetch",
301
253
  fork: "boolean?",
302
254
  },
303
255
  requiresInit: true,
@@ -315,7 +267,9 @@ export default class Rpc {
315
267
  temperature: params.temperature ?? null,
316
268
  persona: params.persona ?? null,
317
269
  contextLimit: params.contextLimit,
318
- noContext: params.noContext,
270
+ noRepo: params.noRepo,
271
+ noInteraction: params.noInteraction,
272
+ noWeb: params.noWeb,
319
273
  fork: params.fork,
320
274
  },
321
275
  );
@@ -329,7 +283,9 @@ export default class Rpc {
329
283
  temperature: "number?",
330
284
  persona: "string?",
331
285
  contextLimit: "number?",
332
- noContext: "boolean?",
286
+ noRepo: "boolean?",
287
+ noInteraction: "boolean? — disable ask_user tool",
288
+ noWeb: "boolean? — disable search and URL fetch",
333
289
  fork: "boolean?",
334
290
  },
335
291
  requiresInit: true,
@@ -524,6 +480,10 @@ export default class Rpc {
524
480
  r.registerNotification("run/progress", "Turn status.");
525
481
  r.registerNotification("ui/render", "Streaming output.");
526
482
  r.registerNotification("ui/notify", "Toast notification.");
483
+
484
+ // Auto-dispatch: any registered tool is callable via RPC.
485
+ // Checked at request time — no timing dependency on plugin load order.
486
+ r.setToolFallback(hooks, buildRunContext, dispatchTool);
527
487
  }
528
488
  }
529
489
 
@@ -544,7 +504,7 @@ async function buildRunContext(hooks, ctx, runAlias) {
544
504
  sequence: runRow.next_turn,
545
505
  runId: runRow.id,
546
506
  turnId: null,
547
- noContext: false,
507
+ noRepo: false,
548
508
  contextSize: null,
549
509
  systemPrompt: "",
550
510
  loopPrompt: "",
@@ -555,7 +515,12 @@ async function buildRunContext(hooks, ctx, runAlias) {
555
515
 
556
516
  async function dispatchTool(hooks, rummy, scheme, path, body, attributes) {
557
517
  const store = rummy.entries;
558
- const resultPath = await store.dedup(rummy.runId, scheme, path);
518
+ const resultPath = await store.dedup(
519
+ rummy.runId,
520
+ scheme,
521
+ path,
522
+ rummy.sequence,
523
+ );
559
524
 
560
525
  await store.upsert(rummy.runId, rummy.sequence, resultPath, body, 200, {
561
526
  attributes: attributes,
@@ -1,32 +1,33 @@
1
1
  # set
2
2
 
3
- Writes or edits entry content. Handles new files, full overwrites, SEARCH/REPLACE edits, and pattern updates.
3
+ Writes or edits entry content. Handles new files, full overwrites,
4
+ SEARCH/REPLACE edits, and pattern updates.
4
5
 
5
6
  ## Files
6
7
 
7
8
  - **set.js** — Plugin registration and edit dispatch logic.
8
- - **HeuristicMatcher.js** — Fuzzy SEARCH/REPLACE matching. Handles whitespace/indentation differences and escaped characters when literal match fails.
9
+ - **HeuristicMatcher.js** — Fuzzy SEARCH/REPLACE matching.
9
10
  - **HeuristicMatcher.test.js** — Tests for HeuristicMatcher.
10
11
 
11
12
  ## Registration
12
13
 
13
14
  - **Tool**: `set`
14
- - **Modes**: ask, act
15
- - **Category**: act
16
- - **Handler**: Routes to different paths based on attributes:
15
+ - **Category**: `logging`
16
+ - **Handler**: Routes based on attributes:
17
17
  - `blocks` or `search` — SEARCH/REPLACE edit via `processEdit`.
18
18
  - `preview` — pattern preview (dry run).
19
- - K/V path — direct upsert at `full` state.
20
- - File path — produces `proposed` entry with udiff patch.
19
+ - Scheme path — direct upsert at status 200.
20
+ - File path — produces status 202 (proposed) with unified diff patch.
21
21
  - Glob/filter — bulk update via `updateBodyByPattern`.
22
22
 
23
23
  ## Projection
24
24
 
25
- Shows `set {file}` with token delta (`before→after tokens`). Includes the merge conflict block when a SEARCH/REPLACE was performed.
25
+ Shows `set {file}` with token delta (`before→after tokens`). Includes
26
+ the merge conflict block when a SEARCH/REPLACE was performed.
26
27
 
27
28
  ## Behavior
28
29
 
29
- - **Literal match first**: SEARCH text is matched literally against the entry body.
30
- - **Heuristic fallback**: On literal failure, `HeuristicMatcher.matchAndPatch` attempts fuzzy matching with warnings.
31
- - **Patch generation**: `generatePatch` produces unified diff format for client display.
32
- - File writes are always `proposed`; K/V writes resolve immediately.
30
+ - **Literal match first**: SEARCH text is matched literally.
31
+ - **Heuristic fallback**: On literal failure, fuzzy matching with warnings.
32
+ - **Patch generation**: `generatePatch` produces unified diff for client display.
33
+ - File writes are always status 202 (proposed); scheme writes resolve immediately.
@@ -1,7 +1,10 @@
1
- import { readFileSync } from "node:fs";
2
1
  import KnownStore from "../../agent/KnownStore.js";
2
+ import { countTokens } from "../../agent/tokens.js";
3
3
  import Hedberg, { generatePatch } from "../hedberg/hedberg.js";
4
4
  import { storePatternResult } from "../helpers.js";
5
+ import docs from "./setDoc.js";
6
+
7
+ const VALID_FIDELITY = { archive: 1, summary: 1, index: 1, full: 1 };
5
8
 
6
9
  // biome-ignore lint/suspicious/noShadowRestrictedNames: tool name is "set"
7
10
  export default class Set {
@@ -14,16 +17,86 @@ export default class Set {
14
17
  core.on("full", this.full.bind(this));
15
18
  core.on("summary", this.summary.bind(this));
16
19
  core.on("turn.proposing", this.#materializeRevisions.bind(this));
17
- const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
18
- core.filter("instructions.toolDocs", async (content) =>
19
- content ? `${content}\n\n${docs}` : docs,
20
- );
20
+ core.filter("instructions.toolDocs", async (docsMap) => {
21
+ docsMap.set = docs;
22
+ return docsMap;
23
+ });
21
24
  }
22
25
 
23
26
  async handler(entry, rummy) {
24
27
  const { entries: store, sequence: turn, runId, loopId } = rummy;
25
28
  const attrs = entry.attributes;
26
29
 
30
+ // Fidelity control: <set path="..." fidelity="archive"/>
31
+ const fidelityAttr = VALID_FIDELITY[attrs.fidelity] ? attrs.fidelity : null;
32
+ if (fidelityAttr && attrs.path) {
33
+ const target = attrs.path;
34
+ const rawSummary =
35
+ typeof attrs.summary === "string" ? attrs.summary : null;
36
+ const summaryText = rawSummary ? rawSummary.slice(0, 80) : null;
37
+ const matches = await store.getEntriesByPattern(
38
+ runId,
39
+ target,
40
+ attrs.body,
41
+ );
42
+ if (entry.body) {
43
+ // Write content directly at specified fidelity
44
+ const entryAttrs = summaryText ? { summary: summaryText } : null;
45
+ for (const match of matches) {
46
+ await store.upsert(runId, turn, match.path, entry.body, 200, {
47
+ fidelity: fidelityAttr,
48
+ attributes: entryAttrs,
49
+ loopId,
50
+ });
51
+ }
52
+ if (matches.length === 0) {
53
+ await store.upsert(runId, turn, target, entry.body, 200, {
54
+ fidelity: fidelityAttr,
55
+ attributes: entryAttrs,
56
+ loopId,
57
+ });
58
+ }
59
+ } else {
60
+ // No body — change fidelity, attach summary if provided
61
+ for (const match of matches) {
62
+ await store.setFidelity(runId, match.path, fidelityAttr);
63
+ if (summaryText) {
64
+ await store.setAttributes(runId, match.path, {
65
+ summary: summaryText,
66
+ });
67
+ }
68
+ }
69
+ }
70
+ if (matches.length === 0) {
71
+ await store.upsert(
72
+ runId,
73
+ turn,
74
+ entry.resultPath,
75
+ `${target} not found`,
76
+ 404,
77
+ {
78
+ fidelity: "archive",
79
+ loopId,
80
+ },
81
+ );
82
+ return;
83
+ }
84
+ const label =
85
+ fidelityAttr === "archive" ? "archived" : `set to ${fidelityAttr}`;
86
+ await store.upsert(
87
+ runId,
88
+ turn,
89
+ entry.resultPath,
90
+ `${matches.map((m) => m.path).join(", ")} ${label}`,
91
+ 200,
92
+ {
93
+ fidelity: "archive",
94
+ loopId,
95
+ },
96
+ );
97
+ return;
98
+ }
99
+
27
100
  if (attrs.blocks || attrs.search != null) {
28
101
  await this.#processEdit(rummy, entry, attrs);
29
102
  return;
@@ -143,7 +216,7 @@ export default class Set {
143
216
  ? `<<<<<<< SEARCH\n${searchText}\n=======\n${replaceText}\n>>>>>>> REPLACE`
144
217
  : null;
145
218
  const beforeTokens = match.tokens_full || 0;
146
- const afterTokens = patch ? (patch.length / 4) | 0 : beforeTokens;
219
+ const afterTokens = patch ? countTokens(patch) : beforeTokens;
147
220
 
148
221
  await store.upsert(runId, turn, resultPath, match.body, status, {
149
222
  attributes: {
@@ -210,7 +283,7 @@ export default class Set {
210
283
  : null;
211
284
  const merge = mergeBlocks.length > 0 ? mergeBlocks.join("\n") : null;
212
285
  const beforeTokens = fileEntry[0].tokens_full || 0;
213
- const afterTokens = current ? (current.length / 4) | 0 : beforeTokens;
286
+ const afterTokens = current ? countTokens(current) : beforeTokens;
214
287
 
215
288
  await store.upsert(runId, turn, entry.path, original, state, {
216
289
  attributes: {
@@ -232,10 +305,7 @@ export default class Set {
232
305
  return { search: attrs.search, replace: attrs.replace ?? "" };
233
306
  }
234
307
  if (attrs.blocks?.length > 0) {
235
- return {
236
- search: attrs.blocks[0].search,
237
- replace: attrs.blocks[0].replace,
238
- };
308
+ return { blocks: attrs.blocks };
239
309
  }
240
310
  return null;
241
311
  }
@@ -257,11 +327,32 @@ export default class Set {
257
327
  };
258
328
  }
259
329
  if (body && attrs.blocks?.length > 0) {
260
- const block = attrs.blocks[0];
261
- return Hedberg.replace(body, block.search, block.replace, {
262
- sed: block.sed,
263
- flags: block.flags,
264
- });
330
+ if (attrs.blocks.length === 1) {
331
+ const block = attrs.blocks[0];
332
+ return Hedberg.replace(body, block.search, block.replace, {
333
+ sed: block.sed,
334
+ flags: block.flags,
335
+ });
336
+ }
337
+ // Multi-block: apply sequentially, no per-hunk merge notation
338
+ let current = body;
339
+ let lastWarning = null;
340
+ for (const block of attrs.blocks) {
341
+ const result = Hedberg.replace(current, block.search, block.replace, {
342
+ sed: block.sed,
343
+ flags: block.flags,
344
+ });
345
+ if (result.error) return result;
346
+ if (result.warning) lastWarning = result.warning;
347
+ if (result.patch) current = result.patch;
348
+ }
349
+ return {
350
+ patch: current !== body ? current : null,
351
+ searchText: null,
352
+ replaceText: null,
353
+ warning: lastWarning,
354
+ error: null,
355
+ };
265
356
  }
266
357
  return {
267
358
  patch: null,
@@ -0,0 +1,49 @@
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
+ // --- Syntax: path attr + body = edit content
6
+ ['## <set path="[path/to/file]">[edit]</set> - Edit a file or entry'],
7
+
8
+ // --- Examples: sed, SEARCH/REPLACE, fidelity control
9
+ [
10
+ 'Example: <set path="src/config.js">s/port = 3000/port = 8080/g</set>',
11
+ "Sed syntax: most common edit pattern. Shows s/old/new/ with g flag.",
12
+ ],
13
+ [
14
+ `Example: <set path="src/app.js"><<<<<<< SEARCH
15
+ // TODO: add error handling
16
+ =======
17
+ // error handler configured
18
+ >>>>>>> REPLACE</set>`,
19
+ "SEARCH/REPLACE block: literal match and replace. Use when sed escaping is complex.",
20
+ ],
21
+ [
22
+ 'Example: <set path="known://project/milestones" fidelity="summary" summary="milestone,deadline,2026"/> ... <set path="prompt://3" fidelity="index"/>',
23
+ "Fidelity control: compress a known entry to keywords, demote a previous prompt to index-only. Both free context while keeping paths visible.",
24
+ ],
25
+
26
+ // --- Constraints
27
+ [
28
+ '* `fidelity="..."`: `archive`, `summary`, `index`, `full`',
29
+ "Fidelity control. Archive removes from context but preserves for retrieval.",
30
+ ],
31
+ [
32
+ '* `fidelity="summary"` HIDES the body — does NOT require reading or compressing content. Write any short keyword label you already know.',
33
+ "M-10 fix: model was reading files before compressing to summary, believing it needed semantic content. It does not. The body is preserved on disk; only context visibility changes.",
34
+ ],
35
+ [
36
+ '* `summary="..."` (<= 80 chars) persists across fidelity changes',
37
+ "Model-authored descriptions survive demotion. No janitorial pass needed.",
38
+ ],
39
+ [
40
+ "* YOU MUST NOT use <sh/> or <env/> to read, create, or edit files",
41
+ "Forces file operations through set/get. Prevents untracked mutations.",
42
+ ],
43
+ [
44
+ "* Editing: s/old/new/ sed patterns and literal SEARCH/REPLACE blocks",
45
+ "Both syntaxes supported. Hedberg normalizes either form.",
46
+ ],
47
+ ];
48
+
49
+ export default LINES.map(([text]) => text).join("\n");