@possumtech/rummy 2.2.1 → 2.3.2
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.
- package/package.json +14 -6
- package/service.js +18 -10
- package/src/agent/AgentLoop.js +2 -11
- package/src/agent/ContextAssembler.js +34 -3
- package/src/agent/Entries.js +16 -89
- package/src/agent/ProjectAgent.js +1 -16
- package/src/agent/TurnExecutor.js +12 -52
- package/src/agent/XmlParser.js +30 -117
- package/src/agent/errors.js +3 -22
- package/src/agent/materializeContext.js +3 -11
- package/src/hooks/Hooks.js +0 -29
- package/src/hooks/PluginContext.js +15 -0
- package/src/lib/hedberg/hedberg.js +4 -14
- package/src/lib/hedberg/marker.js +15 -59
- package/src/llm/LlmProvider.js +13 -26
- package/src/llm/errors.js +3 -11
- package/src/llm/openaiStream.js +6 -46
- package/src/plugins/ask_user/ask_user.js +12 -17
- package/src/plugins/budget/README.md +46 -8
- package/src/plugins/budget/budget.js +23 -42
- package/src/plugins/cp/cp.js +28 -18
- package/src/plugins/env/env.js +11 -7
- package/src/plugins/error/error.js +8 -37
- package/src/plugins/get/get.js +42 -24
- package/src/plugins/google/google.js +23 -3
- package/src/plugins/helpers.js +34 -50
- package/src/plugins/instructions/README.md +2 -2
- package/src/plugins/instructions/instructions-user.md +1 -1
- package/src/plugins/instructions/instructions.js +19 -6
- package/src/plugins/known/known.js +1 -8
- package/src/plugins/log/log.js +15 -1
- package/src/plugins/mv/mv.js +29 -19
- package/src/plugins/persona/persona.js +4 -4
- package/src/plugins/prompt/README.md +1 -1
- package/src/plugins/prompt/prompt.js +1 -1
- package/src/plugins/rm/rm.js +26 -15
- package/src/plugins/rm/rmDoc.md +0 -2
- package/src/plugins/set/set.js +37 -84
- package/src/plugins/set/setDoc.md +16 -16
- package/src/plugins/sh/sh.js +10 -8
- package/src/plugins/skill/skillDoc.md +1 -1
- package/src/plugins/unknown/README.md +1 -1
- package/src/plugins/unknown/unknown.js +2 -6
- package/src/plugins/update/update.js +3 -2
- package/src/plugins/update/updateDoc.md +1 -1
- package/.env.example +0 -152
- package/.xai.key +0 -1
- package/PLUGINS.md +0 -962
- package/SPEC.md +0 -1897
- package/biome/no-fallbacks.grit +0 -50
- package/gemini.key +0 -1
package/src/agent/XmlParser.js
CHANGED
|
@@ -3,17 +3,8 @@ import {
|
|
|
3
3
|
parseMarkerBody,
|
|
4
4
|
} from "../lib/hedberg/marker.js";
|
|
5
5
|
|
|
6
|
-
// Edit-marker body opacity
|
|
7
|
-
//
|
|
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
|
|
47
|
+
// Per-tool resolution: missing canonical attribute is filled from the body.
|
|
57
48
|
function resolveCommand(name, a, rawBody) {
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
|
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
|
-
//
|
|
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
|
|
440
|
-
//
|
|
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
|
-
//
|
|
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;
|
package/src/agent/errors.js
CHANGED
|
@@ -1,27 +1,13 @@
|
|
|
1
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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+\/([^/]+)\//);
|
package/src/hooks/Hooks.js
CHANGED
|
@@ -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,3 +1,14 @@
|
|
|
1
|
+
import { projectEmission, summarizeEmission } from "../plugins/helpers.js";
|
|
2
|
+
|
|
3
|
+
// Shared projection helpers for plugins (including external ones).
|
|
4
|
+
// Action log entries tab-indent body via emission; summaries cap at
|
|
5
|
+
// SUMMARY_MAX_CHARS via summarize. External plugins use these via
|
|
6
|
+
// `core.projection` to avoid drift across the action-log paradigm.
|
|
7
|
+
const PROJECTION = Object.freeze({
|
|
8
|
+
emission: projectEmission,
|
|
9
|
+
summarize: summarizeEmission,
|
|
10
|
+
});
|
|
11
|
+
|
|
1
12
|
// Plugin-only registration interface; tool verbs live on RummyContext. PLUGINS.md.
|
|
2
13
|
export default class PluginContext {
|
|
3
14
|
#name;
|
|
@@ -8,6 +19,10 @@ export default class PluginContext {
|
|
|
8
19
|
this.#hooks = hooks;
|
|
9
20
|
}
|
|
10
21
|
|
|
22
|
+
get projection() {
|
|
23
|
+
return PROJECTION;
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
get name() {
|
|
12
27
|
return this.#name;
|
|
13
28
|
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import HeuristicMatcher, { generatePatch } from "./matcher.js";
|
|
2
2
|
import { hedmatch, hedsearch } from "./patterns.js";
|
|
3
3
|
|
|
4
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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.
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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];
|
package/src/llm/LlmProvider.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
71
|
-
//
|
|
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 =
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
86
|
-
//
|
|
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);
|