@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
@@ -0,0 +1,120 @@
1
+ import config from "../../agent/config.js";
2
+ import ProjectAgent from "../../agent/ProjectAgent.js";
3
+
4
+ const TERMINAL_STATUSES = new Set([200, 204, 413, 422, 499, 500]);
5
+
6
+ // Inert unless RUMMY_PROMPT is set; see plugin README.
7
+ export default class Cli {
8
+ #core;
9
+
10
+ constructor(core) {
11
+ this.#core = core;
12
+ core.on("boot.completed", this.#onBoot.bind(this));
13
+ }
14
+
15
+ async #onBoot({ db, hooks }) {
16
+ const prompt = process.env.RUMMY_PROMPT;
17
+ if (!prompt) return;
18
+
19
+ const model = process.env.RUMMY_MODEL;
20
+ if (!model) {
21
+ console.error("rummy-cli: RUMMY_MODEL is required");
22
+ process.exit(2);
23
+ }
24
+
25
+ const rawMode = process.env.RUMMY_MODE;
26
+ const mode = rawMode == null ? "act" : rawMode;
27
+ if (mode !== "ask" && mode !== "act") {
28
+ console.error(
29
+ `rummy-cli: RUMMY_MODE must be "ask" or "act" (got ${JSON.stringify(rawMode)})`,
30
+ );
31
+ process.exit(2);
32
+ }
33
+
34
+ // In-process CLI has no socket client to resolve proposals; default
35
+ // YOLO so any proposal-emitting tool auto-accepts. Operator can
36
+ // pass --RUMMY_YOLO=0 to opt out.
37
+ if (process.env.RUMMY_YOLO == null) process.env.RUMMY_YOLO = "1";
38
+
39
+ const projectRoot = process.cwd();
40
+ const alias = `cli_${Date.now()}`;
41
+
42
+ const projectAgent = new ProjectAgent(db, hooks);
43
+ const { projectId } = await projectAgent.init(alias, projectRoot);
44
+
45
+ // Watchdog; overridable via --RUMMY_RUN_TIMEOUT=<ms>.
46
+ const timeoutMs = config.RUN_TIMEOUT;
47
+ const timer = setTimeout(() => {
48
+ console.error(`rummy-cli: timed out after ${timeoutMs}ms`);
49
+ process.exit(124);
50
+ }, timeoutMs);
51
+ timer.unref();
52
+
53
+ // stderr progress: log update entries as they land.
54
+ hooks.entry.created.on((entry) => {
55
+ if (entry?.scheme !== "update") return;
56
+ const turnMatch = entry.path?.match(/^log:\/\/turn_(\d+)\//);
57
+ if (!turnMatch) return;
58
+ const status = entry.attributes?.status ?? 102;
59
+ console.error(`[rummy-cli] turn ${turnMatch[1]} status=${status}`);
60
+ });
61
+
62
+ // Capture enriched terminal payload (status, cost, tokens, model)
63
+ // from ask.completed / act.completed. Only one fires for our run.
64
+ let runSummary = null;
65
+ const captureSummary = (payload) => {
66
+ if (payload.run !== alias) return;
67
+ runSummary = payload;
68
+ };
69
+ hooks.ask.completed.on(captureSummary);
70
+ hooks.act.completed.on(captureSummary);
71
+
72
+ const runFn =
73
+ mode === "act"
74
+ ? projectAgent.act.bind(projectAgent)
75
+ : projectAgent.ask.bind(projectAgent);
76
+
77
+ try {
78
+ const result = await runFn(projectId, model, prompt, alias, {});
79
+ const { status } = result;
80
+ if (TERMINAL_STATUSES.has(status)) {
81
+ const text = await this.#findLatestSummary(db, alias);
82
+ if (text) process.stdout.write(`${text}\n`);
83
+ }
84
+ if (runSummary) {
85
+ process.stdout.write(
86
+ `__RUMMY_RUN_SUMMARY__ ${JSON.stringify({
87
+ run: runSummary.run,
88
+ status: runSummary.status,
89
+ turn: runSummary.turn,
90
+ turns: runSummary.turns,
91
+ cost: runSummary.cost,
92
+ tokens: runSummary.tokens,
93
+ model: runSummary.model,
94
+ })}\n`,
95
+ );
96
+ }
97
+ await new Promise((r) => setTimeout(r, 50));
98
+ process.exit(status === 200 ? 0 : 1);
99
+ } catch (err) {
100
+ console.error(`rummy-cli: run crashed: ${err.message}`);
101
+ process.exit(1);
102
+ }
103
+ }
104
+
105
+ async #findLatestSummary(db, alias) {
106
+ const runRow = await db.get_run_by_alias.get({ alias });
107
+ if (!runRow) return null;
108
+ const entries = await db.get_known_entries.all({ run_id: runRow.id });
109
+ const updates = entries
110
+ .filter(
111
+ (e) =>
112
+ e.scheme === "log" &&
113
+ /^log:\/\/turn_\d+\/update\//.test(e.path) &&
114
+ e.state === "resolved",
115
+ )
116
+ .toSorted((a, b) => a.turn - b.turn);
117
+ if (updates.length === 0) return null;
118
+ return updates.at(-1).body;
119
+ }
120
+ }
@@ -17,7 +17,8 @@ side effects.
17
17
  The audit record (renders inside `<log>` as `<env>`).
18
18
  - **Data channels**: `env://turn_N/{slug}_1` (stdout), `env://turn_N/{slug}_2`
19
19
  (stderr) — scheme=`env`, category=`data`. The captured payload
20
- (renders inside `<context>` as `<env>`).
20
+ (renders inside `<visible>` as `<env>` when promoted; otherwise listed
21
+ in `<summarized>`).
21
22
 
22
23
  The `env` scheme exists **only** for the data channels. See
23
24
  [scheme_category_split](#scheme_category_split).
@@ -1,4 +1,4 @@
1
- import { logPathToDataBase } from "../helpers.js";
1
+ import { logPathToDataBase, streamSummary } from "../helpers.js";
2
2
  import docs from "./envDoc.js";
3
3
 
4
4
  const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
@@ -8,9 +8,7 @@ export default class Env {
8
8
 
9
9
  constructor(core) {
10
10
  this.#core = core;
11
- // `env` scheme holds the streamed stdout/stderr payload. See sh.js
12
- // for the scheme/category split rationale. env differs from sh only
13
- // in ask-mode policy (env is safe/read-only; sh has side effects).
11
+ // env vs sh: env is read-only (allowed in ask-mode); see plugin README.
14
12
  core.registerScheme({ category: "data" });
15
13
  core.on("handler", this.handler.bind(this));
16
14
  core.on("visible", this.full.bind(this));
@@ -66,7 +64,7 @@ export default class Env {
66
64
  return `# env ${entry.attributes.command}\n${entry.body}`;
67
65
  }
68
66
 
69
- summary() {
70
- return "";
67
+ summary(entry) {
68
+ return streamSummary("env", entry);
71
69
  }
72
70
  }
@@ -6,8 +6,8 @@ Example: <env>npm --version</env>
6
6
  Example: <env>git log --oneline -5</env>
7
7
  <!-- Git history. Shows env for read-only investigation. -->
8
8
 
9
- * YOU MUST NOT use <env></env> to read or list files — use <get path="*"/> instead
9
+ YOU MUST NOT use <env></env> to read or list files — use <get path="*"/> instead
10
10
  <!-- Prevents cat/ls through shell. Forces file access through get. -->
11
11
 
12
- * YOU MUST NOT use <env></env> for commands with side effects
12
+ YOU MUST NOT use <env></env> for commands with side effects
13
13
  <!-- Separates exploration from action. env = observe only. -->
@@ -1,6 +1,6 @@
1
- const MAX_STRIKES = Number(process.env.RUMMY_MAX_STRIKES);
2
- const MIN_CYCLES = Number(process.env.RUMMY_MIN_CYCLES);
3
- const MAX_CYCLE_PERIOD = Number(process.env.RUMMY_MAX_CYCLE_PERIOD);
1
+ import config from "../../agent/config.js";
2
+
3
+ const { MAX_STRIKES, MIN_CYCLES, MAX_CYCLE_PERIOD } = config;
4
4
 
5
5
  const CONTRACT_REMINDER = "Missing update";
6
6
 
@@ -88,28 +88,29 @@ export default class ErrorPlugin {
88
88
  if (state) state.turnErrors++;
89
89
  }
90
90
 
91
- async #verdict({ store, runId, loopId, turn, recorded, summaryText }) {
91
+ async #verdict({ store, runId, loopId, recorded, summaryText }) {
92
92
  const state = this.#loopState.get(loopId);
93
93
 
94
94
  let cycleReason = null;
95
- if (recorded && recorded.length > 0) {
96
- const fp = recorded.map(fingerprint).toSorted().join("|");
97
- state.history.push(fp);
98
- const cycle = detectCycle(state.history);
99
- if (cycle.detected) {
100
- cycleReason = "Loop detected";
101
- await this.#core.hooks.error.log.emit({
102
- store,
103
- runId,
104
- turn,
105
- loopId,
106
- message: cycleReason,
107
- status: 429,
108
- });
109
- }
95
+ // Empty turns share a blank fingerprint; intentional.
96
+ const fp = recorded.map(fingerprint).toSorted().join("|");
97
+ state.history.push(fp);
98
+ const cycle = detectCycle(state.history);
99
+ if (cycle.detected) {
100
+ cycleReason = "Loop detected";
101
+ // Silent strike: increment turn-errors without a model-facing entry.
102
+ state.turnErrors++;
110
103
  }
111
104
 
112
- const struck = state.turnErrors > 0;
105
+ let recordedFailed = false;
106
+ for (const e of recorded) {
107
+ const current = await store.getState(runId, e.path);
108
+ if (current?.state === "failed") {
109
+ recordedFailed = true;
110
+ break;
111
+ }
112
+ }
113
+ const struck = state.turnErrors > 0 || recordedFailed;
113
114
 
114
115
  if (summaryText && !struck) {
115
116
  state.streak = 0;
@@ -121,8 +122,7 @@ export default class ErrorPlugin {
121
122
  if (struck) {
122
123
  state.streak++;
123
124
  if (state.streak >= MAX_STRIKES) {
124
- // On the abandoning strike, a same-turn terminal update
125
- // is honored as completion rather than overridden by 499.
125
+ // Abandoning-strike turn: same-turn terminal update wins over 499.
126
126
  if (summaryText) {
127
127
  state.streak = 0;
128
128
  const updateEntry = recorded?.findLast?.(
@@ -141,7 +141,7 @@ export default class ErrorPlugin {
141
141
  }
142
142
  return {
143
143
  continue: true,
144
- reason: cycleReason || CONTRACT_REMINDER,
144
+ reason: CONTRACT_REMINDER,
145
145
  };
146
146
  }
147
147
 
@@ -1,20 +1,11 @@
1
1
  import { isAbsolute, relative } from "node:path";
2
2
 
3
- /**
4
- * File plugin: projections and constraints for filesystem entries.
5
- *
6
- * Bare file paths (src/app.js) have scheme=NULL in the DB because
7
- * schemeOf() only recognizes "://" patterns. The schemes table has
8
- * a "file" entry so v_model_context can JOIN via COALESCE(scheme, 'file').
9
- * This is the one exception to "every scheme has a plugin owner" —
10
- * the file plugin owns the NULL scheme through the "file" registry entry.
11
- */
3
+ // Owns NULL scheme (bare paths) via the "file" registry entry; see plugin README.
12
4
  export default class File {
13
5
  #core;
14
6
 
15
7
  constructor(core) {
16
8
  this.#core = core;
17
- // "file" scheme covers bare paths (scheme IS NULL in DB)
18
9
  core.registerScheme({ category: "data" });
19
10
  core.on("visible", this.full.bind(this));
20
11
  core.on("summarized", this.summary.bind(this));
@@ -28,10 +19,6 @@ export default class File {
28
19
  return "";
29
20
  }
30
21
 
31
- /**
32
- * Set a project-level file constraint. Backbone operation —
33
- * constraints are project config, not tool dispatch.
34
- */
35
22
  static async setConstraint(db, projectId, pattern, visibility = "active") {
36
23
  const path = await normalizePath(db, projectId, pattern);
37
24
  if (!path) return null;
@@ -45,9 +32,6 @@ export default class File {
45
32
  return path;
46
33
  }
47
34
 
48
- /**
49
- * Remove a project-level file constraint.
50
- */
51
35
  static async dropConstraint(db, projectId, pattern) {
52
36
  const path = await normalizePath(db, projectId, pattern);
53
37
  if (!path) return null;
@@ -60,11 +44,7 @@ export default class File {
60
44
  return path;
61
45
  }
62
46
 
63
- /**
64
- * True if `path` is covered by any readonly constraint for the project.
65
- * Constraints can be globs; hedberg.match provides the pattern engine.
66
- * Called from AgentLoop set-accept to refuse writes to protected paths.
67
- */
47
+ // True if any readonly constraint matches; called from set-accept gate.
68
48
  static async isReadonly(db, projectId, path) {
69
49
  const rows = await db.get_file_constraints.all({ project_id: projectId });
70
50
  const { hedmatch } = await import("./../hedberg/patterns.js");
@@ -21,32 +21,23 @@ export default class Get {
21
21
  const { entries: store, sequence: turn, runId, loopId } = rummy;
22
22
  const target = entry.attributes.path;
23
23
  if (!target) {
24
- // Route through the unified error channel so the message lands
25
- // as `<error>` in <log> with a readable body AND the failure
26
- // counts as a strike. The previous direct `store.set` wrote a
27
- // blank-bodied failed entry whose `error` attribute was never
28
- // rendered — model saw a vague 400 and repeated the mistake.
29
- await rummy.hooks.error.log.emit({
30
- store,
24
+ await store.set({
31
25
  runId,
32
26
  turn,
33
27
  loopId,
34
- message:
35
- 'Missing required "path" attribute on <get>. Use <get path="..."/>.',
36
- status: 400,
28
+ path: entry.resultPath,
29
+ body: 'Missing required "path" attribute on <get>. Use <get path="..."/>.',
30
+ state: "failed",
31
+ outcome: "validation",
37
32
  });
38
33
  return;
39
34
  }
40
35
  const normalized = Entries.normalizePath(target);
41
- // XmlParser passes attributes through; `body` attr is optional.
42
36
  const bodyFilter = entry.attributes.body;
43
- const preview = entry.attributes.preview !== undefined;
37
+ const manifest = entry.attributes.manifest !== undefined;
44
38
  const isPattern = bodyFilter || normalized.includes("*");
45
39
 
46
- // Negative `line` is idiomatic tail-from-end: `line="-50"` means
47
- // "start 50 lines from the end," enabling `tail -n N` behavior.
48
- // Positive `line` is 1-indexed from start (classic). `limit` is
49
- // always a positive count.
40
+ // Negative line = tail-from-end (line=-50 starts 50 from end).
50
41
  const lineRaw = entry.attributes.line;
51
42
  const line = lineRaw != null ? parseInt(lineRaw, 10) : null;
52
43
  const limit =
@@ -60,12 +51,8 @@ export default class Get {
60
51
  bodyFilter,
61
52
  );
62
53
 
63
- // Preview list matches with their full-body token costs. No promotion,
64
- // no visibility change, no Token Budget spent. Model uses this to plan
65
- // which entries to actually promote. getDoc promises this behavior; the
66
- // prior implementation silently promoted anyway, burning the Token Budget
67
- // on entries the model thought it was only inspecting.
68
- if (preview) {
54
+ // Manifest: list matches + full-body token costs; no promotion.
55
+ if (manifest) {
69
56
  await storePatternResult(
70
57
  store,
71
58
  runId,
@@ -74,12 +61,12 @@ export default class Get {
74
61
  target,
75
62
  bodyFilter,
76
63
  matches,
77
- { preview: true, loopId, attributes: { path: target } },
64
+ { manifest: true, loopId, attributes: { path: target } },
78
65
  );
79
66
  return;
80
67
  }
81
68
 
82
- // Partial read — no visibility promotion, returns a line slice as the log item.
69
+ // Partial read: line slice in the log entry; no promotion.
83
70
  if (line !== null || limit !== null) {
84
71
  if (isPattern) {
85
72
  await store.set({
@@ -108,8 +95,6 @@ export default class Get {
108
95
  }
109
96
  const allLines = matches[0].body.split("\n");
110
97
  const total = allLines.length;
111
- // Negative line offsets from the end: line=-50 starts 50 lines
112
- // before the end. Clamped to 1 if the offset exceeds total.
113
98
  const startLine =
114
99
  line == null
115
100
  ? 1
@@ -120,10 +105,6 @@ export default class Get {
120
105
  const endIdx = limit !== null ? Math.min(startIdx + limit, total) : total;
121
106
  const slice = allLines.slice(startIdx, endIdx).join("\n");
122
107
  const endLine = endIdx;
123
- // Body leads with the source path so the model can re-issue
124
- // a full or different-range read without guessing the URL.
125
- // lineStart/lineEnd/totalLines ride attrs so renderLogTag can
126
- // surface `lines="a-b/total"` without parsing the body.
127
108
  const header = `${target}\n[lines ${startLine}–${endLine} / ${total} total]`;
128
109
  await store.set({
129
110
  runId,
@@ -188,10 +169,7 @@ export default class Get {
188
169
  attributes: { path: target },
189
170
  });
190
171
  } else {
191
- // Log a concise record of the promotion. The promoted entry
192
- // itself is visible in <context>; this log line is the model's
193
- // proof in <log> that the get has already been done so it
194
- // doesn't re-issue the same fetch on a later turn.
172
+ // Log line in <log> proves the promotion happened so the model doesn't re-fetch.
195
173
  await store.set({
196
174
  runId,
197
175
  turn,
@@ -9,7 +9,7 @@ Example: <get path="known://*">auth</get>
9
9
  Example: <get path="src/**/*.js">authentication</get>
10
10
  <!-- Full pattern: recursive glob + content filter. -->
11
11
 
12
- Example: <get path="src/**/*.js" preview>authentication</get>
12
+ Example: <get path="src/**/*.js" manifest>authentication</get>
13
13
  <!-- Full pattern: recursive glob + content filter. -->
14
14
 
15
15
  Example: <get path="src/agent/AgentLoop.js" line="644" limit="80"/>
@@ -30,7 +30,9 @@ Example: <get path="https://en.wikipedia.org/wiki/Long_Page" line="1" limit="200
30
30
  * `line` and `limit` read a slice without promoting the entry, which costs as many tokens as the slice contains. Negative `line` reads from the end (tail).
31
31
  <!-- Partial read is safe: context budget unaffected. Tail idiom enables watching growing entries. -->
32
32
 
33
- * `preview` lists the paths and token budget impact of an operation without performing it.
34
- <!-- Partial read is safe: context budget unaffected. Tail idiom enables watching growing entries. -->
33
+ * `manifest` lists the paths and their token amounts instead of performing the operation; useful for bulk and pattern matching tasks.
34
+ <!-- manifest = listing, not snippet. The natural-language reading of "preview" pulled small models toward content-sampling; for body samples use line/limit. -->
35
35
 
36
36
  * Remember to <set path="..." visibility="summarize"/> when entries or log events are no longer relevant.
37
+
38
+ * Promotions don't appear until next turn — emit Stage Continuation (1xx), not Completion (200)
@@ -1,14 +1,4 @@
1
- /**
2
- * Edit format detection. Identifies the edit syntax the model used
3
- * and normalizes it into { search, replace } blocks.
4
- *
5
- * Supported formats:
6
- * 1. SEARCH/REPLACE merge conflict blocks
7
- * 2. Replace-only blocks (no search)
8
- * 3. Unified diff
9
- * 4. Claude XML (<old_text>/<new_text>)
10
- */
11
-
1
+ // Detects merge-conflict / replace-only / udiff / Claude XML edits → {search,replace}; SPEC #hedberg.
12
2
  export function parseEditContent(content) {
13
3
  const blocks = [];
14
4
 
@@ -4,18 +4,7 @@ import { parseJsonEdit } from "./normalize.js";
4
4
  import { hedmatch, hedsearch } from "./patterns.js";
5
5
  import { parseSed } from "./sed.js";
6
6
 
7
- /**
8
- * Hedberg: the interpretation boundary between stochastic model output
9
- * and deterministic system operations.
10
- *
11
- * Registers its functions on core.hedberg so any plugin can call them:
12
- * core.hedberg.match(pattern, string)
13
- * core.hedberg.search(pattern, string)
14
- * core.hedberg.replace(body, search, replacement, options?)
15
- * core.hedberg.parseSed(input)
16
- * core.hedberg.parseEdits(content)
17
- * core.hedberg.generatePatch(path, old, new)
18
- */
7
+ // Stochastic→deterministic boundary; exposes pattern/edit utilities on core.hedberg. SPEC #hedberg.
19
8
  export default class Hedberg {
20
9
  #core;
21
10
 
@@ -31,17 +20,9 @@ export default class Hedberg {
31
20
  parseJsonEdit,
32
21
  generatePatch,
33
22
  };
34
-
35
- // Patterns documentation distributed to individual tool docs.
36
- // Hedberg has no model-facing docs of its own.
37
23
  }
38
24
 
39
- /**
40
- * Apply a replacement to text. Handles sed regex, literal match,
41
- * and heuristic fuzzy match — in that order.
42
- *
43
- * Returns { patch, searchText, replaceText, warning, error }
44
- */
25
+ // Order: sed regex → literal → heuristic fuzzy.
45
26
  static replace(body, search, replacement, { sed = false, flags = "" } = {}) {
46
27
  let patch = null;
47
28
  let warning = null;
@@ -55,11 +36,7 @@ export default class Hedberg {
55
36
  searchText,
56
37
  flags.includes("g") ? flags : `${flags}g`,
57
38
  );
58
- // Unescape regex metacharacter escapes in the replacement string.
59
- // The model writes `\[x\]` meaning literal `[x]` in both search
60
- // and replace. RegExp handles this in search; in the replacement
61
- // string we must strip the backslashes ourselves since
62
- // String.replace only interprets `$` sequences, not `\`.
39
+ // Strip regex-meta escapes in replacement; String.replace only interprets `$`, not `\`.
63
40
  const unescaped = replaceText.replace(/\\([[\](){}.*+?^$|\\])/g, "$1");
64
41
  patch = body.replace(re, unescaped);
65
42
  if (patch === body) patch = null;
@@ -1,8 +1,4 @@
1
- /**
2
- * Parse JSON-style edit from body content.
3
- * Accepts: {"search":"old","replace":"new"} and {search="old",replace="new"}
4
- * Returns { search, replace } or null.
5
- */
1
+ // {"search":"old","replace":"new"} or {search="old",replace="new"} → {search,replace}|null.
6
2
  export function parseJsonEdit(text) {
7
3
  const trimmed = text.trim();
8
4
  if (!trimmed.startsWith("{") || !/search/.test(trimmed)) return null;
@@ -313,10 +313,7 @@ function collectDescendants(node, out) {
313
313
 
314
314
  // --- Public API ---
315
315
 
316
- /**
317
- * hedmatch — does the pattern match the ENTIRE string?
318
- * For path matching, WHERE clauses, full-string comparison.
319
- */
316
+ // hedmatch — full-string match (path, WHERE clause).
320
317
  export function hedmatch(pattern, string) {
321
318
  if (string === null) return false;
322
319
 
@@ -345,11 +342,7 @@ export function hedmatch(pattern, string) {
345
342
  return false;
346
343
  }
347
344
 
348
- /**
349
- * hedsearch — find the pattern anywhere IN the string.
350
- * For substring search, content filtering, "does this text contain...".
351
- * Returns { found, match, index } or { found: false }.
352
- */
345
+ // hedsearch — substring match → { found, match, index }.
353
346
  export function hedsearch(pattern, string) {
354
347
  if (string === null) return { found: false };
355
348
 
@@ -400,10 +393,7 @@ export function hedsearch(pattern, string) {
400
393
  return { found: false };
401
394
  }
402
395
 
403
- /**
404
- * hedreplace — find pattern in string, replace with replacement.
405
- * Returns the new string, or null if pattern not found.
406
- */
396
+ // hedreplace — substitute; null when pattern not found.
407
397
  export function hedreplace(pattern, replacement, string) {
408
398
  if (string === null) return null;
409
399
 
@@ -446,5 +436,4 @@ export function hedreplace(pattern, replacement, string) {
446
436
  return null;
447
437
  }
448
438
 
449
- // SQL functions are in separate files (hedmatch.js, hedsearch.js)
450
- // that import from this library. Filename = SQL function name.
439
+ // SQL functions live in sibling files; filename = SQL function name.
@@ -1,10 +1,4 @@
1
- /**
2
- * Sed syntax parsing. Handles s/search/replace/flags with:
3
- * - Escaped delimiters (\\/)
4
- * - Chained commands (s/a/b/ s/c/d/)
5
- * - Flag extraction (g, i, m, s, v)
6
- */
7
-
1
+ // Parses s/search/replace/flags with escaped delimiters, chains, and g/i/m/s/v flags.
8
2
  function splitSed(str, delim) {
9
3
  const parts = [];
10
4
  let current = "";
@@ -2,13 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- /**
6
- * Read a sibling tooldoc markdown file and return its model-facing text.
7
- * Strips HTML comments (rationale stays in source, never reaches the model)
8
- * and collapses any blank-line runs left behind. Each plugin's Doc.js is a
9
- * one-liner that defers to this so authors edit normal markdown instead of
10
- * a JS array of [text, rationale] pairs.
11
- */
5
+ // Read sibling tooldoc .md; strips HTML comments (rationale stays out of the model packet).
12
6
  export function loadDoc(metaUrl, name) {
13
7
  const dir = dirname(fileURLToPath(metaUrl));
14
8
  return readFileSync(join(dir, name), "utf8")
@@ -17,23 +11,37 @@ export function loadDoc(metaUrl, name) {
17
11
  .trim();
18
12
  }
19
13
 
20
- /**
21
- * Translate a log entry path into its companion data-scheme base path.
22
- * `log://turn_N/{action}/{rest}` → `{action}://turn_N/{rest}`.
23
- * Streaming producers (sh, env) create data channel entries under the
24
- * producer scheme while the audit record lives in the log scheme; this
25
- * helper bridges the two namespaces. Returns null for non-log paths.
26
- */
14
+ // log://turn_N/{action}/{rest} → {action}://turn_N/{rest}; null if not a log path.
27
15
  export function logPathToDataBase(logPath) {
28
16
  const m = logPath?.match(/^log:\/\/turn_(\d+)\/([^/]+)\/(.+)$/);
29
17
  if (!m) return null;
30
18
  return `${m[2]}://turn_${m[1]}/${m[3]}`;
31
19
  }
32
20
 
33
- /**
34
- * Shared helper for pattern-based tool results.
35
- * Used by get, set, store, and rm tools.
36
- */
21
+ // env/sh stdout/stderr summary projection: header with line range + last
22
+ // TAIL_LINES of body. The header tells the model exactly which slice is
23
+ // shown so it can issue <get line="N" limit="M"/> for the rest without
24
+ // re-running the command.
25
+ export function streamSummary(label, entry, TAIL_LINES = 12) {
26
+ if (!entry.body) return "";
27
+ const { body, attributes } = entry;
28
+ const command = attributes.command;
29
+ const channel = attributes.channel === 2 ? "stderr" : "stdout";
30
+ const trailingNewline = body.endsWith("\n");
31
+ const lines = trailingNewline
32
+ ? body.slice(0, -1).split("\n")
33
+ : body.split("\n");
34
+ const total = lines.length;
35
+ if (total <= TAIL_LINES) {
36
+ return `# ${label} ${command} (${channel}, ${total}L)\n${body}`;
37
+ }
38
+ const startLine = total - TAIL_LINES + 1;
39
+ const tail =
40
+ lines.slice(-TAIL_LINES).join("\n") + (trailingNewline ? "\n" : "");
41
+ return `# ${label} ${command} (${channel}, tail L${startLine}-${total}/${total}; <get line="1" limit="N"/> for head)\n${tail}`;
42
+ }
43
+
44
+ // Pattern-result log entry shared by get/set/store/rm.
37
45
  export async function storePatternResult(
38
46
  store,
39
47
  runId,
@@ -42,13 +50,13 @@ export async function storePatternResult(
42
50
  path,
43
51
  bodyFilter,
44
52
  matches,
45
- { preview = false, loopId = null, attributes = null } = {},
53
+ { manifest = false, loopId = null, attributes = null } = {},
46
54
  ) {
47
55
  const logSlug = await store.logPath(runId, turn, scheme, path);
48
56
  const filter = bodyFilter ? ` body="${bodyFilter}"` : "";
49
57
  const total = matches.reduce((s, m) => s + m.tokens, 0);
50
58
  const listing = matches.map((m) => `${m.path} (${m.tokens})`).join("\n");
51
- const prefix = preview ? "PREVIEW " : "";
59
+ const prefix = manifest ? "MANIFEST " : "";
52
60
  const body = `${prefix}${scheme} path="${path}"${filter}: ${matches.length} matched (${total} tokens)\n${listing}`;
53
61
  await store.set({
54
62
  runId,