@possumtech/rummy 2.2.1 → 2.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 (50) hide show
  1. package/package.json +14 -6
  2. package/service.js +18 -10
  3. package/src/agent/AgentLoop.js +2 -11
  4. package/src/agent/ContextAssembler.js +34 -3
  5. package/src/agent/Entries.js +16 -89
  6. package/src/agent/ProjectAgent.js +1 -16
  7. package/src/agent/TurnExecutor.js +12 -52
  8. package/src/agent/XmlParser.js +30 -117
  9. package/src/agent/errors.js +3 -22
  10. package/src/agent/materializeContext.js +3 -11
  11. package/src/hooks/Hooks.js +0 -29
  12. package/src/lib/hedberg/hedberg.js +4 -14
  13. package/src/lib/hedberg/marker.js +15 -59
  14. package/src/llm/LlmProvider.js +13 -26
  15. package/src/llm/errors.js +3 -11
  16. package/src/llm/openaiStream.js +6 -46
  17. package/src/plugins/ask_user/ask_user.js +12 -17
  18. package/src/plugins/budget/README.md +46 -8
  19. package/src/plugins/budget/budget.js +23 -42
  20. package/src/plugins/cp/cp.js +28 -18
  21. package/src/plugins/env/env.js +11 -7
  22. package/src/plugins/error/error.js +8 -37
  23. package/src/plugins/get/get.js +42 -24
  24. package/src/plugins/google/google.js +23 -3
  25. package/src/plugins/helpers.js +34 -50
  26. package/src/plugins/instructions/README.md +2 -2
  27. package/src/plugins/instructions/instructions-user.md +1 -1
  28. package/src/plugins/instructions/instructions.js +19 -6
  29. package/src/plugins/known/known.js +1 -8
  30. package/src/plugins/log/log.js +15 -1
  31. package/src/plugins/mv/mv.js +29 -19
  32. package/src/plugins/persona/persona.js +4 -4
  33. package/src/plugins/prompt/README.md +1 -1
  34. package/src/plugins/prompt/prompt.js +1 -1
  35. package/src/plugins/rm/rm.js +26 -15
  36. package/src/plugins/rm/rmDoc.md +0 -2
  37. package/src/plugins/set/set.js +37 -84
  38. package/src/plugins/set/setDoc.md +16 -16
  39. package/src/plugins/sh/sh.js +10 -8
  40. package/src/plugins/skill/skillDoc.md +1 -1
  41. package/src/plugins/unknown/README.md +1 -1
  42. package/src/plugins/unknown/unknown.js +2 -6
  43. package/src/plugins/update/update.js +3 -2
  44. package/src/plugins/update/updateDoc.md +1 -1
  45. package/.env.example +0 -152
  46. package/.xai.key +0 -1
  47. package/PLUGINS.md +0 -962
  48. package/SPEC.md +0 -1897
  49. package/biome/no-fallbacks.grit +0 -50
  50. package/gemini.key +0 -1
@@ -8,7 +8,7 @@ turn.
8
8
  ## Registration
9
9
 
10
10
  - **Filter**: `assembly.user` at priority 30 (front of user
11
- message, before all dynamic state and the `<instructions>`
11
+ message, before all dynamic state and the `<system_requirements>`
12
12
  block at 165)
13
13
 
14
14
  ## Behavior
@@ -12,7 +12,7 @@ export default class Prompt {
12
12
  "summarized",
13
13
  );
14
14
  core.on("turn.started", this.onTurnStarted.bind(this));
15
- core.filter("assembly.user", this.assemblePrompt.bind(this), 60);
15
+ core.filter("assembly.user", this.assemblePrompt.bind(this), 30);
16
16
  }
17
17
 
18
18
  async onTurnStarted({ rummy, mode, prompt, isContinuation }) {
@@ -1,5 +1,10 @@
1
1
  import Entries from "../../agent/Entries.js";
2
- import { storePatternResult } from "../helpers.js";
2
+ import { countTokens } from "../../agent/tokens.js";
3
+ import {
4
+ projectEmission,
5
+ storePatternResult,
6
+ summarizeEmission,
7
+ } from "../helpers.js";
3
8
  import docs from "./rmDoc.js";
4
9
 
5
10
  const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
@@ -64,9 +69,6 @@ export default class Rm {
64
69
  entry.attributes.body,
65
70
  );
66
71
 
67
- // Manifest: list what would be removed without performing the rm.
68
- // Safety idiom for destructive bulk ops — the model can audit a
69
- // glob's reach before committing to it.
70
72
  if (entry.attributes.manifest !== undefined) {
71
73
  await storePatternResult(
72
74
  store,
@@ -98,23 +100,29 @@ export default class Rm {
98
100
  const fileMatches = matches.filter((m) => m.scheme === null);
99
101
  const schemeMatches = matches.filter((m) => m.scheme !== null);
100
102
 
101
- // Scheme entries: remove all, write one aggregate result entry
102
103
  for (const match of schemeMatches)
103
104
  await store.rm({ runId: runId, path: match.path });
104
105
  if (schemeMatches.length > 0) {
105
- const paths = schemeMatches.map((m) => m.path).join("\n");
106
+ const beforeTokens = schemeMatches.reduce(
107
+ (n, m) => n + countTokens(m.body),
108
+ 0,
109
+ );
106
110
  await store.set({
107
111
  runId,
108
112
  turn,
109
113
  path: entry.resultPath,
110
- body: paths,
114
+ body: "",
111
115
  state: "resolved",
112
- attributes: { path: target },
116
+ attributes: {
117
+ path: target,
118
+ beforeActionTokens: beforeTokens,
119
+ afterActionTokens: 0,
120
+ },
113
121
  loopId,
114
122
  });
115
123
  }
116
124
 
117
- // File entries: individual proposals (require user resolution)
125
+ // File matches: individual proposals (require user resolution).
118
126
  if (fileMatches.length > 0 && schemeMatches.length > 0)
119
127
  await store.rm({ runId: runId, path: entry.resultPath });
120
128
  for (const match of fileMatches) {
@@ -126,20 +134,23 @@ export default class Rm {
126
134
  runId,
127
135
  turn,
128
136
  path: resultPath,
129
- body: match.path,
137
+ body: "",
130
138
  state: "proposed",
131
- attributes: { path: match.path },
139
+ attributes: {
140
+ path: match.path,
141
+ beforeActionTokens: countTokens(match.body),
142
+ afterActionTokens: 0,
143
+ },
132
144
  loopId,
133
145
  });
134
146
  }
135
147
  }
136
148
 
137
149
  full(entry) {
138
- const header = `# rm ${entry.attributes.path || entry.path}`;
139
- return entry.body ? `${header}\n${entry.body}` : header;
150
+ return projectEmission(entry.body);
140
151
  }
141
152
 
142
- summary() {
143
- return "";
153
+ summary(entry) {
154
+ return summarizeEmission(entry.body);
144
155
  }
145
156
  }
@@ -2,10 +2,8 @@
2
2
 
3
3
  Example: <rm path="src/config.js"/>
4
4
  <!-- File removal. Simplest form. -->
5
-
6
5
  Example: <rm path="known://countries/france/*" manifest/>
7
6
  <!-- Manifest before deleting. Safety pattern for bulk operations. -->
8
-
9
7
  Example: <rm path="log://turn_3/get/**"/>
10
8
  <!-- Bulk delete by glob. Recursive scheme glob; clears a turn's get logs in one call. -->
11
9
 
@@ -2,7 +2,11 @@ import Entries from "../../agent/Entries.js";
2
2
  import { countTokens } from "../../agent/tokens.js";
3
3
  import Hedberg, { generatePatch } from "../../lib/hedberg/hedberg.js";
4
4
  import File from "../file/file.js";
5
- import { SUMMARY_MAX_CHARS, storePatternResult } from "../helpers.js";
5
+ import {
6
+ projectEmission,
7
+ storePatternResult,
8
+ summarizeEmission,
9
+ } from "../helpers.js";
6
10
  import docs from "./setDoc.js";
7
11
 
8
12
  const VALID_VISIBILITY = { archived: 1, summarized: 1, visible: 1 };
@@ -13,10 +17,6 @@ function isSetProposal(path) {
13
17
  return m?.[1] === "set";
14
18
  }
15
19
 
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
20
  const CONFLICT_FEEDBACK_MAX_CHARS = 4000;
21
21
  function truncateForFeedback(body) {
22
22
  if (body == null) return null;
@@ -41,9 +41,7 @@ export default class Set {
41
41
  });
42
42
  core.filter("proposal.accepting", this.#vetoReadonly.bind(this));
43
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.
44
+ // Shape-coupled (attrs.path + attrs.patched) — cp/set share one materializer.
47
45
  core.on("proposal.accepted", this.#materializeFile.bind(this));
48
46
  }
49
47
 
@@ -73,19 +71,13 @@ export default class Set {
73
71
 
74
72
  async #materializeFile(ctx) {
75
73
  const { attrs, runId, projectId, projectRoot, db, entries } = ctx;
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
74
  if (!attrs?.path || attrs?.patched == null) return;
81
75
 
82
76
  const existing = await entries.getBody(runId, attrs.path);
83
77
  const isNewFile = existing === null;
84
78
  const patched = attrs.patched;
85
79
  const turn = (await db.get_run_by_id.get({ id: runId })).next_turn;
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.
80
+ // Visibility precedence: explicit attr > existing state > scheme default.
89
81
  const existingState = await entries.getState(runId, attrs.path);
90
82
  const visibility = attrs.visibility
91
83
  ? attrs.visibility
@@ -120,11 +112,7 @@ export default class Set {
120
112
  const rawTags = typeof attrs.tags === "string" ? attrs.tags : null;
121
113
  const tagsText = rawTags ? rawTags.slice(0, 80) : null;
122
114
 
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.
115
+ // log:// is immutable; visibility flips OK, body rewrites are not.
128
116
  if (attrs.path?.startsWith("log://") && entry.body) {
129
117
  await store.set({
130
118
  runId,
@@ -159,11 +147,6 @@ export default class Set {
159
147
  return;
160
148
  }
161
149
 
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
150
  if (attrs.error) {
168
151
  await store.set({
169
152
  runId,
@@ -178,10 +161,7 @@ export default class Set {
178
161
  return;
179
162
  }
180
163
 
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.
164
+ // Manifest: universal preview gate, fires before any operational branch.
185
165
  if (attrs.manifest !== undefined && attrs.path) {
186
166
  const matches = await store.getEntriesByPattern(
187
167
  runId,
@@ -251,36 +231,28 @@ export default class Set {
251
231
  return;
252
232
  }
253
233
 
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
234
  const target = attrs.path;
258
235
  if (!target) return;
259
236
  let newContent;
260
237
  if (attrs.operations) {
261
238
  const existing = await store.getBody(runId, target);
262
- const requiresExisting = attrs.operations.some(
263
- (op) => op.op === "search_replace" || op.op === "delete",
264
- );
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
- }
239
+ // Missing-path recovery: search_replace → append (replace text only),
240
+ // delete → drop. Lets the model's edit-shaped emission land on a
241
+ // fresh path without first having to write a NEW.
242
+ const operations =
243
+ existing === null
244
+ ? attrs.operations.flatMap((op) => {
245
+ if (op.op === "search_replace") {
246
+ return [{ op: "append", content: op.replace }];
247
+ }
248
+ if (op.op === "delete") return [];
249
+ return [op];
250
+ })
251
+ : attrs.operations;
252
+ if (operations.length === 0) return;
281
253
  const result = Set.#applyOperations(
282
254
  existing == null ? "" : existing,
283
- attrs.operations,
255
+ operations,
284
256
  );
285
257
  if (result.error) {
286
258
  await store.set({
@@ -308,8 +280,7 @@ export default class Set {
308
280
  if (newContent !== undefined) {
309
281
  const scheme = Entries.scheme(target);
310
282
  if (scheme === null) {
311
- // File write — emit a "proposed" entry; #materializeFile
312
- // writes to disk on accept.
283
+ // File write: proposed entry; #materializeFile writes to disk on accept.
313
284
  const existing = await store.getBody(runId, target);
314
285
  const oldContent = existing == null ? "" : existing;
315
286
  const udiff = generatePatch(target, oldContent, newContent);
@@ -319,22 +290,20 @@ export default class Set {
319
290
  runId,
320
291
  turn,
321
292
  path: entry.resultPath,
322
- body: newContent,
293
+ body: attrs.inner,
323
294
  state: "proposed",
324
295
  attributes: {
325
296
  path: target,
326
297
  patch: udiff,
327
298
  patched: newContent,
328
- beforeTokens,
329
- afterTokens,
299
+ beforeActionTokens: beforeTokens,
300
+ afterActionTokens: afterTokens,
330
301
  tags: tagsText,
331
302
  },
332
303
  loopId,
333
304
  });
334
305
  } else if (attrs.filter || target.includes("*")) {
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).
306
+ // Pattern body-update: bulk body assignment, no operations.
338
307
  const matches = await store.getEntriesByPattern(
339
308
  runId,
340
309
  target,
@@ -357,7 +326,6 @@ export default class Set {
357
326
  { loopId },
358
327
  );
359
328
  } else {
360
- // Direct scheme write; same diff-against-existing shape as file writes.
361
329
  const existing = await store.getBody(runId, target);
362
330
  const oldContent = existing == null ? "" : existing;
363
331
  const udiff = generatePatch(target, oldContent, newContent);
@@ -378,21 +346,20 @@ export default class Set {
378
346
  runId,
379
347
  turn,
380
348
  path: entry.resultPath,
381
- body: newContent,
349
+ body: attrs.inner,
382
350
  state: "resolved",
383
351
  loopId,
384
352
  attributes: {
385
353
  path: target,
386
354
  patch: udiff,
387
- beforeTokens,
388
- afterTokens,
355
+ beforeActionTokens: beforeTokens,
356
+ afterActionTokens: afterTokens,
389
357
  tags: tagsText,
390
358
  },
391
359
  });
392
360
  }
393
361
  }
394
362
 
395
- // Apply visibility after all write operations
396
363
  if (visibilityAttr && attrs.path) {
397
364
  const target = attrs.path;
398
365
  const scheme = Entries.scheme(target);
@@ -415,38 +382,24 @@ export default class Set {
415
382
 
416
383
  full(entry) {
417
384
  const attrs = entry.attributes;
418
- const target = attrs.path || entry.path;
419
385
  if (attrs.error) {
420
- const lines = [`# set ${target}`, attrs.error];
386
+ const target = attrs.path || entry.path;
387
+ const lines = [`error at ${target}: ${attrs.error}`];
421
388
  if (attrs.attempted) {
422
389
  lines.push("", "--- attempted ---", attrs.attempted);
423
390
  }
424
391
  if (attrs.currentBody != null) {
425
392
  lines.push("", `--- current body of ${target} ---`, attrs.currentBody);
426
393
  }
427
- return lines.join("\n");
394
+ return projectEmission(lines.join("\n"));
428
395
  }
429
- const tokens =
430
- attrs.beforeTokens != null
431
- ? ` ${attrs.beforeTokens}→${attrs.afterTokens} tokens`
432
- : "";
433
- if (!attrs.patch) return `# set ${target}${tokens}`;
434
- return `# set ${target}${tokens}\n${attrs.patch}`;
396
+ return projectEmission(entry.body);
435
397
  }
436
398
 
437
399
  summary(entry) {
438
- if (!entry.body) return "";
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);
400
+ return summarizeEmission(entry.body);
444
401
  }
445
402
 
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
403
  static #applyOperations(currentBody, operations) {
451
404
  let body = currentBody;
452
405
  for (const op of operations) {
@@ -1,13 +1,18 @@
1
1
  ## <set path="{path}" tags="{topical,searchable,folksonomic,internal,tags}">[content or edit]</set> - Create, edit, or update an entry or file
2
2
 
3
- * The <set/> command requires HEREDOC string literal syntax
4
- * The <set/> command's SEARCH/REPLACE string literal syntax uses HEREDOC instead of git conflict markers
5
- * The `{SEARCH|REPLACE|NEW|APPEND|PREPEND|DELETE} Operative Labels determine the type of edit
3
+ YOU SHOULD prefer minimal and multiple atomic edits to reduce the frequency and severity of conflicts and errors
6
4
 
7
- YOU MAY add additional characters to the Operative Labels to avoid collisions
5
+ * The <set/> command requires matching HEREDOC label string literal syntax
6
+
7
+ * Special Operative Labels: ({SEARCH|REPLACE|NEW|PREPEND|APPEND|DELETE}) dictate the type of edit
8
+ SEARCH/REPLACE - SEARCH/REPLACE string literal syntax uses HEREDOC in place of git conflict markers
9
+ NEW - Create (or clobber) entry content
10
+ PREPEND - Prepend content at beginning of existing entry
11
+ APPEND - Append content to end of existing entry
12
+ DELETE - Delete matching content in existing entry
8
13
 
9
14
  Example:
10
- <set path="src/main.go" tags="go,source,unlinted"><<SEARCH
15
+ <set path="src/main.go"><<SEARCH
11
16
  exact
12
17
  text
13
18
  to be
@@ -17,27 +22,23 @@ Example:
17
22
  replacement
18
23
  text
19
24
  REPLACE</set>
20
- <!-- SEARCH/REPLACE: surgical edit, fuzzy on whitespace. Multiple pairs in one body apply in order. -->
21
25
 
22
26
  Example:
23
- <set path="src/main.go"><<NEW
27
+ <set path="src/main.go" tags="go,source,unlinted"><<NEW
24
28
  package main
25
29
 
26
30
  func main() {}
27
31
  NEW</set>
28
- <!-- NEW: create with body content. -->
32
+
33
+ Example:
34
+ <set path="known://plan" tags="docs"><<PREPEND0
35
+ Documenting the <<PREPEND label
36
+ PREPEND0</set>
29
37
 
30
38
  Example:
31
39
  <set path="known://plan" tags="plan,project,todo"><<APPEND
32
40
  - [ ] new task
33
41
  APPEND</set>
34
- <!-- APPEND adds to the end; PREPEND to the start. -->
35
-
36
- Example:
37
- <set path="known://plan" tags="docs"><<PREPEND0
38
- Documenting the <<PREPEND label
39
- PREPEND0</set>
40
- <!-- APPEND adds to the end; PREPEND to the start. -->
41
42
 
42
43
  Example:
43
44
  <set path="src/main.go"><<DELETE
@@ -49,4 +50,3 @@ Example:
49
50
  <set path="docs/guide.md" tags="docs"><<GUIDE
50
51
  The pair is <<SEARCH ... SEARCH<<REPLACE ... REPLACE.
51
52
  GUIDE</set>
52
- <!-- Any IDENT brackets opaque body. Use a custom IDENT (GUIDE, EOF, DOC, file paths, etc.) for bodies that contain `<<` literally. -->
@@ -1,4 +1,9 @@
1
- import { logPathToDataBase, streamSummary } from "../helpers.js";
1
+ import {
2
+ logPathToDataBase,
3
+ projectEmission,
4
+ streamSummary,
5
+ summarizeEmission,
6
+ } from "../helpers.js";
2
7
  import docs from "./shDoc.js";
3
8
 
4
9
  const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
@@ -8,10 +13,6 @@ export default class Sh {
8
13
 
9
14
  constructor(core) {
10
15
  this.#core = core;
11
- // Streaming stdout/stderr is time-indexed activity output, not
12
- // topic-indexed state — category="logging" so it renders in <log>
13
- // adjacent to its action entry, not in <summary>/<visible> next
14
- // to knowns and files. SPEC #streaming_entries.
15
16
  core.registerScheme({ category: "logging" });
16
17
  core.on("handler", this.handler.bind(this));
17
18
  core.on("visible", this.full.bind(this));
@@ -46,13 +47,11 @@ export default class Sh {
46
47
  runId: ctx.runId,
47
48
  path: ctx.path,
48
49
  state: "resolved",
49
- body: `ran '${command}' (in progress). Output: ${dataBase}_1, ${dataBase}_2`,
50
50
  });
51
51
  }
52
52
 
53
53
  async handler(entry, rummy) {
54
54
  const { entries: store, sequence: turn, runId, loopId } = rummy;
55
- // 202 with command summary, empty body; stdout/stderr entries created on accept.
56
55
  await store.set({
57
56
  runId,
58
57
  turn,
@@ -64,11 +63,14 @@ export default class Sh {
64
63
  });
65
64
  }
66
65
 
66
+ // log:// entries: emission, tab-indented. sh:// entries: stream bytes verbatim.
67
67
  full(entry) {
68
- return `# sh ${entry.attributes.command}\n${entry.body}`;
68
+ if (entry.path.startsWith("log://")) return projectEmission(entry.body);
69
+ return entry.body;
69
70
  }
70
71
 
71
72
  summary(entry) {
73
+ if (entry.path.startsWith("log://")) return summarizeEmission(entry.body);
72
74
  return streamSummary("sh", entry);
73
75
  }
74
76
  }
@@ -1,4 +1,4 @@
1
- ## <skill path="[path-or-url]"/> - Drop in a deep skill
1
+ ## <skill path="[path-or-url]"/> - Drop in a skill
2
2
 
3
3
  Example: <skill path="docs/refactoring.md"/>
4
4
  <!-- Single-file skill: archived at skill://refactoring (summarized). -->
@@ -9,7 +9,7 @@ The Rumsfeld mechanism. The model registers what it doesn't know before acting.
9
9
  - **Tool**: `unknown`
10
10
  - **Category**: `unknown`
11
11
  - **Handler**: None — recorded by TurnExecutor, deduplicated against existing unknowns.
12
- - **Filter**: `assembly.user` at priority 150 — renders `<unknowns>` after `<log>` (priority 100) and before `<instructions>` (priority 165) in the sandwich. Unknowns are active work, not stable environment state; they belong in the user packet.
12
+ - **Filter**: `assembly.system` at priority 350 — renders `<unknowns>` at the bottom of the system message (after `<summary>` 200, `<visible>` 250, `<log>` 300). Open work surfaces alongside the rest of the accumulated state in system; the user message holds prompt + budget + per-turn requirements.
13
13
 
14
14
  ## Projection
15
15
 
@@ -9,10 +9,8 @@ export default class Unknown {
9
9
  core.on("handler", this.handler.bind(this));
10
10
  core.on("visible", this.full.bind(this));
11
11
  core.on("summarized", this.summary.bind(this));
12
- core.filter("assembly.user", this.assembleUnknowns.bind(this), 175);
13
- // Hidden from the advertised tool list — the model writes unknowns
14
- // via <set path="unknown://..."/>. The unknown:// scheme lifecycle
15
- // is taught in instructions-user.md, not in a separate tooldoc.
12
+ core.filter("assembly.system", this.assembleUnknowns.bind(this), 350);
13
+ // Written via <set path="unknown://...">; lifecycle in instructions-user.md.
16
14
  core.markHidden();
17
15
  }
18
16
 
@@ -33,7 +31,6 @@ export default class Unknown {
33
31
  return;
34
32
  }
35
33
 
36
- // tags > body for slug; lets the model round-trip via <get>.
37
34
  const unknownPath = await store.slugPath(
38
35
  runId,
39
36
  "unknown",
@@ -54,7 +51,6 @@ export default class Unknown {
54
51
  return entry.body;
55
52
  }
56
53
 
57
- // First SUMMARY_MAX_CHARS of the body. Matches <known> / <prompt>.
58
54
  summary(entry) {
59
55
  if (!entry.body) return "";
60
56
  return entry.body.slice(0, SUMMARY_MAX_CHARS);
@@ -1,6 +1,7 @@
1
+ import { projectEmission } from "../helpers.js";
1
2
  import docs from "./updateDoc.js";
2
3
 
3
- const CONTRACT_REMINDER = "Missing update";
4
+ const CONTRACT_REMINDER = "UPDATE missing";
4
5
 
5
6
  const EMPTY_RESPONSE_REMINDER = "Response empty";
6
7
 
@@ -55,7 +56,7 @@ export default class Update {
55
56
  }
56
57
 
57
58
  full(entry) {
58
- return `# update\n${entry.body}`;
59
+ return projectEmission(entry.body);
59
60
  }
60
61
 
61
62
  summary(entry) {
@@ -1,4 +1,4 @@
1
- ## <update status="N">{ direct answer or one-line summary }</update> - Turn termination
1
+ ## <update status="N">{ direct one-line answer or one-line summary }</update> - Turn termination
2
2
 
3
3
  YOU MUST conclude every turn with one (and only one) <update status="N"></update>.
4
4
  YOU MUST keep the update body to <= 80 characters.