@possumtech/rummy 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +14 -6
  2. package/service.js +18 -10
  3. package/src/agent/AgentLoop.js +2 -11
  4. package/src/agent/ContextAssembler.js +34 -3
  5. package/src/agent/Entries.js +16 -89
  6. package/src/agent/ProjectAgent.js +1 -16
  7. package/src/agent/TurnExecutor.js +12 -52
  8. package/src/agent/XmlParser.js +30 -117
  9. package/src/agent/errors.js +3 -22
  10. package/src/agent/materializeContext.js +3 -11
  11. package/src/hooks/Hooks.js +0 -29
  12. package/src/lib/hedberg/hedberg.js +4 -14
  13. package/src/lib/hedberg/marker.js +15 -59
  14. package/src/llm/LlmProvider.js +13 -26
  15. package/src/llm/errors.js +3 -11
  16. package/src/llm/openaiStream.js +6 -46
  17. package/src/plugins/ask_user/ask_user.js +12 -17
  18. package/src/plugins/budget/README.md +46 -8
  19. package/src/plugins/budget/budget.js +23 -42
  20. package/src/plugins/cp/cp.js +28 -18
  21. package/src/plugins/env/env.js +11 -7
  22. package/src/plugins/error/error.js +8 -37
  23. package/src/plugins/get/get.js +42 -24
  24. package/src/plugins/google/google.js +23 -3
  25. package/src/plugins/helpers.js +34 -50
  26. package/src/plugins/instructions/README.md +2 -2
  27. package/src/plugins/instructions/instructions-user.md +1 -1
  28. package/src/plugins/instructions/instructions.js +19 -6
  29. package/src/plugins/known/known.js +1 -8
  30. package/src/plugins/log/log.js +15 -1
  31. package/src/plugins/mv/mv.js +29 -19
  32. package/src/plugins/persona/persona.js +4 -4
  33. package/src/plugins/prompt/README.md +1 -1
  34. package/src/plugins/prompt/prompt.js +1 -1
  35. package/src/plugins/rm/rm.js +26 -15
  36. package/src/plugins/rm/rmDoc.md +0 -2
  37. package/src/plugins/set/set.js +37 -84
  38. package/src/plugins/set/setDoc.md +16 -16
  39. package/src/plugins/sh/sh.js +10 -8
  40. package/src/plugins/skill/skillDoc.md +1 -1
  41. package/src/plugins/unknown/README.md +1 -1
  42. package/src/plugins/unknown/unknown.js +2 -6
  43. package/src/plugins/update/update.js +3 -2
  44. package/src/plugins/update/updateDoc.md +1 -1
  45. package/.env.example +0 -152
  46. package/.xai.key +0 -1
  47. package/PLUGINS.md +0 -962
  48. package/SPEC.md +0 -1897
  49. package/biome/no-fallbacks.grit +0 -50
  50. package/gemini.key +0 -1
@@ -3,17 +3,8 @@ import {
3
3
  parseMarkerBody,
4
4
  } from "../lib/hedberg/marker.js";
5
5
 
6
- // Edit-marker body opacity. When `#findBodyEnd` is scanning a `<set>`
7
- // body and hits an opener, jump past the matching closer so tag-shaped
8
- // content inside the marker (`</set>`, `<get/>`, etc.) doesn't trigger
9
- // structural recovery.
10
- //
11
- // Two opener shapes are recognized for opacity:
12
- // - `<<IDENT` — current edit syntax (parsed by marker.js).
13
- // - `<<:::IDENT` — packet-rendering shape (engine emits via
14
- // plugins/helpers.js). A model copy-pasting the packet shape into
15
- // a `<set>` body should still get clean opacity even though
16
- // marker.js routes such bodies to plain-body REPLACE.
6
+ // Edit-marker body opacity inside `<set>`. Two opener shapes recognized:
7
+ // `<<IDENT` (edit syntax) and `<<:::IDENT` (packet-rendering shape).
17
8
  function skipBareMarker(s, pos) {
18
9
  const m = s.slice(pos).match(/^<<([A-Z][A-Za-z0-9_]*)/);
19
10
  if (!m) return null;
@@ -53,15 +44,10 @@ export const ALL_TOOLS = new Set([
53
44
  "think",
54
45
  ]);
55
46
 
56
- // Per-tool resolution: missing canonical attribute is filled silently from the body.
47
+ // Per-tool resolution: missing canonical attribute is filled from the body.
57
48
  function resolveCommand(name, a, rawBody) {
58
- // Generic heredoc affordance: any non-`<set>` plugin's body may be
59
- // wrapped in a single `<<IDENT...IDENT` heredoc to opaquely contain
60
- // multi-line scripts, tag-shaped prose, or content with special
61
- // characters. Plugins consume the unwrapped inner body verbatim;
62
- // the IDENT is exposed as `heredocIdent` on the command for plugins
63
- // that want to act on the label. `<set>` is exempt because it does
64
- // its own multi-op heredoc parsing via `parseMarkerBody`.
49
+ // Non-`<set>` plugins accept a single `<<IDENT...IDENT` heredoc wrapper
50
+ // for opaque multi-line content; `<set>` does its own marker parsing.
65
51
  if (name !== "set") {
66
52
  const heredoc = extractSingleHeredoc(rawBody);
67
53
  if (heredoc) {
@@ -72,25 +58,15 @@ function resolveCommand(name, a, rawBody) {
72
58
  const trimmed = rawBody.trim();
73
59
 
74
60
  if (name === "set") {
75
- // `search`/`replace` as attributes is no longer in the grammar;
76
- // strip them so they can't sneak past via the attribute spread.
77
61
  const { search: _s, replace: _r, ...rest } = a;
78
62
  a = rest;
79
63
 
80
- // Self-close / no-body: visibility/metadata op.
81
64
  if (!trimmed) return { name, ...a, body: a.body || "" };
82
65
 
83
- // Edit syntax (SPEC.md "Edit Syntax"): walks the body for
84
- // `<<:::IDENT...:::IDENT` markers and returns an ordered op
85
- // list. No markers → plain body, treated as full-replace.
86
- // Non-keyword IDENTs (path-flavored, identifier-flavored)
87
- // route to REPLACE so the model gets a working write whatever
88
- // IDENT it picks.
89
66
  const { ops, error } = parseMarkerBody(rawBody);
90
67
  if (error) return { name, ...a, error };
91
68
  if (ops) return { name, ...a, operations: ops };
92
69
 
93
- // No markers — plain body, full-replace.
94
70
  return { name, ...a, body: trimmed };
95
71
  }
96
72
 
@@ -100,9 +76,7 @@ function resolveCommand(name, a, rawBody) {
100
76
  return { name, ...a, body, status };
101
77
  }
102
78
 
103
- // Body shorthand fallback: when the attribute is unset (undefined),
104
- // fall back to the trimmed body. Empty-string attrs are preserved
105
- // as-is — handlers validate. `||` would conflate the two cases.
79
+ // Distinguish unset attr (falls back to body) from empty-string attr.
106
80
  const fromBody = trimmed === "" ? null : trimmed;
107
81
 
108
82
  if (name === "get" || name === "rm") {
@@ -137,43 +111,10 @@ const NAME_CHAR = /[a-zA-Z0-9_]/;
137
111
  const ATTR_KEY_CHAR = /[a-zA-Z0-9_:-]/;
138
112
  const WS = /\s/;
139
113
 
140
- // Tokenizer for rummy's closed set of tool tags. Body opacity for closed
141
- // bodies; tail recovery for unclosed bodies.
142
- //
143
- // Design contract:
144
- // - Tool tags (<get>, <set>, <sh>, ...) are the only syntactic special tags.
145
- // Any other "<...>" sequence in OUTER text is treated as literal text.
146
- // - Inside a tool tag's body, content is OPAQUE: only the matching
147
- // `</tagname>` close (depth-counted for same-name nesting) ends the
148
- // body. Mismatched closes of OTHER tag names — `</env>`, `</mv>`,
149
- // `</foo>` inside a `<set>` body — are body content, not structural
150
- // signals.
151
- // - Backtick spans (`...`) and triple-backtick fences (```...```)
152
- // suppress tag recognition AT THE OUTER LEVEL ONLY (between tool
153
- // calls). Documentation prose with backticked tag examples doesn't
154
- // get parsed as commands. Inside tool bodies backticks are content;
155
- // bodies that need opacity for tag-like content use the edit-syntax
156
- // marker family (see SPEC.md "Edit Syntax"), which has no
157
- // false-positive failure modes (unlike inside-body backtick
158
- // tracking, which would suppress closing tags on bodies with stray
159
- // unbalanced backticks).
160
- // - Edit-syntax marker opacity (set only): `<<:::IDENT...:::IDENT`
161
- // spans inside a `<set>` body are skipped during tag detection so
162
- // content with `</set>` literals or marker-shaped text stays as
163
- // body. Multiple markers per body supported; see marker.js.
164
- // - Same-name nesting (`<set>...<set/>...</set>`) is depth-counted so
165
- // nested examples don't prematurely close the outer. Same-name
166
- // nesting also disables tail recovery — the model's intent is clearly
167
- // opaque body content.
168
- // - Unclosed openers (no matching close, no same-name nesting) try
169
- // tail recovery: scan the captured body for the leftmost position
170
- // whose suffix tokenizes cleanly into ≥1 well-formed tool calls
171
- // with zero leftover text. If found, end the unclosed body there
172
- // and let the trailing tags parse as proper siblings. The warning
173
- // surfaces "Unclosed <name> — recovered N trailing tool call(s)"
174
- // so the model can see what happened. If recovery finds nothing,
175
- // capture body to EOF and emit "Unclosed <name> — content captured
176
- // anyway".
114
+ // Tokenizer for rummy's closed set of tool tags. See SPEC.md "XML Parser"
115
+ // for the full design contract; in short: opaque tool bodies, outer-text
116
+ // backtick suppression, edit-marker opacity inside `<set>`, depth-counted
117
+ // same-name nesting, tail recovery for unclosed openers.
177
118
  export default class XmlParser {
178
119
  static MAX_COMMANDS = Number(process.env.RUMMY_MAX_COMMANDS);
179
120
 
@@ -198,8 +139,7 @@ export default class XmlParser {
198
139
  break;
199
140
  }
200
141
 
201
- // Triple-backtick fence toggles take precedence over single backtick
202
- // because ``` overlaps `.
142
+ // Triple takes precedence over single because ``` overlaps `.
203
143
  if (s[i] === "`" && s[i + 1] === "`" && s[i + 2] === "`") {
204
144
  inTripleFence = !inTripleFence;
205
145
  text.push("```");
@@ -227,9 +167,15 @@ export default class XmlParser {
227
167
  }
228
168
 
229
169
  const { name, attrs, selfClose, end: openerEnd } = opener;
170
+ const openerStart = i;
230
171
 
231
172
  if (selfClose) {
232
- commands.push(resolveCommand(name, attrs, ""));
173
+ const source = s.slice(openerStart, openerEnd);
174
+ commands.push({
175
+ ...resolveCommand(name, attrs, ""),
176
+ source,
177
+ inner: "",
178
+ });
233
179
  i = openerEnd;
234
180
  continue;
235
181
  }
@@ -245,10 +191,14 @@ export default class XmlParser {
245
191
  warnings.push(`Unclosed <${name}> tag — content captured anyway`);
246
192
  }
247
193
  }
248
- commands.push(resolveCommand(name, attrs, body));
194
+ const source = s.slice(openerStart, result.afterClose);
195
+ const inner = body.replace(/^\n+/, "").replace(/\n+$/, "");
196
+ commands.push({
197
+ ...resolveCommand(name, attrs, body),
198
+ source,
199
+ inner,
200
+ });
249
201
  i = result.afterClose;
250
-
251
- // Body terminated; reset outer-text fence tracking.
252
202
  inSingleBacktick = false;
253
203
  inTripleFence = false;
254
204
  }
@@ -266,8 +216,7 @@ export default class XmlParser {
266
216
  };
267
217
  }
268
218
 
269
- // Returns { name, attrs, selfClose, end } if `s[pos..]` opens a known tool,
270
- // else null. `end` is the index after the closing `>` (or `/>`).
219
+ // Returns { name, attrs, selfClose, end } or null. `end` is post-`>`/`/>`.
271
220
  static #matchOpener(s, pos) {
272
221
  if (s[pos] !== "<") return null;
273
222
  let i = pos + 1;
@@ -277,7 +226,6 @@ export default class XmlParser {
277
226
  const name = s.slice(nameStart, i).toLowerCase();
278
227
  if (!ALL_TOOLS.has(name)) return null;
279
228
 
280
- // Char after the name must end the name token cleanly.
281
229
  if (i < s.length && !WS.test(s[i]) && s[i] !== "/" && s[i] !== ">") {
282
230
  return null;
283
231
  }
@@ -322,7 +270,6 @@ export default class XmlParser {
322
270
  i++;
323
271
  }
324
272
 
325
- // Hit EOF without closing — not a parseable opener.
326
273
  return null;
327
274
  }
328
275
 
@@ -367,33 +314,12 @@ export default class XmlParser {
367
314
  return attrs;
368
315
  }
369
316
 
370
- // Scans body content from `fromPos` until the matching `</name>` closer,
371
- // counting depth so same-name nested examples don't prematurely close.
372
- // Returns { bodyEnd, afterClose, unclosed }.
373
- //
374
- // Strict body opacity: only `</name>` (matching the open) and same-name
375
- // nested opens affect parsing. Mismatched closes of OTHER tag names are
376
- // body content, period.
377
- //
378
- // Backtick fences (`…`, ```…```) inside the body suppress all tag
379
- // recognition — a markdown table cell containing `<set>` examples
380
- // stays as content, not interpreted as a nested tag. This matches
381
- // the outer-level convention and is the load-bearing reason a model
382
- // can write documentation about rummy commands inside a deliverable
383
- // body without breaking parsing.
384
- //
385
- // If the matching close never arrives, emit "Unclosed" so the model
386
- // sees a clear failure and corrects on the next turn.
317
+ // Returns { bodyEnd, afterClose, unclosed }. Same-name nesting is depth-counted.
387
318
  static #findBodyEnd(s, name, fromPos) {
388
319
  let depth = 1;
389
320
  let sameNameNested = false;
390
321
  let i = fromPos;
391
322
  while (i < s.length) {
392
- // Edit-syntax marker opacity: marker spans (bare `<<IDENT` or
393
- // packet-shaped `<<:::IDENT`) are opaque — tag detection
394
- // skips them so inner `</set>` and other tag-shaped content
395
- // stays as body. Multiple markers per `<set>` body are
396
- // supported; check on every iteration.
397
323
  if (
398
324
  name === "set" &&
399
325
  (s.startsWith("<<:::", i) ||
@@ -436,17 +362,8 @@ export default class XmlParser {
436
362
  }
437
363
  i++;
438
364
  }
439
- // Unclosed: try tail recovery, but only if the body never
440
- // nested a same-name opener. Same-name nesting is the model
441
- // deliberately using opaque body for examples (`<set>` writing
442
- // docs about `<set>`); we trust the body content as authored.
443
- // No nesting means a plain botched `</set>` — recovery is safe.
444
- // If the body's tail is a clean sequence of one or more
445
- // well-formed tool calls (zero leftover text), end the body
446
- // at the start of that tail and let the outer tokenizer parse
447
- // those calls as proper siblings. Closes the silent-swallow
448
- // gap when a model botches `</set>` after SEARCH/REPLACE and
449
- // emits trailing `<sh>` / `<update>`.
365
+ // Unclosed tail recovery, unless same-name nesting (treated as
366
+ // authored opaque body content with intentional tag examples).
450
367
  if (sameNameNested) {
451
368
  return { bodyEnd: s.length, afterClose: s.length, unclosed: true };
452
369
  }
@@ -462,11 +379,7 @@ export default class XmlParser {
462
379
  return { bodyEnd: s.length, afterClose: s.length, unclosed: true };
463
380
  }
464
381
 
465
- // Scan body content for the leftmost position whose suffix tokenizes
466
- // cleanly into ≥1 commands with no leftover non-whitespace text.
467
- // Returns { tailStart, commandCount } or null. Only considers opener
468
- // positions; treats the suffix as outer-level so backtick fences and
469
- // tag recognition match the parent tokenizer's behavior.
382
+ // Find leftmost suffix that tokenizes cleanly to ≥1 commands; null if none.
470
383
  static #findTailRecovery(s, fromPos) {
471
384
  let best = null;
472
385
  let i = fromPos;
@@ -1,27 +1,13 @@
1
- // Outcomes that record a failure but don't strike — findings the model
2
- // adapts to, not contract violations. `not_found` (model acted on an
3
- // entry that doesn't exist) and `conflict` (SEARCH text didn't match
4
- // current body) are recoverable: read the new state, try again.
5
- // `unparsed` (free text outside any tool tag — comments, "thinking
6
- // out loud" between tool calls) is non-actionable but not malicious;
7
- // the empty-turn failure mode is already caught by update plugin's
8
- // 422 "Missing update", so striking unparsed too is duplicative.
9
- // Hard outcomes (validation, permission, exit:N) DO strike. Shared
10
- // between error.js's verdict accumulator (recordedFailed gate) and
11
- // Entries' auto-failure hook (passes soft=true so error.log.emit
12
- // skips turn errors increment when the outcome is soft).
1
+ // Recoverable outcomes recorded but no strike.
13
2
  export const SOFT_FAILURE_OUTCOMES = new Set([
14
3
  "not_found",
15
4
  "conflict",
16
5
  "unparsed",
17
6
  ]);
18
7
 
19
- // Writer tier excluded from scheme.writable_by; see SPEC writer_tiers.
8
+ // SPEC writer_tiers.
20
9
  export class PermissionError extends Error {
21
10
  constructor(scheme, writer, allowed) {
22
- // Paths without `://` have a null scheme. Report null verbatim
23
- // rather than substituting a plausible-sounding "file" — there is
24
- // no scheme called "file" and the error must reflect actual state.
25
11
  const schemeLabel = scheme === null ? "(none)" : scheme;
26
12
  super(
27
13
  `403: writer "${writer}" not permitted for scheme "${schemeLabel}" (allowed: ${allowed.join(", ")})`,
@@ -33,12 +19,7 @@ export class PermissionError extends Error {
33
19
  }
34
20
  }
35
21
 
36
- // Body length exceeded the entries.body CHECK constraint (RUMMY_ENTRY_SIZE_MAX
37
- // at create-time). Surfaced as a 413 strike. The cap value lives only in the
38
- // schema — JS does not duplicate it, because the database persists across
39
- // rummy invocations and the env var that built the schema may differ from
40
- // the env var seen by the running instance. Reporting body size is enough
41
- // for the model to adapt; operators can read the cap from the schema.
22
+ // 413 strike: body exceeded entries.body CHECK (RUMMY_ENTRY_SIZE_MAX).
42
23
  export class EntryOverflowError extends Error {
43
24
  constructor(path, size) {
44
25
  super(
@@ -2,16 +2,9 @@ import { SUMMARY_MAX_CHARS } from "../plugins/helpers.js";
2
2
  import ContextAssembler from "./ContextAssembler.js";
3
3
  import { countLines, countTokens } from "./tokens.js";
4
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
-
14
5
  // Rebuild turn_context from v_model_context and assemble messages.
6
+ // File-scheme is exempt from SUMMARY_MAX_CHARS (its summary is a structural
7
+ // symbol map, not writer-bounded prose).
15
8
  export default async function materializeContext({
16
9
  db,
17
10
  hooks,
@@ -27,12 +20,11 @@ export default async function materializeContext({
27
20
  }) {
28
21
  await db.clear_turn_context.run({ run_id: runId, turn });
29
22
  const viewRows = await db.get_model_context.all({ run_id: runId });
30
- // Per-entry token accounting; merged back after the turn_context roundtrip.
31
23
  const tokenAccounting = new Map();
32
24
  for (const row of viewRows) {
33
25
  const scheme = row.scheme ? row.scheme : "file";
34
26
  const attrs = row.attributes ? JSON.parse(row.attributes) : null;
35
- // Dispatch log entries to their action plugin's view via path segment.
27
+ // Log entries dispatch to their action plugin's view via path segment.
36
28
  let projectionKey = scheme;
37
29
  if (scheme === "log") {
38
30
  const m = row.path.match(/^log:\/\/turn_\d+\/([^/]+)\//);
@@ -2,7 +2,6 @@ import HookRegistry from "./HookRegistry.js";
2
2
  import RpcRegistry from "./RpcRegistry.js";
3
3
  import ToolRegistry from "./ToolRegistry.js";
4
4
 
5
- // Strictly-typed hook surface; replaces the previous Proxy magic.
6
5
  export default function createHooks(debug = false) {
7
6
  const registry = new HookRegistry(debug);
8
7
  const tools = new ToolRegistry();
@@ -20,13 +19,10 @@ export default function createHooks(debug = false) {
20
19
  });
21
20
 
22
21
  return {
23
- // Core Turn Pipeline
24
22
  onTurn: registry.onTurn.bind(registry),
25
23
  processTurn: registry.processTurn.bind(registry),
26
24
 
27
- // Explicit Hook Schema
28
25
  boot: {
29
- // Post-init, pre-accept-connections; one-shot post-init actions subscribe here.
30
26
  completed: createEvent("boot.completed"),
31
27
  },
32
28
  project: {
@@ -48,13 +44,6 @@ export default function createHooks(debug = false) {
48
44
  step: {
49
45
  completed: createEvent("run.step.completed"),
50
46
  },
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
47
  wake: createEvent("run.wake"),
59
48
  },
60
49
  loop: {
@@ -63,28 +52,12 @@ export default function createHooks(debug = false) {
63
52
  },
64
53
  turn: {
65
54
  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
55
  beforeDispatch: createFilter("turn.beforeDispatch"),
73
56
  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
57
  dispatched: createEvent("turn.dispatched"),
78
58
  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
59
  verdict: createFilter("turn.verdict"),
86
60
  },
87
- // SPEC #resolution covers the proposal hook chain.
88
61
  proposal: {
89
62
  prepare: createEvent("proposal.prepare"),
90
63
  pending: createEvent("proposal.pending"),
@@ -115,9 +88,7 @@ export default function createHooks(debug = false) {
115
88
  },
116
89
  messages: createFilter("llm.messages"),
117
90
  response: createFilter("llm.response"),
118
- // Plugins contribute reasoning text into reasoning_content; fires between parse and turn.response.
119
91
  reasoning: createFilter("llm.reasoning"),
120
- // Provider entries: { name, matches, completion, getContextSize }.
121
92
  providers: [],
122
93
  },
123
94
  file: {},
@@ -1,9 +1,7 @@
1
1
  import HeuristicMatcher, { generatePatch } from "./matcher.js";
2
2
  import { hedmatch, hedsearch } from "./patterns.js";
3
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.
4
+ // SPEC #hedberg. Edit-shape parsing lives in marker.js.
7
5
  export default class Hedberg {
8
6
  #core;
9
7
 
@@ -18,17 +16,9 @@ export default class Hedberg {
18
16
  };
19
17
  }
20
18
 
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.
19
+ // Literal substitution first, heuristic fuzzy fallback. `sed=true` strips
20
+ // regex-meta backslashes for muscle-memory escape friendliness; we never
21
+ // actually compile a regex.
32
22
  static replace(body, search, replacement, { sed = false } = {}) {
33
23
  let patch = null;
34
24
  let warning = null;
@@ -1,49 +1,14 @@
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).
1
+ // Edit-syntax marker parser for `<set>` bodies. Grammar in SPEC.md "Edit Syntax".
2
+ // Returns { ops, error } — `ops: null` on either no-markers or parse failure.
36
3
 
37
4
  const KEYWORD_RE =
38
5
  /^(NEW|PREPEND|APPEND|REPLACE|DELETE|SEARCH)([A-Za-z0-9_]*)$/;
39
6
 
40
- // Opener: `<<IDENT` preceded by start-of-input, whitespace, or `>`.
41
7
  const OPENER_RE = /(?<=^|[\s>])<<([A-Z][A-Za-z0-9_]*)/;
42
8
 
43
9
  function operationFromIdent(ident) {
44
10
  const m = ident.match(KEYWORD_RE);
45
11
  if (m) return m[1].toLowerCase();
46
- // Non-keyword IDENT — treat as REPLACE.
47
12
  return "replace";
48
13
  }
49
14
 
@@ -60,10 +25,7 @@ function findOpener(body, startIdx) {
60
25
 
61
26
  function findCloser(body, startIdx, ident) {
62
27
  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`).
28
+ // Trailing `<` lets `SEARCH<<REPLACE` adjoin without intermediate whitespace.
67
29
  const re = new RegExp(`(?<=^|\\s)${escIdent}(?=[\\s<>]|$)`);
68
30
  const slice = body.slice(startIdx);
69
31
  const match = slice.match(re);
@@ -81,21 +43,7 @@ function trimMarkerNewlines(content) {
81
43
  return result;
82
44
  }
83
45
 
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.
46
+ // Returns { ident, content } if `body` is exactly one heredoc; null otherwise.
99
47
  export function extractSingleHeredoc(body) {
100
48
  if (!body) return null;
101
49
  const trimmed = body.trim();
@@ -114,7 +62,6 @@ export function extractSingleHeredoc(body) {
114
62
  }
115
63
 
116
64
  export function parseMarkerBody(body) {
117
- // Cheap rejection — most `<set>` bodies don't contain markers.
118
65
  if (!/<<[A-Z]/.test(body)) return { ops: null, error: null };
119
66
 
120
67
  const raw = [];
@@ -125,7 +72,17 @@ export function parseMarkerBody(body) {
125
72
  const op = operationFromIdent(opener.ident);
126
73
  const closer = findCloser(body, opener.openerEnd, opener.ident);
127
74
  if (!closer) {
128
- return { ops: null, error: `unclosed <<${opener.ident}` };
75
+ // Tail-close recovery: last opener with no closer and no further
76
+ // opener absorbs body to EOF. SEARCH stays strict (needs REPLACE).
77
+ if (op === "search") {
78
+ return { ops: null, error: `unclosed <<${opener.ident}` };
79
+ }
80
+ const tail = body.slice(opener.openerEnd);
81
+ if (findOpener(tail, 0)) {
82
+ return { ops: null, error: `unclosed <<${opener.ident}` };
83
+ }
84
+ raw.push({ op, content: trimMarkerNewlines(tail) });
85
+ break;
129
86
  }
130
87
  const content = trimMarkerNewlines(
131
88
  body.slice(opener.openerEnd, closer.closerStart),
@@ -135,7 +92,6 @@ export function parseMarkerBody(body) {
135
92
  }
136
93
  if (raw.length === 0) return { ops: null, error: null };
137
94
 
138
- // Pair adjacent SEARCH+REPLACE into one search_replace op.
139
95
  const ops = [];
140
96
  for (let j = 0; j < raw.length; j++) {
141
97
  const cur = raw[j];
@@ -10,19 +10,11 @@ const LLM_DEADLINE = Number(process.env.RUMMY_LLM_DEADLINE);
10
10
  const LLM_MAX_BACKOFF = Number(process.env.RUMMY_LLM_MAX_BACKOFF);
11
11
 
12
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>`.
13
+ // Floor so a near-full prompt still leaves room for a closing `<update>`.
16
14
  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.
15
+ // 1−X headroom absorbs BPE/estimator drift and envelope overhead.
21
16
  const BUDGET_CEILING = Number(process.env.RUMMY_BUDGET_CEILING);
22
17
 
23
- // Per-category retry policies. Gateway/server are bounded short because
24
- // upstream-down won't recover by waiting; warmup/rate_limit get the full
25
- // LLM deadline because they're recoverable wait states with knowable bounds.
26
18
  const POLICIES = Object.freeze({
27
19
  gateway: { deadlineMs: 30_000, baseDelayMs: 500, maxDelayMs: 5_000 },
28
20
  warmup: {
@@ -38,7 +30,6 @@ const POLICIES = Object.freeze({
38
30
  server: { deadlineMs: 60_000, baseDelayMs: 1000, maxDelayMs: 10_000 },
39
31
  });
40
32
 
41
- // Dispatches to hooks.llm.providers; per-category transient retry; ContextExceededError surface.
42
33
  export default class LlmProvider {
43
34
  #db;
44
35
  #hooks;
@@ -67,27 +58,23 @@ export default class LlmProvider {
67
58
  ? Number.parseFloat(process.env.RUMMY_TEMPERATURE)
68
59
  : undefined);
69
60
 
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.
61
+ // max_tokens = effectiveContext promptEstimate. lastPromptTokens
62
+ // is ground truth when available (turn 1 falls back to chars/divisor).
75
63
  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
- );
64
+ const promptEstimate =
65
+ options.lastPromptTokens > 0
66
+ ? options.lastPromptTokens
67
+ : messages.reduce(
68
+ (sum, m) => sum + Math.ceil(m.content.length / TOKEN_DIVISOR),
69
+ 0,
70
+ );
80
71
  const effectiveContext = Math.floor(contextLength * BUDGET_CEILING);
81
72
  let maxTokens = Math.max(
82
73
  MAX_TOKENS_FLOOR,
83
74
  effectiveContext - promptEstimate,
84
75
  );
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.
76
+ // Per-model output cap (`RUMMY_OUTPUT_CAP_<alias>`) output ceilings
77
+ // are typically far smaller than advertised context windows.
91
78
  const outputCapEnv = process.env[`RUMMY_OUTPUT_CAP_${model}`];
92
79
  if (outputCapEnv) {
93
80
  const cap = Number.parseInt(outputCapEnv, 10);