@possumtech/rummy 2.0.1 → 2.1.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 (114) hide show
  1. package/.env.example +12 -7
  2. package/BENCH_ENVIRONMENT.md +230 -0
  3. package/CLIENT_INTERFACE.md +396 -0
  4. package/PLUGINS.md +93 -1
  5. package/SPEC.md +305 -28
  6. package/bin/postinstall.js +2 -2
  7. package/bin/rummy.js +2 -2
  8. package/last_run.txt +5617 -0
  9. package/migrations/001_initial_schema.sql +2 -1
  10. package/package.json +6 -2
  11. package/scriptify/cache_probe.js +66 -0
  12. package/scriptify/cache_probe_grok.js +74 -0
  13. package/service.js +22 -11
  14. package/src/agent/AgentLoop.js +33 -139
  15. package/src/agent/ContextAssembler.js +2 -9
  16. package/src/agent/Entries.js +36 -101
  17. package/src/agent/ProjectAgent.js +2 -9
  18. package/src/agent/TurnExecutor.js +45 -83
  19. package/src/agent/XmlParser.js +247 -273
  20. package/src/agent/budget.js +5 -28
  21. package/src/agent/config.js +38 -0
  22. package/src/agent/errors.js +7 -13
  23. package/src/agent/httpStatus.js +1 -19
  24. package/src/agent/known_store.sql +7 -2
  25. package/src/agent/materializeContext.js +12 -17
  26. package/src/agent/pathEncode.js +5 -0
  27. package/src/agent/rummyHome.js +9 -0
  28. package/src/agent/runs.sql +18 -0
  29. package/src/agent/tokens.js +2 -8
  30. package/src/hooks/HookRegistry.js +1 -16
  31. package/src/hooks/Hooks.js +8 -33
  32. package/src/hooks/PluginContext.js +3 -21
  33. package/src/hooks/RpcRegistry.js +1 -4
  34. package/src/hooks/RummyContext.js +2 -16
  35. package/src/hooks/ToolRegistry.js +5 -15
  36. package/src/llm/LlmProvider.js +28 -23
  37. package/src/llm/errors.js +41 -4
  38. package/src/llm/openaiStream.js +125 -0
  39. package/src/llm/retry.js +61 -15
  40. package/src/plugins/budget/budget.js +14 -81
  41. package/src/plugins/cli/README.md +87 -0
  42. package/src/plugins/cli/bin.js +61 -0
  43. package/src/plugins/cli/cli.js +120 -0
  44. package/src/plugins/env/README.md +2 -1
  45. package/src/plugins/env/env.js +4 -6
  46. package/src/plugins/env/envDoc.md +2 -2
  47. package/src/plugins/error/error.js +23 -23
  48. package/src/plugins/file/file.js +2 -22
  49. package/src/plugins/get/get.js +12 -34
  50. package/src/plugins/get/getDoc.md +5 -3
  51. package/src/plugins/hedberg/edits.js +1 -11
  52. package/src/plugins/hedberg/hedberg.js +3 -26
  53. package/src/plugins/hedberg/normalize.js +1 -5
  54. package/src/plugins/hedberg/patterns.js +4 -15
  55. package/src/plugins/hedberg/sed.js +1 -7
  56. package/src/plugins/helpers.js +28 -20
  57. package/src/plugins/index.js +25 -41
  58. package/src/plugins/instructions/README.md +18 -0
  59. package/src/plugins/instructions/instructions.js +13 -76
  60. package/src/plugins/instructions/instructions.md +19 -18
  61. package/src/plugins/instructions/instructions_104.md +5 -4
  62. package/src/plugins/instructions/instructions_105.md +16 -15
  63. package/src/plugins/instructions/instructions_106.md +15 -14
  64. package/src/plugins/instructions/instructions_107.md +13 -6
  65. package/src/plugins/known/README.md +26 -6
  66. package/src/plugins/known/known.js +36 -34
  67. package/src/plugins/log/README.md +2 -2
  68. package/src/plugins/log/log.js +6 -33
  69. package/src/plugins/ollama/ollama.js +50 -66
  70. package/src/plugins/openai/openai.js +26 -44
  71. package/src/plugins/openrouter/openrouter.js +28 -52
  72. package/src/plugins/policy/README.md +8 -2
  73. package/src/plugins/policy/policy.js +8 -21
  74. package/src/plugins/prompt/README.md +22 -0
  75. package/src/plugins/prompt/prompt.js +8 -16
  76. package/src/plugins/rm/rm.js +5 -2
  77. package/src/plugins/rm/rmDoc.md +4 -4
  78. package/src/plugins/rpc/README.md +2 -1
  79. package/src/plugins/rpc/rpc.js +51 -47
  80. package/src/plugins/set/README.md +5 -1
  81. package/src/plugins/set/set.js +23 -33
  82. package/src/plugins/set/setDoc.md +1 -1
  83. package/src/plugins/sh/README.md +2 -1
  84. package/src/plugins/sh/sh.js +5 -11
  85. package/src/plugins/sh/shDoc.md +2 -2
  86. package/src/plugins/stream/README.md +6 -5
  87. package/src/plugins/stream/stream.js +6 -35
  88. package/src/plugins/telemetry/telemetry.js +26 -19
  89. package/src/plugins/think/think.js +4 -7
  90. package/src/plugins/unknown/unknown.js +8 -13
  91. package/src/plugins/update/update.js +36 -35
  92. package/src/plugins/update/updateDoc.md +3 -3
  93. package/src/plugins/xai/xai.js +30 -20
  94. package/src/plugins/yolo/yolo.js +8 -41
  95. package/src/server/ClientConnection.js +17 -47
  96. package/src/server/SocketServer.js +14 -14
  97. package/src/server/protocol.js +1 -10
  98. package/src/sql/functions/slugify.js +5 -7
  99. package/src/sql/v_model_context.sql +4 -11
  100. package/turns/cli_1777462658211/turn_001.txt +772 -0
  101. package/turns/cli_1777462658211/turn_002.txt +606 -0
  102. package/turns/cli_1777462658211/turn_003.txt +667 -0
  103. package/turns/cli_1777462658211/turn_004.txt +297 -0
  104. package/turns/cli_1777462658211/turn_005.txt +301 -0
  105. package/turns/cli_1777462658211/turn_006.txt +262 -0
  106. package/turns/cli_1777465095132/turn_001.txt +715 -0
  107. package/turns/cli_1777465095132/turn_002.txt +236 -0
  108. package/turns/cli_1777465095132/turn_003.txt +287 -0
  109. package/turns/cli_1777465095132/turn_004.txt +694 -0
  110. package/turns/cli_1777465095132/turn_005.txt +422 -0
  111. package/turns/cli_1777465095132/turn_006.txt +365 -0
  112. package/turns/cli_1777465095132/turn_007.txt +885 -0
  113. package/turns/cli_1777465095132/turn_008.txt +1277 -0
  114. package/turns/cli_1777465095132/turn_009.txt +736 -0
@@ -14,3 +14,25 @@ Finds the latest `prompt://` entry in the turn_context rows. The mode
14
14
  attribute (available tool list) and optional `warn` attribute in ask
15
15
  mode. Falls back to the mode passed by the core if no prompt entry
16
16
  exists.
17
+
18
+ ## Archived prompts: the singular exception to invisibility
19
+
20
+ `v_model_context.sql` filters archived entries out of the model's
21
+ context — every scheme **except `prompt`**. Archived `prompt://`
22
+ entries flow through with `effective_visibility = 'archived'` and
23
+ their body suppressed (per `projected.body`'s visibility CASE). The
24
+ plugin then renders the tag with full attributes (`path`,
25
+ `visibility="archived"`, etc.) but empty body.
26
+
27
+ The exception exists because the prompt is run identity: every other
28
+ archived entry is recoverable by pattern search if the model ever
29
+ needs it back, but the prompt is the question the run is answering.
30
+ A model that loses sight of its prompt cannot honestly act. Keeping
31
+ the archived prompt's path visible lets the model emit
32
+ `<get path="prompt://N"/>` to promote it back if it archived
33
+ prematurely (or step back to an earlier stage via
34
+ `<update status="174">`).
35
+
36
+ This is the only entry-type exception to the "archived = invisible"
37
+ contract. New schemes that warrant similar treatment should be added
38
+ explicitly here, not by accident.
@@ -1,3 +1,5 @@
1
+ const SUMMARIZED_PROMPT_CHAR_CAP = 500;
2
+
1
3
  export default class Prompt {
2
4
  #core;
3
5
 
@@ -7,28 +9,23 @@ export default class Prompt {
7
9
  core.hooks.tools.onView(
8
10
  "prompt",
9
11
  (entry) => {
10
- const limit = 500;
11
12
  const full = entry.body;
12
- if (full.length <= limit) return full;
13
- return `${full.slice(0, limit)}\n[truncated — promote to see the complete prompt]`;
13
+ if (full.length <= SUMMARIZED_PROMPT_CHAR_CAP) return full;
14
+ return `${full.slice(0, SUMMARIZED_PROMPT_CHAR_CAP)}\n[truncated — promote to see the complete prompt]`;
14
15
  },
15
16
  "summarized",
16
17
  );
17
18
  core.on("turn.started", this.onTurnStarted.bind(this));
18
- core.filter("assembly.user", this.assemblePrompt.bind(this), 300);
19
+ core.filter("assembly.user", this.assemblePrompt.bind(this), 225);
19
20
  }
20
21
 
21
22
  async onTurnStarted({ rummy, mode, prompt, isContinuation }) {
22
23
  const { entries: store, sequence: turn, runId, loopId } = rummy;
23
24
 
24
25
  if (!isContinuation && prompt) {
25
- // Each new prompt is the start of an independent state-machine
26
- // cycle. Archive prior cycles' prompts and per-turn logs so they
27
- // don't pollute Deployment-landing validation. Knowns, unknowns,
28
- // and file entries persist (cross-cycle knowledge survives).
26
+ // New prompt = new cycle; archive prior cycle's prompts/logs (knowns/unknowns persist).
29
27
  await store.archivePriorPromptArtifacts(runId, turn);
30
28
 
31
- // prompt:// writable_by: ["plugin"] — explicit for clarity.
32
29
  await store.set({
33
30
  runId,
34
31
  turn,
@@ -43,7 +40,7 @@ export default class Prompt {
43
40
  }
44
41
 
45
42
  async assemblePrompt(content, ctx) {
46
- const { rows, contextSize, toolSet } = ctx;
43
+ const { rows, toolSet } = ctx;
47
44
  const promptEntry = rows.findLast(
48
45
  (r) => r.category === "prompt" && r.scheme === "prompt",
49
46
  );
@@ -63,12 +60,7 @@ export default class Prompt {
63
60
  let warn = "";
64
61
  if (mode === "ask") warn = ' warn="File editing disallowed."';
65
62
 
66
- // Surface the most recent prior-turn budget demotion as a
67
- // `reverted="N"` attribute on <prompt>. Historical error
68
- // entries sit in <log> but read as ambient noise; this signal
69
- // is dynamic and always fresh — the model sees that its
70
- // promotions last turn were reverted, in the same spot where
71
- // it reads budget numbers.
63
+ // reverted="N" surfaces last turn's 413 demotion count next to budget numbers.
72
64
  let reverted = "";
73
65
  const priorTurn = ctx.turn - 1;
74
66
  if (priorTurn >= 1) {
@@ -27,9 +27,12 @@ export default class Rm {
27
27
  await ctx.entries.rm({ runId: ctx.runId, path: target });
28
28
  if (ctx.projectRoot) {
29
29
  const { unlink } = await import("node:fs/promises");
30
- const { join } = await import("node:path");
30
+ const { isAbsolute, join } = await import("node:path");
31
+ const targetPath = isAbsolute(target)
32
+ ? target
33
+ : join(ctx.projectRoot, target);
31
34
  try {
32
- await unlink(join(ctx.projectRoot, target));
35
+ await unlink(targetPath);
33
36
  } catch (err) {
34
37
  // File may already be absent — entry rm'd regardless.
35
38
  if (err.code !== "ENOENT") throw err;
@@ -3,11 +3,11 @@
3
3
  Example: <rm path="src/config.js"/>
4
4
  <!-- File removal. Simplest form. -->
5
5
 
6
- Example: <rm path="known://temp_*" preview/>
7
- <!-- Preview before deleting. Safety pattern for bulk operations. -->
6
+ Example: <rm path="known://temp_*" manifest/>
7
+ <!-- Optional: Manifest before deleting. Safety pattern for bulk operations. -->
8
8
 
9
9
  * Permanent. Prefer <set path="..." visibility="archived"/> to preserve for later retrieval
10
10
  <!-- Nudges toward archive over rm. Path attr included so the model sees a complete invocation shape, not a fragment. -->
11
11
 
12
- * `preview` shows what paths would be affected without performing the operation.
13
- <!-- Canonical preview teaching lives here — rm is the most intuitive 'check before committing' case. Model generalizes to cp/mv/get by analogy. Advanced uses (e.g. archive rediscovery via <get preview>) belong in persona/skill docs, not here. -->
12
+ * `manifest` lists what paths would be affected without performing the operation.
13
+ <!-- Canonical manifest teaching lives here — rm is the most intuitive 'check before committing' case. Model generalizes to cp/mv/get by analogy. Advanced uses (e.g. archive rediscovery via <get manifest>) belong in persona/skill docs, not here. -->
@@ -29,4 +29,5 @@ all registered tools.
29
29
  - `getRuns`, `getRun`
30
30
 
31
31
  ### Notifications
32
- - `run/state`, `run/progress`, `run/proposal`, `ui/render`, `ui/notify`, `stream/cancelled`
32
+ - `run/changed` — pulse: an entry under this run changed; client reconciles via `getEntries(run, { since })`.
33
+ - `ui/render`, `ui/notify`, `stream/cancelled`
@@ -24,10 +24,7 @@ export default class Rpc {
24
24
  description: "Returns { methods, notifications } catalog.",
25
25
  });
26
26
 
27
- // --- Primitives (SPEC primitives) ---
28
- // The client surface is a thin projection of the plugin API.
29
- // Six verbs, each takes an object of entry-grammar params.
30
- // Writer is fixed to "client"; permissions enforced per scheme.
27
+ // Primitives (SPEC #primitives); writer fixed to "client".
31
28
 
32
29
  r.register("set", {
33
30
  handler: async (params, ctx) => {
@@ -139,8 +136,9 @@ export default class Rpc {
139
136
  return { ok: true, path };
140
137
  },
141
138
  description:
142
- "Write an update:// entry carrying a turn's continuation/terminal " +
143
- "signal. Not general — this is the lifecycle verb.",
139
+ "Write a status update at log://turn_N/update/<slug> carrying a " +
140
+ "turn's continuation/terminal signal. Not general — this is the " +
141
+ "lifecycle verb.",
144
142
  params: {
145
143
  run: "string — run alias",
146
144
  body: "string — update text",
@@ -151,9 +149,7 @@ export default class Rpc {
151
149
  requiresInit: true,
152
150
  });
153
151
 
154
- // Connection handshake. First call a client makes. Establishes
155
- // the project identity for this connection and announces the
156
- // server's protocol version.
152
+ // Connection handshake; project identity + protocol version.
157
153
  r.register("rummy/hello", {
158
154
  handler: async (params, ctx) => {
159
155
  const { RUMMY_PROTOCOL_VERSION } = await import(
@@ -311,11 +307,18 @@ export default class Rpc {
311
307
  r.register("getEntries", {
312
308
  handler: async (params, ctx) => {
313
309
  const runRow = await this.#resolveRun(params.run, ctx);
314
- const { pattern = "*", bodyFilter = null } = params;
310
+ const {
311
+ pattern = "*",
312
+ bodyFilter = null,
313
+ since = null,
314
+ limit = null,
315
+ withBody = false,
316
+ } = params;
315
317
  const rows = await ctx.projectAgent.entries.getEntriesByPattern(
316
318
  runRow.id,
317
319
  pattern,
318
320
  bodyFilter,
321
+ { since, limit },
319
322
  );
320
323
  return rows
321
324
  .filter((e) => !params.scheme || e.scheme === params.scheme)
@@ -323,30 +326,43 @@ export default class Rpc {
323
326
  .filter(
324
327
  (e) => !params.visibility || e.visibility === params.visibility,
325
328
  )
326
- .map((e) => ({
327
- path: e.path,
328
- scheme: e.scheme,
329
- state: e.state,
330
- outcome: e.outcome,
331
- visibility: e.visibility,
332
- turn: e.turn,
333
- tokens: e.tokens,
334
- attributes:
335
- typeof e.attributes === "string"
336
- ? JSON.parse(e.attributes)
337
- : e.attributes,
338
- }));
329
+ .map((e) => {
330
+ const row = {
331
+ id: e.id,
332
+ path: e.path,
333
+ scheme: e.scheme,
334
+ state: e.state,
335
+ outcome: e.outcome,
336
+ visibility: e.visibility,
337
+ turn: e.turn,
338
+ tokens: e.tokens,
339
+ attributes:
340
+ typeof e.attributes === "string"
341
+ ? JSON.parse(e.attributes)
342
+ : e.attributes,
343
+ };
344
+ if (withBody) row.body = e.body;
345
+ return row;
346
+ });
339
347
  },
340
348
  description:
341
349
  "List entries matching a pattern. Read-only — no promotion. " +
342
- "Optional filters: scheme, state, visibility, bodyFilter.",
350
+ "Optional filters: scheme, state, visibility, bodyFilter. " +
351
+ "Pass `withBody: true` to include `body` on each row (omitted by default to keep pulse-reconcile traffic lean). " +
352
+ "For incremental sync after a `run/changed` pulse, pass `since` (last seen entry id); " +
353
+ "use `limit` to chunk catch-up.",
343
354
  params: {
344
355
  run: "string — run alias",
345
356
  pattern: "string? — glob pattern (default '*')",
346
357
  scheme: "string? — filter by scheme (e.g. 'file')",
347
358
  state: "string? — filter by state",
348
359
  visibility: "string? — filter by visibility",
349
- bodyFilter: "string? — narrow pattern matches by body content",
360
+ bodyFilter:
361
+ "string? — filter rows by content of body (substring/glob; NOT for body inclusion — see withBody)",
362
+ withBody:
363
+ "boolean? — include `body` field on each returned row (default false)",
364
+ since: "number? — only entries with id > since (insertion-ordered)",
365
+ limit: "number? — cap result count",
350
366
  },
351
367
  requiresInit: true,
352
368
  });
@@ -436,9 +452,10 @@ export default class Rpc {
436
452
 
437
453
  // --- Notifications ---
438
454
 
439
- r.registerNotification("run/state", "Turn state update.");
440
- r.registerNotification("run/progress", "Turn status.");
441
- r.registerNotification("run/proposal", "Proposal awaiting resolution.");
455
+ r.registerNotification(
456
+ "run/changed",
457
+ "Pulse: an entry under this run changed. Query with `getEntries(run, { pattern, since })` to reconcile.",
458
+ );
442
459
  r.registerNotification(
443
460
  "stream/cancelled",
444
461
  "Server-initiated stream cancellation.",
@@ -446,8 +463,7 @@ export default class Rpc {
446
463
  r.registerNotification("ui/render", "Streaming output.");
447
464
  r.registerNotification("ui/notify", "Toast notification.");
448
465
 
449
- // Auto-dispatch: any registered tool is callable via RPC.
450
- // Checked at request time — no timing dependency on plugin load order.
466
+ // Any registered tool is callable via RPC; resolved at request time.
451
467
  r.setToolFallback(hooks, buildRunContext, dispatchTool);
452
468
  }
453
469
 
@@ -464,18 +480,14 @@ export default class Rpc {
464
480
  async #dispatchSet(params, ctx) {
465
481
  if (!params.path) throw new Error("set: path is required");
466
482
 
467
- // run:// is the lifecycle surface. A set to a brand-new run://
468
- // alias starts a run loop; a state transition cancels or resolves.
483
+ // run:// = lifecycle surface (start run, cancel, resolve).
469
484
  if (params.path.startsWith("run://")) {
470
485
  return await this.#dispatchRunSet(params, ctx);
471
486
  }
472
487
 
473
488
  const runRow = await this.#resolveRun(params.run, ctx);
474
489
 
475
- // State transition on an existing proposed entryroute through
476
- // AgentLoop.resolve, which applies scheme-specific side effects
477
- // (patch application for set://, file removal for rm://, stream
478
- // setup for sh:// / env://, etc.).
490
+ // State transitions on proposed entriesAgentLoop.resolve for scheme-specific effects.
479
491
  if (params.state && !params.append && !params.pattern) {
480
492
  const current = await ctx.projectAgent.entries.getState(
481
493
  runRow.id,
@@ -521,10 +533,7 @@ export default class Rpc {
521
533
  async #dispatchRunSet(params, ctx) {
522
534
  let alias = params.path.slice("run://".length);
523
535
 
524
- // Empty alias on a new-run set synthesize ${model}_${epoch}.
525
- // Matches AgentLoop.#generateAlias so server- and client-initiated
526
- // runs share one naming scheme. Clients that want a specific name
527
- // pass it in the path; anonymous starts get the synthesized one.
536
+ // Empty alias → ${model}_${epoch}; mirrors AgentLoop.#generateAlias.
528
537
  if (!alias) {
529
538
  const { attributes: attrs = {} } = params;
530
539
  if (!attrs.model) {
@@ -580,9 +589,7 @@ export default class Rpc {
580
589
  fork: attrs.fork,
581
590
  };
582
591
  const { body = "" } = params;
583
- // Fire-and-forget: client watches state via entry notifications.
584
- // ProjectAgent exposes .ask/.act wrappers over AgentLoop#run; route
585
- // by mode rather than calling the private loop directly.
592
+ // Fire-and-forget; client watches state via entry notifications.
586
593
  const kickoff =
587
594
  mode === "act"
588
595
  ? ctx.projectAgent.act(
@@ -605,10 +612,7 @@ export default class Rpc {
605
612
  return { ok: true, alias };
606
613
  }
607
614
 
608
- // Existing run + fork=true: create a child run synchronously so we
609
- // can return the child alias, then kick off the loop against it.
610
- // fork needs a brand-new run row with parent_run_id set; inject()
611
- // would just add another prompt to the parent.
615
+ // fork=true new child run with parent_run_id; inject() would only add a prompt to parent.
612
616
  const attrs = params.attributes ? params.attributes : {};
613
617
  if (attrs.fork === true) {
614
618
  const { mode } = attrs;
@@ -15,7 +15,7 @@ SEARCH/REPLACE edits, and pattern updates.
15
15
  - **Category**: `logging`
16
16
  - **Handler**: Routes based on attributes:
17
17
  - `blocks` or `search` — SEARCH/REPLACE edit via `processEdit`.
18
- - `preview` — pattern preview (dry run).
18
+ - `manifest` — pattern manifest (lists matches without performing the set).
19
19
  - Scheme path — direct upsert at status 200.
20
20
  - File path — produces status 202 (proposed) with unified diff patch.
21
21
  - Glob/filter — bulk update via `updateBodyByPattern`.
@@ -31,3 +31,7 @@ the merge conflict block when a SEARCH/REPLACE was performed.
31
31
  - **Heuristic fallback**: On literal failure, fuzzy matching with warnings.
32
32
  - **Patch generation**: `generatePatch` produces unified diff for client display.
33
33
  - File writes are always status 202 (proposed); scheme writes resolve immediately.
34
+ - **`proposal.content` filter** — when the client accepts a proposed
35
+ set, this plugin overrides the resolved body to the body it
36
+ already staged on the audit entry (rather than whatever literal
37
+ body the client passed through `resolve`).
@@ -79,12 +79,7 @@ export default class Set {
79
79
  }
80
80
  }
81
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).
82
+ // Preserve current visibility; default would wipe an earlier <get>'s promotion.
88
83
  const existingState = await entries.getState(runId, attrs.path);
89
84
  await entries.set({
90
85
  runId,
@@ -94,9 +89,13 @@ export default class Set {
94
89
  visibility: existingState?.visibility,
95
90
  });
96
91
  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(() => {});
92
+ const { writeFile, mkdir } = await import("node:fs/promises");
93
+ const { dirname, isAbsolute, join } = await import("node:path");
94
+ const targetPath = isAbsolute(attrs.path)
95
+ ? attrs.path
96
+ : join(projectRoot, attrs.path);
97
+ await mkdir(dirname(targetPath), { recursive: true });
98
+ await writeFile(targetPath, patched);
100
99
  }
101
100
  if (isNewFile && projectId) {
102
101
  await File.setConstraint(db, projectId, attrs.path, "active");
@@ -112,24 +111,22 @@ export default class Set {
112
111
  const rawSummary = typeof attrs.summary === "string" ? attrs.summary : null;
113
112
  const summaryText = rawSummary ? rawSummary.slice(0, 80) : null;
114
113
 
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.
114
+ // Reject invalid visibility on body-less set; otherwise a typo silently wipes the body.
120
115
  if (
121
116
  !entry.body &&
122
117
  attrs.path &&
123
118
  attrs.visibility !== undefined &&
124
119
  !visibilityAttr
125
120
  ) {
126
- await rummy.hooks.error.log.emit({
127
- store,
121
+ await store.set({
128
122
  runId,
129
123
  turn,
130
124
  loopId,
131
- message: `Invalid visibility "${attrs.visibility}" on <set path="${attrs.path}"/>. Use visibility="visible|summarized|archived".`,
132
- status: 400,
125
+ path: entry.resultPath,
126
+ body: `Invalid visibility "${attrs.visibility}" on <set path="${attrs.path}"/>. Use visibility="visible|summarized|archived".`,
127
+ state: "failed",
128
+ outcome: "validation",
129
+ attributes: { path: attrs.path },
133
130
  });
134
131
  return;
135
132
  }
@@ -187,8 +184,8 @@ export default class Set {
187
184
  // Edit: sed patterns or SEARCH/REPLACE blocks
188
185
  if (attrs.blocks || attrs.search != null) {
189
186
  await this.#processEdit(rummy, entry, attrs);
190
- } else if (attrs.preview && attrs.path) {
191
- // Preview
187
+ } else if (attrs.manifest && attrs.path) {
188
+ // Manifest: list paths and token costs without performing the operation.
192
189
  const matches = await store.getEntriesByPattern(
193
190
  runId,
194
191
  attrs.path,
@@ -202,7 +199,7 @@ export default class Set {
202
199
  attrs.path,
203
200
  attrs.body,
204
201
  matches,
205
- { preview: true, loopId },
202
+ { manifest: true, loopId },
206
203
  );
207
204
  return;
208
205
  } else {
@@ -262,8 +259,7 @@ export default class Set {
262
259
  { loopId },
263
260
  );
264
261
  } else {
265
- // Direct scheme write (known://, unknown://, etc.)
266
- // Same result shape as file writes — diff against existing.
262
+ // Direct scheme write; same diff-against-existing shape as file writes.
267
263
  const existing = await store.getBody(runId, target);
268
264
  const oldContent = existing === null ? "" : existing;
269
265
  const newContent = entry.body;
@@ -280,8 +276,7 @@ export default class Set {
280
276
  path: target,
281
277
  body: newContent,
282
278
  state: "resolved",
283
- // Scheme writes default to promoted — the model wrote it, so
284
- // it's material unless they explicitly demote/archive.
279
+ // Scheme writes default visible; the model wrote it.
285
280
  visibility: visibilityAttr ? visibilityAttr : "visible",
286
281
  attributes: summaryText ? { summary: summaryText } : null,
287
282
  loopId,
@@ -340,8 +335,7 @@ export default class Set {
340
335
 
341
336
  summary(entry) {
342
337
  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.
338
+ // Preserve SEARCH/REPLACE blocks intact; truncation strips before/after the model needs.
345
339
  if (/<<<<<<< SEARCH[\s\S]*>>>>>>> REPLACE/.test(entry.body)) {
346
340
  return entry.body;
347
341
  }
@@ -370,10 +364,7 @@ export default class Set {
370
364
 
371
365
  for (const match of matches) {
372
366
  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.
367
+ // Bare file: apply edit immediately so log carries before/after merge.
377
368
  const canonicalPath = `set://${match.path}`;
378
369
  const revision = Set.#buildRevision(attrs);
379
370
  const existingAttrs = await rummy.getAttributes(canonicalPath);
@@ -533,8 +524,7 @@ export default class Set {
533
524
  }
534
525
  }
535
526
 
536
- // `replace` attr is optional in search/replace form absence means
537
- // "delete the match"; normalize to empty string at this boundary.
527
+ // Missing `replace` = delete the match; normalize to empty string.
538
528
  static #resolveReplace(attrs) {
539
529
  return attrs.replace === undefined ? "" : attrs.replace;
540
530
  }
@@ -18,5 +18,5 @@ Example: <set path="src/config.js">s/port = 3000/port = 8080/g;s/We're almost do
18
18
  Example: <set path="example.md">Full file content here</set>
19
19
  <!-- Create: body contents are entire file. -->
20
20
 
21
- * YOU MUST NOT use <sh></sh> or <env></env> to list, create, read, or edit files — use <get></get> and <set></set>
21
+ YOU MUST NOT use <sh></sh> or <env></env> to list, create, read, or edit files — use <get></get> and <set></set>
22
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. -->
@@ -24,7 +24,8 @@ record, one data payload:
24
24
  - **Data channels**: `sh://turn_N/{slug}_1` (stdout), `sh://turn_N/{slug}_2`
25
25
  (stderr) — scheme=`sh`, category=`data`. Created at status=102 on
26
26
  proposal acceptance, grow via the `stream` RPC, transition to 200/500
27
- via `stream/completed`. Render inside the `<context>` block as `<sh>`.
27
+ via `stream/completed`. Render inside `<visible>` as `<sh>` when
28
+ promoted; listed in `<summarized>` otherwise.
28
29
 
29
30
  The `sh` scheme exists **only** for the data channels. The proposal/log
30
31
  entry itself is in the unified `log://` namespace along with every
@@ -1,4 +1,4 @@
1
- import { logPathToDataBase } from "../helpers.js";
1
+ import { logPathToDataBase, streamSummary } from "../helpers.js";
2
2
  import docs from "./shDoc.js";
3
3
 
4
4
  const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
@@ -8,11 +8,7 @@ export default class Sh {
8
8
 
9
9
  constructor(core) {
10
10
  this.#core = core;
11
- // `sh` scheme holds the streamed stdout/stderr payload that's
12
- // data the model reads, not an audit record. The log entry at
13
- // log://turn_N/sh/{slug} (scheme=log, category=logging) is the
14
- // audit record; it lives in a separate namespace by design.
15
- // See SPEC §streaming_entries and the scheme/category invariant.
11
+ // data scheme = streamed stdout/stderr; audit lives in log://. SPEC #streaming_entries.
16
12
  core.registerScheme({ category: "data" });
17
13
  core.on("handler", this.handler.bind(this));
18
14
  core.on("visible", this.full.bind(this));
@@ -53,9 +49,7 @@ export default class Sh {
53
49
 
54
50
  async handler(entry, rummy) {
55
51
  const { entries: store, sequence: turn, runId, loopId } = rummy;
56
- // Proposal at 202 with the command as summary and empty body the
57
- // body fills in on accept (log message about the action). Data
58
- // entries with stdout/stderr are created on accept in resolve().
52
+ // 202 with command summary, empty body; stdout/stderr entries created on accept.
59
53
  await store.set({
60
54
  runId,
61
55
  turn,
@@ -71,7 +65,7 @@ export default class Sh {
71
65
  return `# sh ${entry.attributes.command}\n${entry.body}`;
72
66
  }
73
67
 
74
- summary() {
75
- return "";
68
+ summary(entry) {
69
+ return streamSummary("sh", entry);
76
70
  }
77
71
  }
@@ -6,8 +6,8 @@ Example: <sh>npm install express</sh>
6
6
  Example: <sh>npm test</sh>
7
7
  <!-- Test execution. Another common side-effect action. -->
8
8
 
9
- * YOU MUST NOT use <sh></sh> to read, create, or edit files — use <get></get> and <set></set>
9
+ YOU MUST NOT use <sh></sh> to read, create, or edit files — use <get></get> and <set></set>
10
10
  <!-- Forces file operations through the entry system. -->
11
11
 
12
- * YOU MUST use <env></env> for commands without side effects
12
+ YOU MUST use <env></env> for commands without side effects
13
13
  <!-- Reinforces the env/sh split. Read = env, mutate = sh. -->
@@ -16,12 +16,13 @@ A streaming action lives in **two namespaces** by design:
16
16
  `{action}://turn_N/{slug}_2`, ... — scheme=`{action}` (sh, env, ...),
17
17
  category=`data`. Created at status=102 on proposal acceptance. Grow
18
18
  via `stream`; terminal via `stream/completed` / `stream/aborted` /
19
- `stream/cancel`. Render inside `<context>`.
19
+ `stream/cancel`. Render inside `<visible>` (or `<summarized>` if
20
+ demoted).
20
21
 
21
- The stream RPC `path` param is always the **log-entry path** (that's
22
- what clients receive on `run/proposal`). The server derives the data
23
- base path internally via `logPathToDataBase`. See
24
- [scheme_category_split](#scheme_category_split).
22
+ The stream RPC `path` param is always the **log-entry path** (the
23
+ `log://...` path the client discovers via `getEntries` after a
24
+ `run/changed` pulse). The server derives the data base path internally
25
+ via `logPathToDataBase`. See [scheme_category_split](#scheme_category_split).
25
26
 
26
27
  ## RPC Methods
27
28
 
@@ -1,22 +1,6 @@
1
1
  import { logPathToDataBase } from "../helpers.js";
2
2
 
3
- /**
4
- * Stream plugin — generic streaming entry infrastructure.
5
- *
6
- * Receives chunks from the client (or any producer) and appends them to
7
- * existing data entries. Producers (sh/env handlers) create the data
8
- * entries at status=102 on proposal acceptance; this plugin handles the
9
- * subsequent append + terminal-status transition via two RPC methods.
10
- *
11
- * RPC `path` param is the **log-entry path** (log://turn_N/{action}/{slug}
12
- * — that's what the client sees on `run/proposal`). Channels live under
13
- * the producer scheme ({action}://turn_N/{slug}_N) for a clean
14
- * data-vs-logging namespace split; this plugin derives the data base from
15
- * the log path on every RPC call.
16
- *
17
- * Not a model-facing tool. No scheme, no tooldoc, no dispatch handler.
18
- * Pure RPC plumbing that any streaming-producer plugin can leverage.
19
- */
3
+ // RPC plumbing that appends/terminates streaming data entries; see plugin README.
20
4
  export default class Stream {
21
5
  #core;
22
6
 
@@ -25,9 +9,7 @@ export default class Stream {
25
9
  const hooks = core.hooks;
26
10
  const r = hooks.rpc.registry;
27
11
 
28
- // stream: append a chunk to a streaming entry.
29
- // Entry path is constructed as `${path}_${channel}` per the Unix FD
30
- // convention (1=stdout, 2=stderr, higher=other producer channels).
12
+ // stream: append chunk; channel = Unix FD (1=stdout, 2=stderr).
31
13
  r.register("stream", {
32
14
  handler: async (params, ctx) => {
33
15
  if (!params.run) throw new Error("run is required");
@@ -67,8 +49,7 @@ export default class Stream {
67
49
  requiresInit: true,
68
50
  });
69
51
 
70
- // stream/completed: transition all data channels for this producer
71
- // to their terminal status and finalize the log entry body.
52
+ // stream/completed: terminal status on all channels + finalize log body.
72
53
  r.register("stream/completed", {
73
54
  handler: async (params, ctx) => {
74
55
  if (!params.run) throw new Error("run is required");
@@ -107,8 +88,7 @@ export default class Stream {
107
88
  });
108
89
  }
109
90
 
110
- // Update the log entry body with final stats. Keep it terse —
111
- // one line summarizing exit code, duration, and channel sizes.
91
+ // One-line final stats for the log entry body.
112
92
  const logEntry = await store.getAttributes(runId, params.path);
113
93
  let command = "";
114
94
  if (logEntry?.command) command = logEntry.command;
@@ -138,11 +118,7 @@ export default class Stream {
138
118
  requiresInit: true,
139
119
  });
140
120
 
141
- // stream/aborted: client-initiated cancellation. Transitions all data
142
- // channels to status 499 (Client Closed Request — the de-facto HTTP
143
- // status for client-terminated requests) and rewrites the log entry
144
- // body to note the abort. Shape mirrors stream/completed for client
145
- // symmetry: same run/path addressing, same channel sweep.
121
+ // stream/aborted: client cancellation; channels 499; mirrors stream/completed.
146
122
  r.register("stream/aborted", {
147
123
  handler: async (params, ctx) => {
148
124
  if (!params.run) throw new Error("run is required");
@@ -211,12 +187,7 @@ export default class Stream {
211
187
  requiresInit: true,
212
188
  });
213
189
 
214
- // stream/cancel: server-initiated cancellation. Any client (or
215
- // internal server code) can cancel a streaming producer — the server
216
- // transitions channels to 499 immediately and pushes a
217
- // stream/cancelled notification so connected clients can kill their
218
- // local processes. Also serves as stale 102 cleanup: if the client
219
- // died mid-stream, call stream/cancel to mark orphaned entries terminal.
190
+ // stream/cancel: server-initiated; pushes stream/cancelled notification; cleans stale 102s.
220
191
  r.register("stream/cancel", {
221
192
  handler: async (params, ctx) => {
222
193
  if (!params.run) throw new Error("run is required");