@possumtech/rummy 2.0.0 → 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 (117) hide show
  1. package/.env.example +31 -5
  2. package/BENCH_ENVIRONMENT.md +230 -0
  3. package/CLIENT_INTERFACE.md +396 -0
  4. package/PLUGINS.md +93 -1
  5. package/SPEC.md +389 -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 +13 -9
  11. package/scriptify/ask_run.js +77 -0
  12. package/scriptify/cache_probe.js +66 -0
  13. package/scriptify/cache_probe_grok.js +74 -0
  14. package/service.js +22 -11
  15. package/src/agent/AgentLoop.js +62 -157
  16. package/src/agent/ContextAssembler.js +2 -9
  17. package/src/agent/Entries.js +54 -98
  18. package/src/agent/ProjectAgent.js +4 -11
  19. package/src/agent/TurnExecutor.js +48 -83
  20. package/src/agent/XmlParser.js +247 -273
  21. package/src/agent/budget.js +5 -28
  22. package/src/agent/config.js +38 -0
  23. package/src/agent/errors.js +7 -13
  24. package/src/agent/httpStatus.js +1 -19
  25. package/src/agent/known_queries.sql +1 -1
  26. package/src/agent/known_store.sql +12 -2
  27. package/src/agent/materializeContext.js +15 -18
  28. package/src/agent/pathEncode.js +5 -0
  29. package/src/agent/rummyHome.js +9 -0
  30. package/src/agent/runs.sql +37 -0
  31. package/src/agent/tokens.js +7 -7
  32. package/src/hooks/HookRegistry.js +1 -16
  33. package/src/hooks/Hooks.js +8 -33
  34. package/src/hooks/PluginContext.js +3 -21
  35. package/src/hooks/RpcRegistry.js +1 -4
  36. package/src/hooks/RummyContext.js +6 -16
  37. package/src/hooks/ToolRegistry.js +5 -15
  38. package/src/llm/LlmProvider.js +41 -33
  39. package/src/llm/errors.js +41 -4
  40. package/src/llm/openaiStream.js +125 -0
  41. package/src/llm/retry.js +109 -0
  42. package/src/plugins/budget/budget.js +55 -76
  43. package/src/plugins/cli/README.md +87 -0
  44. package/src/plugins/cli/bin.js +61 -0
  45. package/src/plugins/cli/cli.js +120 -0
  46. package/src/plugins/env/README.md +2 -1
  47. package/src/plugins/env/env.js +4 -6
  48. package/src/plugins/env/envDoc.md +2 -2
  49. package/src/plugins/error/error.js +23 -23
  50. package/src/plugins/file/file.js +2 -22
  51. package/src/plugins/get/get.js +12 -34
  52. package/src/plugins/get/getDoc.md +8 -6
  53. package/src/plugins/hedberg/edits.js +1 -11
  54. package/src/plugins/hedberg/hedberg.js +3 -26
  55. package/src/plugins/hedberg/normalize.js +1 -5
  56. package/src/plugins/hedberg/patterns.js +4 -15
  57. package/src/plugins/hedberg/sed.js +1 -7
  58. package/src/plugins/helpers.js +28 -20
  59. package/src/plugins/index.js +25 -41
  60. package/src/plugins/instructions/README.md +18 -0
  61. package/src/plugins/instructions/instructions.js +97 -38
  62. package/src/plugins/instructions/instructions.md +24 -15
  63. package/src/plugins/instructions/instructions_104.md +5 -4
  64. package/src/plugins/instructions/instructions_105.md +29 -36
  65. package/src/plugins/instructions/instructions_106.md +22 -0
  66. package/src/plugins/instructions/instructions_107.md +17 -0
  67. package/src/plugins/instructions/instructions_108.md +0 -8
  68. package/src/plugins/known/README.md +26 -6
  69. package/src/plugins/known/known.js +37 -34
  70. package/src/plugins/log/README.md +2 -2
  71. package/src/plugins/log/log.js +27 -34
  72. package/src/plugins/ollama/ollama.js +50 -66
  73. package/src/plugins/openai/openai.js +26 -44
  74. package/src/plugins/openrouter/openrouter.js +28 -52
  75. package/src/plugins/policy/README.md +8 -2
  76. package/src/plugins/policy/policy.js +8 -21
  77. package/src/plugins/prompt/README.md +22 -0
  78. package/src/plugins/prompt/prompt.js +14 -16
  79. package/src/plugins/rm/rm.js +5 -2
  80. package/src/plugins/rm/rmDoc.md +4 -4
  81. package/src/plugins/rpc/README.md +2 -1
  82. package/src/plugins/rpc/rpc.js +62 -48
  83. package/src/plugins/set/README.md +5 -1
  84. package/src/plugins/set/set.js +23 -33
  85. package/src/plugins/set/setDoc.md +1 -1
  86. package/src/plugins/sh/README.md +2 -1
  87. package/src/plugins/sh/sh.js +5 -11
  88. package/src/plugins/sh/shDoc.md +2 -2
  89. package/src/plugins/stream/README.md +6 -5
  90. package/src/plugins/stream/stream.js +6 -35
  91. package/src/plugins/telemetry/telemetry.js +26 -19
  92. package/src/plugins/think/think.js +4 -7
  93. package/src/plugins/unknown/unknown.js +8 -13
  94. package/src/plugins/update/update.js +42 -25
  95. package/src/plugins/update/updateDoc.md +3 -3
  96. package/src/plugins/xai/xai.js +30 -20
  97. package/src/plugins/yolo/yolo.js +159 -0
  98. package/src/server/ClientConnection.js +17 -47
  99. package/src/server/SocketServer.js +14 -14
  100. package/src/server/protocol.js +1 -10
  101. package/src/sql/functions/slugify.js +5 -7
  102. package/src/sql/v_model_context.sql +4 -11
  103. package/turns/cli_1777462658211/turn_001.txt +772 -0
  104. package/turns/cli_1777462658211/turn_002.txt +606 -0
  105. package/turns/cli_1777462658211/turn_003.txt +667 -0
  106. package/turns/cli_1777462658211/turn_004.txt +297 -0
  107. package/turns/cli_1777462658211/turn_005.txt +301 -0
  108. package/turns/cli_1777462658211/turn_006.txt +262 -0
  109. package/turns/cli_1777465095132/turn_001.txt +715 -0
  110. package/turns/cli_1777465095132/turn_002.txt +236 -0
  111. package/turns/cli_1777465095132/turn_003.txt +287 -0
  112. package/turns/cli_1777465095132/turn_004.txt +694 -0
  113. package/turns/cli_1777465095132/turn_005.txt +422 -0
  114. package/turns/cli_1777465095132/turn_006.txt +365 -0
  115. package/turns/cli_1777465095132/turn_007.txt +885 -0
  116. package/turns/cli_1777465095132/turn_008.txt +1277 -0
  117. package/turns/cli_1777465095132/turn_009.txt +736 -0
@@ -0,0 +1,87 @@
1
+ # cli
2
+
3
+ One-shot CLI client. Boots the service, runs a single `ask`/`act`,
4
+ prints the final summary to stdout, exits with code `0` on terminal
5
+ status `200` (non-zero otherwise). Server mode is unaffected — the
6
+ plugin is inert when `RUMMY_PROMPT` is unset.
7
+
8
+ ## Invocation
9
+
10
+ ```bash
11
+ rummy-cli --RUMMY_PROMPT="list files in /tmp" --RUMMY_MODEL=xfast
12
+ ```
13
+
14
+ All args are env-var-shape: `--KEY=value`, `--KEY value`, or `--KEY`
15
+ (boolean shorthand → `"1"`). Anything else is rejected with exit
16
+ code `2`. CLI flags trump every `.env*` file (Node's `loadEnvFile`
17
+ preserves existing vars).
18
+
19
+ ## Required env
20
+
21
+ | Var | Effect |
22
+ |---|---|
23
+ | `RUMMY_PROMPT` | Activates the plugin and supplies the instruction. |
24
+ | `RUMMY_MODEL` | Model alias (must match a registered `RUMMY_MODEL_<alias>`). |
25
+
26
+ ## Optional env
27
+
28
+ | Var | Default | Effect |
29
+ |---|---|---|
30
+ | `RUMMY_MODE` | `act` | `ask` or `act`. |
31
+
32
+ `RUMMY_RUN_TIMEOUT` is required at boot via `src/agent/config.js`;
33
+ default lives in `.env.example`. Watchdog exits with code `124` on
34
+ overflow.
35
+
36
+ Per-run defaults (`RUMMY_YOLO`, `RUMMY_NO_REPO`, `RUMMY_NO_WEB`,
37
+ `RUMMY_NO_INTERACTION`, `RUMMY_NO_PROPOSALS`) cascade through
38
+ `AgentLoop`'s boundary normalization — see `.env.example`.
39
+
40
+ ## Profile pattern
41
+
42
+ Layer profile-specific defaults via Node's `--env-file-if-exists`:
43
+
44
+ ```bash
45
+ node --env-file-if-exists=.env.example \
46
+ --env-file-if-exists=.env \
47
+ --env-file-if-exists=.env.tbench \
48
+ src/plugins/cli/bin.js \
49
+ --RUMMY_PROMPT="..." --RUMMY_MODEL=xfast
50
+ ```
51
+
52
+ A `.env.tbench` profile typically pins `RUMMY_YOLO=1`,
53
+ `RUMMY_NO_INTERACTION=1`, `RUMMY_NO_WEB=1`, plus model alias and
54
+ provider key. Bench harnesses call `rummy-cli` with just
55
+ `--RUMMY_PROMPT="..."` and let the profile carry the rest.
56
+
57
+ ## Exit codes
58
+
59
+ | Code | Meaning |
60
+ |---|---|
61
+ | `0` | Terminal status `200`. Model claimed success. |
62
+ | `1` | Terminal status in `{204, 413, 422, 499, 500}` or run crashed. |
63
+ | `2` | Arg parse error (invalid flag shape, missing required env). |
64
+ | `124` | Wall-clock timeout (`RUMMY_RUN_TIMEOUT` exceeded). |
65
+
66
+ External verifiers (terminal-bench, SWE-bench, etc.) decide actual
67
+ task success — the exit code only reports rummy's internal terminal
68
+ status.
69
+
70
+ ## Files
71
+
72
+ - **`cli.js`** — plugin class. Subscribes to `boot.completed`; on fire,
73
+ if `RUMMY_PROMPT` is set, constructs a `ProjectAgent`, kicks off
74
+ the run, awaits its terminal status, prints the latest update body,
75
+ exits.
76
+ - **`bin.js`** — executable. Parses env-shape args, mirrors
77
+ `bin/rummy.js`'s env-loading prelude, imports `service.js`.
78
+
79
+ ## Architectural notes
80
+
81
+ - The plugin uses the same `ProjectAgent` constructor as
82
+ `ClientConnection`. In CLI mode, `SocketServer` still starts (it's
83
+ cheap) — `process.exit()` from the plugin terminates everything.
84
+ - `core.on("boot.completed", ...)` is the plugin's only hook.
85
+ Subscribing earlier (e.g. constructor-time) would race plugin
86
+ registration order; `boot.completed` fires after all plugins are
87
+ inited and the DB is open.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, isAbsolute, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import resolveRummyHome from "../../agent/rummyHome.js";
7
+
8
+ // Env-var-shape args: --KEY=value, --KEY value, or --KEY (→ "1").
9
+ const ENV_FLAG = /^--([A-Z][A-Z0-9_]*)(?:=([\s\S]*))?$/;
10
+
11
+ function parseEnvArgs(argv) {
12
+ const args = argv.slice(2);
13
+ let i = 0;
14
+ while (i < args.length) {
15
+ const m = args[i].match(ENV_FLAG);
16
+ if (!m) {
17
+ console.error(
18
+ `rummy-cli: unknown arg ${JSON.stringify(args[i])}. ` +
19
+ "All args must be --KEY=value, --KEY value, or --KEY (env-var-shape).",
20
+ );
21
+ process.exit(2);
22
+ }
23
+ const [, name, inline] = m;
24
+ if (inline !== undefined) {
25
+ process.env[name] = inline;
26
+ i += 1;
27
+ continue;
28
+ }
29
+ const next = args[i + 1];
30
+ if (next === undefined || next.startsWith("--")) {
31
+ process.env[name] = "1";
32
+ i += 1;
33
+ continue;
34
+ }
35
+ process.env[name] = next;
36
+ i += 2;
37
+ }
38
+ }
39
+
40
+ parseEnvArgs(process.argv);
41
+
42
+ // Same env cascade as bin/rummy.js; CLI flags trump because loadEnvFile preserves existing vars.
43
+ const __dirname = dirname(fileURLToPath(import.meta.url));
44
+ const packageRoot = join(__dirname, "../../..");
45
+ const rummyHome = resolveRummyHome();
46
+
47
+ const cwd = process.cwd();
48
+ const baseDir = existsSync(join(cwd, ".env.example")) ? cwd : rummyHome;
49
+ if (existsSync(join(baseDir, ".env.example"))) {
50
+ process.loadEnvFile(join(baseDir, ".env.example"));
51
+ }
52
+ const userEnv = join(baseDir, ".env");
53
+ if (existsSync(userEnv)) process.loadEnvFile(userEnv);
54
+
55
+ process.env.RUMMY_HOME = rummyHome;
56
+ const dbPath = process.env.RUMMY_DB_PATH;
57
+ if (dbPath && !isAbsolute(dbPath)) {
58
+ process.env.RUMMY_DB_PATH = join(rummyHome, dbPath);
59
+ }
60
+
61
+ await import(join(packageRoot, "service.js"));
@@ -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,
@@ -1,7 +1,7 @@
1
- ## <get>[path/to/file]</get> - Promote an entry
1
+ ## <get path="[path/to/file]"/> - Promote an entry
2
2
 
3
- Example: <get>src/app.js</get>
4
- <!-- Simplest form. Body = path. -->
3
+ Example: <get path="src/app.js"/>
4
+ <!-- Simplest form. Path attribute. Body is reserved for content filter. -->
5
5
 
6
6
  Example: <get path="known://*">auth</get>
7
7
  <!-- Keyword recall: glob in path, search term in body. -->
@@ -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;