@possumtech/rummy 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.env.example +40 -15
  2. package/.xai.key +1 -0
  3. package/PLUGINS.md +169 -53
  4. package/README.md +38 -32
  5. package/SPEC.md +366 -179
  6. package/bin/digest.js +1097 -0
  7. package/biome/no-fallbacks.grit +2 -2
  8. package/gemini.key +1 -0
  9. package/lang/en.json +10 -1
  10. package/migrations/001_initial_schema.sql +9 -2
  11. package/package.json +19 -8
  12. package/service.js +1 -0
  13. package/src/agent/AgentLoop.js +76 -26
  14. package/src/agent/ContextAssembler.js +2 -0
  15. package/src/agent/Entries.js +238 -60
  16. package/src/agent/ProjectAgent.js +44 -0
  17. package/src/agent/TurnExecutor.js +99 -30
  18. package/src/agent/XmlParser.js +206 -111
  19. package/src/agent/errors.js +35 -0
  20. package/src/agent/known_queries.sql +1 -1
  21. package/src/agent/known_store.sql +3 -42
  22. package/src/agent/materializeContext.js +30 -1
  23. package/src/agent/runs.sql +8 -18
  24. package/src/agent/tokens.js +0 -1
  25. package/src/agent/turns.sql +1 -0
  26. package/src/hooks/Hooks.js +26 -0
  27. package/src/hooks/RummyContext.js +12 -1
  28. package/src/lib/hedberg/README.md +60 -0
  29. package/src/lib/hedberg/hedberg.js +60 -0
  30. package/src/lib/hedberg/marker.js +158 -0
  31. package/src/{plugins → lib}/hedberg/matcher.js +1 -2
  32. package/src/llm/LlmProvider.js +41 -3
  33. package/src/llm/openaiStream.js +17 -0
  34. package/src/plugins/ask_user/ask_user.js +12 -2
  35. package/src/plugins/ask_user/ask_userDoc.md +1 -5
  36. package/src/plugins/budget/README.md +29 -24
  37. package/src/plugins/budget/budget.js +166 -110
  38. package/src/plugins/cli/README.md +3 -4
  39. package/src/plugins/cli/cli.js +31 -5
  40. package/src/plugins/cloudflare/cloudflare.js +136 -0
  41. package/src/plugins/cp/cp.js +41 -4
  42. package/src/plugins/cp/cpDoc.md +5 -6
  43. package/src/plugins/engine/engine.sql +1 -1
  44. package/src/plugins/env/README.md +5 -4
  45. package/src/plugins/env/env.js +7 -4
  46. package/src/plugins/env/envDoc.md +7 -8
  47. package/src/plugins/error/error.js +56 -15
  48. package/src/plugins/file/README.md +12 -3
  49. package/src/plugins/file/file.js +2 -2
  50. package/src/plugins/get/get.js +59 -36
  51. package/src/plugins/get/getDoc.md +10 -34
  52. package/src/plugins/google/google.js +115 -0
  53. package/src/plugins/hedberg/hedberg.js +13 -56
  54. package/src/plugins/helpers.js +66 -12
  55. package/src/plugins/index.js +1 -2
  56. package/src/plugins/instructions/README.md +44 -47
  57. package/src/plugins/instructions/instructions-system.md +44 -0
  58. package/src/plugins/instructions/instructions-user.md +53 -0
  59. package/src/plugins/instructions/instructions.js +58 -189
  60. package/src/plugins/known/README.md +6 -7
  61. package/src/plugins/known/known.js +24 -30
  62. package/src/plugins/log/log.js +41 -32
  63. package/src/plugins/mv/mv.js +40 -1
  64. package/src/plugins/mv/mvDoc.md +1 -8
  65. package/src/plugins/ollama/ollama.js +4 -3
  66. package/src/plugins/openai/openai.js +4 -3
  67. package/src/plugins/openrouter/openrouter.js +14 -4
  68. package/src/plugins/persona/README.md +11 -13
  69. package/src/plugins/persona/default.md +29 -0
  70. package/src/plugins/persona/persona.js +10 -66
  71. package/src/plugins/policy/policy.js +23 -22
  72. package/src/plugins/prompt/README.md +37 -27
  73. package/src/plugins/prompt/prompt.js +13 -19
  74. package/src/plugins/rm/rm.js +18 -0
  75. package/src/plugins/rm/rmDoc.md +5 -6
  76. package/src/plugins/rpc/rpc.js +3 -3
  77. package/src/plugins/set/set.js +205 -323
  78. package/src/plugins/set/setDoc.md +47 -17
  79. package/src/plugins/sh/README.md +6 -5
  80. package/src/plugins/sh/sh.js +8 -5
  81. package/src/plugins/sh/shDoc.md +7 -8
  82. package/src/plugins/skill/README.md +37 -14
  83. package/src/plugins/skill/skill.js +200 -101
  84. package/src/plugins/skill/skillDoc.js +3 -0
  85. package/src/plugins/skill/skillDoc.md +9 -0
  86. package/src/plugins/stream/README.md +7 -6
  87. package/src/plugins/stream/finalize.js +100 -0
  88. package/src/plugins/stream/stream.js +13 -45
  89. package/src/plugins/telemetry/telemetry.js +27 -4
  90. package/src/plugins/think/think.js +2 -3
  91. package/src/plugins/think/thinkDoc.md +2 -4
  92. package/src/plugins/unknown/README.md +1 -1
  93. package/src/plugins/unknown/unknown.js +17 -19
  94. package/src/plugins/update/update.js +4 -51
  95. package/src/plugins/update/updateDoc.md +21 -6
  96. package/src/plugins/xai/xai.js +68 -102
  97. package/src/plugins/yolo/yolo.js +102 -75
  98. package/src/sql/functions/hedmatch.js +1 -1
  99. package/src/sql/functions/hedreplace.js +1 -1
  100. package/src/sql/functions/hedsearch.js +1 -1
  101. package/src/sql/functions/slugify.js +16 -2
  102. package/BENCH_ENVIRONMENT.md +0 -230
  103. package/CLIENT_INTERFACE.md +0 -396
  104. package/last_run.txt +0 -5617
  105. package/scriptify/ask_run.js +0 -77
  106. package/scriptify/cache_probe.js +0 -66
  107. package/scriptify/cache_probe_grok.js +0 -74
  108. package/src/agent/budget.js +0 -33
  109. package/src/agent/config.js +0 -38
  110. package/src/plugins/hedberg/README.md +0 -71
  111. package/src/plugins/hedberg/docs.md +0 -0
  112. package/src/plugins/hedberg/edits.js +0 -55
  113. package/src/plugins/hedberg/normalize.js +0 -17
  114. package/src/plugins/hedberg/sed.js +0 -49
  115. package/src/plugins/instructions/instructions.md +0 -34
  116. package/src/plugins/instructions/instructions_104.md +0 -8
  117. package/src/plugins/instructions/instructions_105.md +0 -39
  118. package/src/plugins/instructions/instructions_106.md +0 -22
  119. package/src/plugins/instructions/instructions_107.md +0 -17
  120. package/src/plugins/instructions/instructions_108.md +0 -0
  121. package/src/plugins/known/knownDoc.js +0 -3
  122. package/src/plugins/known/knownDoc.md +0 -8
  123. package/src/plugins/unknown/unknownDoc.js +0 -3
  124. package/src/plugins/unknown/unknownDoc.md +0 -11
  125. package/turns/cli_1777462658211/turn_001.txt +0 -772
  126. package/turns/cli_1777462658211/turn_002.txt +0 -606
  127. package/turns/cli_1777462658211/turn_003.txt +0 -667
  128. package/turns/cli_1777462658211/turn_004.txt +0 -297
  129. package/turns/cli_1777462658211/turn_005.txt +0 -301
  130. package/turns/cli_1777462658211/turn_006.txt +0 -262
  131. package/turns/cli_1777465095132/turn_001.txt +0 -715
  132. package/turns/cli_1777465095132/turn_002.txt +0 -236
  133. package/turns/cli_1777465095132/turn_003.txt +0 -287
  134. package/turns/cli_1777465095132/turn_004.txt +0 -694
  135. package/turns/cli_1777465095132/turn_005.txt +0 -422
  136. package/turns/cli_1777465095132/turn_006.txt +0 -365
  137. package/turns/cli_1777465095132/turn_007.txt +0 -885
  138. package/turns/cli_1777465095132/turn_008.txt +0 -1277
  139. package/turns/cli_1777465095132/turn_009.txt +0 -736
  140. /package/src/{plugins → lib}/hedberg/patterns.js +0 -0
@@ -1,7 +1,7 @@
1
1
  -- PREP: get_known_entries
2
2
  SELECT
3
3
  path, scheme, state, outcome, visibility, body, turn, hash
4
- , attributes, countTokens(body) AS tokens, scope, loop_id
4
+ , attributes, scope, loop_id, countTokens(body) AS tokens
5
5
  FROM known_entries
6
6
  WHERE run_id = :run_id
7
7
  ORDER BY path;
@@ -230,7 +230,7 @@ WHERE run_id = :run_id AND entry_id IN (
230
230
  -- by id (insertion order) for streaming consumers; otherwise by path.
231
231
  SELECT
232
232
  e.id, e.path, e.body, e.scheme, rv.state, rv.outcome, rv.visibility, rv.turn
233
- , countTokens(e.body) AS tokens, e.attributes
233
+ , e.attributes, countTokens(e.body) AS tokens
234
234
  FROM run_views AS rv
235
235
  JOIN entries AS e ON e.id = rv.entry_id
236
236
  JOIN schemes AS s ON s.name = COALESCE(e.scheme, 'file')
@@ -292,9 +292,7 @@ WHERE run_id = :run_id AND entry_id IN (
292
292
  -- matches the old RETURNING (path, tokens) for caller compatibility.
293
293
  -- State filter: skip failed/cancelled entries (they're already not
294
294
  -- contributing visible context — demoting them would be misleading).
295
- -- Scheme filter: skip known/unknown these are the model's deliverables,
296
- -- not housekeeping. Auto-demoting just-created knowns punishes the
297
- -- correct Distill+Demote pattern.
295
+ -- All schemes participate uniformly per SPEC §budget_enforcement.
298
296
  SELECT e.path, countTokens(e.body) AS tokens
299
297
  FROM run_views AS rv
300
298
  JOIN entries AS e ON e.id = rv.entry_id
@@ -302,15 +300,12 @@ WHERE
302
300
  rv.run_id = :run_id
303
301
  AND rv.turn = :turn
304
302
  AND rv.visibility = 'visible'
305
- AND rv.state NOT IN ('failed', 'cancelled')
306
- AND e.scheme NOT IN ('known', 'unknown');
303
+ AND rv.state NOT IN ('failed', 'cancelled');
307
304
 
308
305
  -- PREP: demote_turn_entries
309
306
  -- View-layer only — visibility lives on run_views. State untouched.
310
307
  -- Call get_turn_demotion_targets first if you need the list of what
311
308
  -- was demoted (used by budget plugin for the overflow error body).
312
- -- Scheme filter mirrors get_turn_demotion_targets — never demote the
313
- -- model's deliverables (known/unknown) along with housekeeping.
314
309
  UPDATE run_views
315
310
  SET
316
311
  visibility = 'summarized'
@@ -319,38 +314,4 @@ WHERE
319
314
  run_id = :run_id
320
315
  AND turn = :turn
321
316
  AND visibility = 'visible'
322
- AND state NOT IN ('failed', 'cancelled')
323
- AND NOT EXISTS (
324
- SELECT 1
325
- FROM entries AS e
326
- WHERE
327
- e.id = run_views.entry_id
328
- AND e.scheme IN ('known', 'unknown')
329
- );
330
-
331
- -- PREP: get_run_visible_targets
332
- -- All visible entries across the run, oldest promotion first. Used by
333
- -- budget postDispatch as the fallback demotion set when this-turn
334
- -- demotion yields nothing but the packet still overflows (promotions
335
- -- from prior turns the model forgot to demote themselves).
336
- SELECT e.path, countTokens(e.body) AS tokens, rv.turn
337
- FROM run_views AS rv
338
- JOIN entries AS e ON e.id = rv.entry_id
339
- WHERE
340
- rv.run_id = :run_id
341
- AND rv.visibility = 'visible'
342
- AND rv.state NOT IN ('failed', 'cancelled')
343
- ORDER BY rv.turn, e.id;
344
-
345
- -- PREP: demote_run_visible
346
- -- Broad cross-turn demotion. Separate prep from demote_turn_entries
347
- -- so the caller's intent (surgical this-turn vs fallback all-visible)
348
- -- stays explicit.
349
- UPDATE run_views
350
- SET
351
- visibility = 'summarized'
352
- , updated_at = CURRENT_TIMESTAMP
353
- WHERE
354
- run_id = :run_id
355
- AND visibility = 'visible'
356
317
  AND state NOT IN ('failed', 'cancelled');
@@ -1,10 +1,21 @@
1
+ import { SUMMARY_MAX_CHARS } from "../plugins/helpers.js";
1
2
  import ContextAssembler from "./ContextAssembler.js";
2
3
  import { countLines, countTokens } from "./tokens.js";
3
4
 
5
+ // Defensive cap: model-written summary projections (knowns, unknowns,
6
+ // log actions, etc.) must produce ≤ SUMMARY_MAX_CHARS — the contract
7
+ // floor for terse model-authored summaries. File-scheme entries are
8
+ // exempt: their summarized projection is a structural derivative
9
+ // (rummy.repo's symbol map), bounded by the file's actual complexity,
10
+ // not by writer discipline. Truncating symbol data at 500 chars
11
+ // destroys its utility. Files either render blank (no symbols
12
+ // extracted) or render their full symbol map.
13
+
4
14
  // Rebuild turn_context from v_model_context and assemble messages.
5
15
  export default async function materializeContext({
6
16
  db,
7
17
  hooks,
18
+ entries,
8
19
  runId,
9
20
  loopId,
10
21
  turn,
@@ -12,6 +23,7 @@ export default async function materializeContext({
12
23
  mode,
13
24
  toolSet,
14
25
  contextSize,
26
+ persona = "",
15
27
  }) {
16
28
  await db.clear_turn_context.run({ run_id: runId, turn });
17
29
  const viewRows = await db.get_model_context.all({ run_id: runId });
@@ -37,10 +49,26 @@ export default async function materializeContext({
37
49
  ...baseEntry,
38
50
  visibility: "visible",
39
51
  });
40
- const summarizedProjection = await hooks.tools.view(projectionKey, {
52
+ const rawSummarizedProjection = await hooks.tools.view(projectionKey, {
41
53
  ...baseEntry,
42
54
  visibility: "summarized",
43
55
  });
56
+ let summarizedProjection = rawSummarizedProjection;
57
+ if (
58
+ scheme !== "file" &&
59
+ typeof summarizedProjection === "string" &&
60
+ summarizedProjection.length > SUMMARY_MAX_CHARS
61
+ ) {
62
+ summarizedProjection = summarizedProjection.slice(0, SUMMARY_MAX_CHARS);
63
+ await hooks.error.log.emit({
64
+ store: entries,
65
+ runId,
66
+ turn,
67
+ loopId,
68
+ message: `${row.path} summarized projection overflow`,
69
+ soft: true,
70
+ });
71
+ }
44
72
  const vTokens = countTokens(visibleProjection);
45
73
  const sTokens = countTokens(summarizedProjection);
46
74
  const vLines = countLines(visibleProjection);
@@ -92,6 +120,7 @@ export default async function materializeContext({
92
120
  toolSet,
93
121
  lastContextTokens,
94
122
  turn,
123
+ persona,
95
124
  },
96
125
  hooks,
97
126
  );
@@ -110,24 +110,14 @@ SELECT
110
110
  FROM run_views
111
111
  WHERE run_id = :parent_run_id;
112
112
 
113
- -- PREP: archive_prior_prompt_artifacts
114
- -- Multi-prompt sessions accumulate artifacts from prior prompt cycles
115
- -- (consumed prompts, their per-turn logs). These pollute the validator's
116
- -- prior-prompts check on subsequent Deployment landings. Archive all
117
- -- prior prompt:// entries and prior-turn log:// entries when a new
118
- -- prompt arrives. Knowns/unknowns/file entries are untouched — they
119
- -- carry persistent knowledge across cycles. The loop_id IS NULL clause
120
- -- catches forked-in views from a parent run (per fork_known_entries),
121
- -- which represent prior cycles' artifacts inherited into a clean child.
122
- UPDATE run_views
123
- SET visibility = 'archived'
124
- WHERE run_id = :run_id
125
- AND visibility != 'archived'
126
- AND (turn < :current_turn OR loop_id IS NULL)
127
- AND entry_id IN (
128
- SELECT id FROM entries
129
- WHERE scheme IN ('prompt', 'log')
130
- );
113
+ -- PREP: set_next_turn
114
+ -- Forks inherit parent's next_turn so turn numbering is absolute
115
+ -- across the lineage; the budget grinder's `current_turn - 1` rule
116
+ -- then targets parent's last-turn promotions on the fork's first
117
+ -- dispatch. See SPEC §budget_enforcement.
118
+ UPDATE runs
119
+ SET next_turn = :next_turn
120
+ WHERE id = :run_id;
131
121
 
132
122
  -- PREP: get_active_runs
133
123
  SELECT r.id
@@ -1,6 +1,5 @@
1
1
  // Conservative chars/token approximation; RUMMY_TOKEN_DIVISOR controls the divisor.
2
2
  const DIVISOR = Number(process.env.RUMMY_TOKEN_DIVISOR);
3
- if (!DIVISOR) throw new Error("RUMMY_TOKEN_DIVISOR must be a non-zero number");
4
3
 
5
4
  export function countTokens(text) {
6
5
  if (!text) return 0;
@@ -14,6 +14,7 @@ SET
14
14
  , reasoning_tokens = :reasoning_tokens
15
15
  , total_tokens = :total_tokens
16
16
  , cost = :cost
17
+ , response_metadata = :response_metadata
17
18
  WHERE id = :id;
18
19
 
19
20
  -- PREP: get_run_usage
@@ -48,6 +48,14 @@ export default function createHooks(debug = false) {
48
48
  step: {
49
49
  completed: createEvent("run.step.completed"),
50
50
  },
51
+ // Fire-and-forget wake: any plugin that wants to deliver a new
52
+ // prompt onto a (possibly dormant) run emits with
53
+ // {runAlias, body, mode}. AgentLoop subscribes and runs inject —
54
+ // writes prompt://<nextTurn>, enqueues a loop, ensures the
55
+ // drainer is up. This is the "streaming child closed after the
56
+ // loop ended" rendezvous: the producer doesn't care whether the
57
+ // run is alive or asleep, just that the prompt reaches it.
58
+ wake: createEvent("run.wake"),
51
59
  },
52
60
  loop: {
53
61
  started: createEvent("loop.started"),
@@ -55,8 +63,26 @@ export default function createHooks(debug = false) {
55
63
  },
56
64
  turn: {
57
65
  started: createEvent("turn.started"),
66
+ // Pre-LLM packet shaping. Filter chain: subscribers receive
67
+ // `{ messages, rows, contextSize, lastPromptTokens,
68
+ // assembledTokens, ok, overflow }` and return a transformed
69
+ // packet. Budget plugin participates here to enforce ceilings
70
+ // (may demote, may set ok=false on overflow). Other plugins
71
+ // could trim, re-order, or annotate — same surface.
72
+ beforeDispatch: createFilter("turn.beforeDispatch"),
58
73
  response: createEvent("turn.response"),
74
+ // Post-dispatch event. Fired after the per-entry dispatch
75
+ // loop, before turn.completed. Budget subscribes here for
76
+ // post-dispatch demotion / 413 overflow detection.
77
+ dispatched: createEvent("turn.dispatched"),
59
78
  completed: createEvent("turn.completed"),
79
+ // Verdict filter chain: each subscriber receives the current
80
+ // verdict object and returns a (possibly modified) one.
81
+ // Initial value is { continue: true }; final value drives the
82
+ // loop's continue/abandon decision. Multi-plugin: strike streak,
83
+ // cycle detect, stagnation pressure, future voters all
84
+ // participate via this surface.
85
+ verdict: createFilter("turn.verdict"),
60
86
  },
61
87
  // SPEC #resolution covers the proposal hook chain.
62
88
  proposal: {
@@ -13,6 +13,7 @@ const CONTEXT_DEFAULTS = Object.freeze({
13
13
  systemPrompt: "",
14
14
  loopPrompt: "",
15
15
  writer: "model",
16
+ signal: null,
16
17
  });
17
18
 
18
19
  export default class RummyContext {
@@ -122,6 +123,16 @@ export default class RummyContext {
122
123
  return this.#context.writer;
123
124
  }
124
125
 
126
+ // AbortSignal tied to the current run/loop's controller. Plugins that
127
+ // spawn subprocesses or perform long-running work MUST honor this so
128
+ // drain (rummy-cli's 895s watchdog → projectAgent.shutdown) can flush
129
+ // telemetry before harbor's outer SIGKILL. Without this wired into a
130
+ // spawn, the in-flight subprocess outlives drain and rummy.db / turns/
131
+ // / last_run.txt never make it out of the docker sandbox.
132
+ get signal() {
133
+ return this.#context.signal;
134
+ }
135
+
125
136
  get system() {
126
137
  return this.#root.children.find((c) => c.tag === "system");
127
138
  }
@@ -153,7 +164,7 @@ export default class RummyContext {
153
164
  this.runId,
154
165
  "known",
155
166
  body,
156
- attributes?.summary,
167
+ attributes?.tags,
157
168
  );
158
169
  }
159
170
  await this.entries.set({
@@ -0,0 +1,60 @@
1
+ # hedberg {#hedberg_plugin}
2
+
3
+ The interpretation boundary between stochastic model output and
4
+ deterministic system operations.
5
+
6
+ Pattern matching (`hedmatch`, `hedsearch`) auto-detects glob, regex
7
+ (via `/pattern/flags`), jsonpath, xpath, or literal. `Hedberg.replace`
8
+ does fuzzy literal substitution — exact substring first, falling
9
+ through to heuristic whitespace-tolerant matching when the literal
10
+ miss is plausibly indentation drift. Edit-shape parsing
11
+ (`<<IDENT...IDENT` markers in `<set>` bodies) lives in
12
+ `marker.js` and is invoked by the XmlParser at `<set>` resolution
13
+ time; see SPEC.md "Edit Syntax".
14
+
15
+ ## Usage
16
+
17
+ Any plugin can access hedberg via `core.hooks.hedberg`:
18
+
19
+ ```js
20
+ constructor(core) {
21
+ const { match, search, replace, generatePatch } = core.hooks.hedberg;
22
+ }
23
+ ```
24
+
25
+ ## API (available on core.hooks.hedberg)
26
+
27
+ | Method | Purpose |
28
+ |---|---|
29
+ | `match(pattern, string)` | Full-string pattern match (glob, regex, literal) |
30
+ | `search(pattern, string)` | Substring search, returns `{ found, match, index }` |
31
+ | `replace(body, search, replacement)` | Fuzzy literal replacement (whitespace-tolerant) |
32
+ | `generatePatch(path, old, new)` | Generate unified diff |
33
+
34
+ ### Hedberg.replace(body, search, replacement)
35
+
36
+ Apply a replacement to text. Exact substring substitution via
37
+ `String.replaceAll` first; if no literal match, falls through to
38
+ heuristic fuzzy matching that's tolerant of whitespace and
39
+ indentation drift.
40
+
41
+ ```js
42
+ const result = Hedberg.replace(fileContent, "port = 3000", "port = 8080");
43
+ // result: { patch, searchText, replaceText, warning, error }
44
+ ```
45
+
46
+ For regex matching, use the explicit `/pattern/flags` syntax via
47
+ `hedmatch` / `hedsearch`.
48
+
49
+ ## Files
50
+
51
+ - **hedberg.js** — plugin class, `replace()` method
52
+ - **marker.js** — edit-syntax marker parser (`<<IDENT...IDENT`)
53
+ - **patterns.js** — pattern type detection (regex, glob, jsonpath, xpath, literal)
54
+ - **matcher.js** — heuristic fuzzy matching, diff generation
55
+
56
+ ## Future
57
+
58
+ This will become a separate npm package (`@possumtech/rummy.hedberg`)
59
+ to isolate the stochastic interpretation logic from the deterministic
60
+ core service.
@@ -0,0 +1,60 @@
1
+ import HeuristicMatcher, { generatePatch } from "./matcher.js";
2
+ import { hedmatch, hedsearch } from "./patterns.js";
3
+
4
+ // Stochastic→deterministic boundary; exposes pattern utilities on
5
+ // core.hedberg. SPEC #hedberg. Edit-shape parsing lives in marker.js
6
+ // and is invoked from XmlParser at <set> resolution time.
7
+ export default class Hedberg {
8
+ #core;
9
+
10
+ constructor(core) {
11
+ this.#core = core;
12
+
13
+ core.hooks.hedberg = {
14
+ match: hedmatch,
15
+ search: hedsearch,
16
+ replace: Hedberg.replace,
17
+ generatePatch,
18
+ };
19
+ }
20
+
21
+ // Order: literal substitution → heuristic fuzzy.
22
+ //
23
+ // sed=true semantically means "literal substring substitution with
24
+ // regex-style escape friendliness." The model writes `\[`, `\.`,
25
+ // `\|`, etc. out of muscle memory from real sed, but we don't
26
+ // compile a regex — native String.replaceAll does the substitution.
27
+ // We strip the regex-meta backslashes from search and replacement
28
+ // so the model's escaped chars match their literal counterparts in
29
+ // body. This sidesteps a class of "regex-meta in content" failures
30
+ // and the parser-edge-case surface that compiling user input as
31
+ // regex drags in.
32
+ static replace(body, search, replacement, { sed = false } = {}) {
33
+ let patch = null;
34
+ let warning = null;
35
+ let error = null;
36
+ const stripRegexEscapes = (s) => s.replace(/\\([[\](){}.*+?^$|\\])/g, "$1");
37
+ const searchText = sed ? stripRegexEscapes(search) : search;
38
+ const replaceText = sed ? stripRegexEscapes(replacement) : replacement;
39
+
40
+ if (body.includes(searchText)) {
41
+ patch = body.replaceAll(searchText, replaceText);
42
+ }
43
+
44
+ if (!patch) {
45
+ const matched = HeuristicMatcher.matchAndPatch(
46
+ "",
47
+ body,
48
+ searchText,
49
+ replaceText,
50
+ );
51
+ patch = matched.newContent;
52
+ warning = matched.warning;
53
+ error = matched.error;
54
+ }
55
+
56
+ return { patch, searchText, replaceText, warning, error };
57
+ }
58
+ }
59
+
60
+ export { generatePatch };
@@ -0,0 +1,158 @@
1
+ // Edit-syntax marker parser. Recognizes bash-heredoc-shaped
2
+ // `<<IDENT...IDENT` body markers inside `<set>` content and routes
3
+ // by IDENT prefix to one of six operations: NEW, PREPEND, APPEND,
4
+ // REPLACE, DELETE, SEARCH. Non-keyword IDENTs (e.g. `<<DOC`, `<<EOF`)
5
+ // route to REPLACE — the content between markers becomes the full
6
+ // new body.
7
+ //
8
+ // Grammar:
9
+ // - Opener: `<<IDENT` where IDENT matches `[A-Z][A-Za-z0-9_]*`.
10
+ // Boundary: preceded by start-of-body, whitespace, or `>` (so
11
+ // `vec<<SEARCH` mid-token does not false-trigger).
12
+ // - Closer: bare IDENT (matching opener exactly) with non-word
13
+ // boundaries — preceded by whitespace/start, followed by
14
+ // whitespace, `<`, `>`, or end.
15
+ // - SEARCH must be immediately followed by REPLACE; the pair maps
16
+ // to one search_replace op. Lone SEARCH is a parse error.
17
+ // - Trailing alphanumeric suffix on the IDENT is opaque to routing
18
+ // (`<<SEARCH1` and `<<SEARCH` both route to SEARCH). Suffix
19
+ // exists so nested markers can disambiguate, same convention as
20
+ // bash heredoc `<<EOF1` vs `<<EOF`. When a body literally
21
+ // contains the bare keyword (`SEARCH` in prose or code), the
22
+ // model picks a suffix so the inner literal does not prematurely
23
+ // close the outer marker.
24
+ //
25
+ // The bare `<<IDENT` shape is visibly distinct from the engine's
26
+ // packet-rendering shape `<<:::IDENT` (see plugins/helpers.js). Edit
27
+ // syntax is bare-only: a body with `<<:::IDENT` does NOT match this
28
+ // parser and falls through to plain-body REPLACE with the markers
29
+ // preserved as literal content. Keep the two grammars distinct so
30
+ // model emissions and engine renderings can never be confused.
31
+ //
32
+ // Returns:
33
+ // { ops: null, error: null } — no markers found, treat body as plain.
34
+ // { ops: [{...}], error: null } — well-formed marker(s).
35
+ // { ops: null, error: "..." } — parse failure (lone SEARCH, unclosed).
36
+
37
+ const KEYWORD_RE =
38
+ /^(NEW|PREPEND|APPEND|REPLACE|DELETE|SEARCH)([A-Za-z0-9_]*)$/;
39
+
40
+ // Opener: `<<IDENT` preceded by start-of-input, whitespace, or `>`.
41
+ const OPENER_RE = /(?<=^|[\s>])<<([A-Z][A-Za-z0-9_]*)/;
42
+
43
+ function operationFromIdent(ident) {
44
+ const m = ident.match(KEYWORD_RE);
45
+ if (m) return m[1].toLowerCase();
46
+ // Non-keyword IDENT — treat as REPLACE.
47
+ return "replace";
48
+ }
49
+
50
+ function findOpener(body, startIdx) {
51
+ const slice = body.slice(startIdx);
52
+ const match = slice.match(OPENER_RE);
53
+ if (!match) return null;
54
+ return {
55
+ ident: match[1],
56
+ openerStart: startIdx + match.index,
57
+ openerEnd: startIdx + match.index + match[0].length,
58
+ };
59
+ }
60
+
61
+ function findCloser(body, startIdx, ident) {
62
+ const escIdent = ident.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ // Closer: bare IDENT with non-word boundaries — preceded by
64
+ // whitespace or start-of-input, followed by whitespace, `<`, `>`,
65
+ // or end. The trailing `<` lets the SEARCH closer adjoin an
66
+ // immediately-following `<<REPLACE` opener (`SEARCH<<REPLACE`).
67
+ const re = new RegExp(`(?<=^|\\s)${escIdent}(?=[\\s<>]|$)`);
68
+ const slice = body.slice(startIdx);
69
+ const match = slice.match(re);
70
+ if (!match) return null;
71
+ return {
72
+ closerStart: startIdx + match.index,
73
+ closerEnd: startIdx + match.index + match[0].length,
74
+ };
75
+ }
76
+
77
+ function trimMarkerNewlines(content) {
78
+ let result = content;
79
+ if (result.startsWith("\n")) result = result.slice(1);
80
+ if (result.endsWith("\n")) result = result.slice(0, -1);
81
+ return result;
82
+ }
83
+
84
+ // Detect a body that is exactly one heredoc wrapping its entire content.
85
+ // Returns `{ ident, content }` if `body` is `<<IDENT\n...\nIDENT` (with
86
+ // optional surrounding whitespace), otherwise `null`. Used by non-`<set>`
87
+ // plugins to let models opaquely wrap multi-line scripts, tag-shaped
88
+ // prose, or content with special characters — without requiring escaping
89
+ // or string-quoting at the model layer. The plugin sees the unwrapped
90
+ // inner content as its body; the IDENT is attached to the command as
91
+ // `heredocIdent` for plugins that want to act on the label.
92
+ //
93
+ // Reuses the same `findOpener`/`findCloser` helpers as `parseMarkerBody`,
94
+ // so the grammar (boundary rules, IDENT shape, suffix nesting) stays
95
+ // single-sourced. Difference is just the validation: this function
96
+ // requires the heredoc to span the body exactly (opener at start,
97
+ // closer at end), where `parseMarkerBody` accepts multiple markers in
98
+ // sequence.
99
+ export function extractSingleHeredoc(body) {
100
+ if (!body) return null;
101
+ const trimmed = body.trim();
102
+ if (!trimmed.startsWith("<<")) return null;
103
+
104
+ const opener = findOpener(trimmed, 0);
105
+ if (!opener || opener.openerStart !== 0) return null;
106
+
107
+ const closer = findCloser(trimmed, opener.openerEnd, opener.ident);
108
+ if (!closer || closer.closerEnd !== trimmed.length) return null;
109
+
110
+ const content = trimMarkerNewlines(
111
+ trimmed.slice(opener.openerEnd, closer.closerStart),
112
+ );
113
+ return { ident: opener.ident, content };
114
+ }
115
+
116
+ export function parseMarkerBody(body) {
117
+ // Cheap rejection — most `<set>` bodies don't contain markers.
118
+ if (!/<<[A-Z]/.test(body)) return { ops: null, error: null };
119
+
120
+ const raw = [];
121
+ let i = 0;
122
+ while (i < body.length) {
123
+ const opener = findOpener(body, i);
124
+ if (!opener) break;
125
+ const op = operationFromIdent(opener.ident);
126
+ const closer = findCloser(body, opener.openerEnd, opener.ident);
127
+ if (!closer) {
128
+ return { ops: null, error: `unclosed <<${opener.ident}` };
129
+ }
130
+ const content = trimMarkerNewlines(
131
+ body.slice(opener.openerEnd, closer.closerStart),
132
+ );
133
+ raw.push({ op, content });
134
+ i = closer.closerEnd;
135
+ }
136
+ if (raw.length === 0) return { ops: null, error: null };
137
+
138
+ // Pair adjacent SEARCH+REPLACE into one search_replace op.
139
+ const ops = [];
140
+ for (let j = 0; j < raw.length; j++) {
141
+ const cur = raw[j];
142
+ if (cur.op === "search") {
143
+ const next = raw[j + 1];
144
+ if (!next || next.op !== "replace") {
145
+ return { ops: null, error: "lone SEARCH (no REPLACE)" };
146
+ }
147
+ ops.push({
148
+ op: "search_replace",
149
+ search: cur.content,
150
+ replace: next.content,
151
+ });
152
+ j++;
153
+ } else {
154
+ ops.push(cur);
155
+ }
156
+ }
157
+ return { ops, error: null };
158
+ }
@@ -99,8 +99,7 @@ export default class HeuristicMatcher {
99
99
  return {
100
100
  patch: null,
101
101
  warning: null,
102
- error:
103
- "SEARCH blocks are matched literally, not as a pattern. Could not find the SEARCH block in the file.",
102
+ error: "SEARCH text not found in current body.",
104
103
  };
105
104
  }
106
105
 
@@ -1,4 +1,3 @@
1
- import config from "../agent/config.js";
2
1
  import msg from "../agent/messages.js";
3
2
  import {
4
3
  ContextExceededError,
@@ -7,7 +6,19 @@ import {
7
6
  } from "./errors.js";
8
7
  import { retryClassified } from "./retry.js";
9
8
 
10
- const { LLM_DEADLINE, LLM_MAX_BACKOFF } = config;
9
+ const LLM_DEADLINE = Number(process.env.RUMMY_LLM_DEADLINE);
10
+ const LLM_MAX_BACKOFF = Number(process.env.RUMMY_LLM_MAX_BACKOFF);
11
+
12
+ const TOKEN_DIVISOR = Number(process.env.RUMMY_TOKEN_DIVISOR);
13
+ // Floor on derived max_tokens. If prompt eats almost the entire context,
14
+ // we still ask for at least this many output tokens so the model has
15
+ // room to emit a usable terminal `<update>`.
16
+ const MAX_TOKENS_FLOOR = 1024;
17
+ // Fraction of the model's context the request may consume (prompt +
18
+ // max_tokens combined). The remaining 1−X absorbs tokenizer drift
19
+ // between our chars/RUMMY_TOKEN_DIVISOR estimate and the provider's
20
+ // BPE-based count plus message-envelope overhead.
21
+ const BUDGET_CEILING = Number(process.env.RUMMY_BUDGET_CEILING);
11
22
 
12
23
  // Per-category retry policies. Gateway/server are bounded short because
13
24
  // upstream-down won't recover by waiting; warmup/rate_limit get the full
@@ -55,7 +66,34 @@ export default class LlmProvider {
55
66
  (process.env.RUMMY_TEMPERATURE !== undefined
56
67
  ? Number.parseFloat(process.env.RUMMY_TEMPERATURE)
57
68
  : undefined);
58
- const resolvedOptions = { ...options, temperature };
69
+
70
+ // Derive max_tokens from the model's context window minus the
71
+ // estimated prompt footprint. Without this, providers fall back
72
+ // to conservative defaults (a few thousand) and the model's
73
+ // response truncates mid-`<set>` body before reaching `<update>`,
74
+ // surfacing as a misleading "no <update>" verdict.
75
+ const contextLength = await this.getContextSize(model);
76
+ const promptEstimate = messages.reduce(
77
+ (sum, m) => sum + Math.ceil(m.content.length / TOKEN_DIVISOR),
78
+ 0,
79
+ );
80
+ const effectiveContext = Math.floor(contextLength * BUDGET_CEILING);
81
+ let maxTokens = Math.max(
82
+ MAX_TOKENS_FLOOR,
83
+ effectiveContext - promptEstimate,
84
+ );
85
+ // Per-model output ceiling. Models advertise huge context windows
86
+ // but actual max OUTPUT tokens is far smaller. Sending max_tokens
87
+ // above the model's real output cap pushes the request into
88
+ // undefined-behavior territory and can correlate with mid-emission
89
+ // EOT sampling. Set `RUMMY_OUTPUT_CAP_<alias>` per model where
90
+ // the published output ceiling is known.
91
+ const outputCapEnv = process.env[`RUMMY_OUTPUT_CAP_${model}`];
92
+ if (outputCapEnv) {
93
+ const cap = Number.parseInt(outputCapEnv, 10);
94
+ if (cap > 0) maxTokens = Math.min(maxTokens, cap);
95
+ }
96
+ const resolvedOptions = { ...options, temperature, maxTokens };
59
97
 
60
98
  const provider = this.#selectProvider(resolvedModel);
61
99
  if (!provider) {
@@ -62,6 +62,12 @@ export async function chatCompletionStream({ url, headers, body, signal }) {
62
62
  let usage = null;
63
63
  let model = null;
64
64
  let finishReason = null;
65
+ // Catch-all for chunk-level metadata that isn't `choices` or `usage` —
66
+ // id, system_fingerprint, service_tier, created, object, plus any
67
+ // provider-specific fields. The last-seen wins (these are typically
68
+ // stable across chunks; xAI/OpenAI repeat them, some land only on the
69
+ // final chunk).
70
+ const chunkMetadata = {};
65
71
 
66
72
  while (true) {
67
73
  const { done, value } = await reader.read();
@@ -90,6 +96,16 @@ export async function chatCompletionStream({ url, headers, body, signal }) {
90
96
  if (chunk.model) model = chunk.model;
91
97
  if (chunk.usage) usage = chunk.usage;
92
98
 
99
+ // Capture every non-content field the provider sends. We strip
100
+ // `choices` (handled below) and `usage` (already extracted) and
101
+ // keep the rest verbatim. Fields seen in a later chunk overwrite
102
+ // earlier ones — providers re-emit stable fields, and final-chunk
103
+ // fields (system_fingerprint on some, service_tier on others) win.
104
+ for (const [k, v] of Object.entries(chunk)) {
105
+ if (k === "choices" || k === "usage") continue;
106
+ chunkMetadata[k] = v;
107
+ }
108
+
93
109
  const choice = chunk.choices?.[0];
94
110
  if (!choice) continue;
95
111
  if (choice.finish_reason) finishReason = choice.finish_reason;
@@ -121,5 +137,6 @@ export async function chatCompletionStream({ url, headers, body, signal }) {
121
137
  },
122
138
  ],
123
139
  usage,
140
+ chunkMetadata,
124
141
  };
125
142
  }