@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.
- package/.env.example +40 -15
- package/.xai.key +1 -0
- package/PLUGINS.md +169 -53
- package/README.md +38 -32
- package/SPEC.md +366 -179
- package/bin/digest.js +1097 -0
- package/biome/no-fallbacks.grit +2 -2
- package/gemini.key +1 -0
- package/lang/en.json +10 -1
- package/migrations/001_initial_schema.sql +9 -2
- package/package.json +19 -8
- package/service.js +1 -0
- package/src/agent/AgentLoop.js +76 -26
- package/src/agent/ContextAssembler.js +2 -0
- package/src/agent/Entries.js +238 -60
- package/src/agent/ProjectAgent.js +44 -0
- package/src/agent/TurnExecutor.js +99 -30
- package/src/agent/XmlParser.js +206 -111
- package/src/agent/errors.js +35 -0
- package/src/agent/known_queries.sql +1 -1
- package/src/agent/known_store.sql +3 -42
- package/src/agent/materializeContext.js +30 -1
- package/src/agent/runs.sql +8 -18
- package/src/agent/tokens.js +0 -1
- package/src/agent/turns.sql +1 -0
- package/src/hooks/Hooks.js +26 -0
- package/src/hooks/RummyContext.js +12 -1
- package/src/lib/hedberg/README.md +60 -0
- package/src/lib/hedberg/hedberg.js +60 -0
- package/src/lib/hedberg/marker.js +158 -0
- package/src/{plugins → lib}/hedberg/matcher.js +1 -2
- package/src/llm/LlmProvider.js +41 -3
- package/src/llm/openaiStream.js +17 -0
- package/src/plugins/ask_user/ask_user.js +12 -2
- package/src/plugins/ask_user/ask_userDoc.md +1 -5
- package/src/plugins/budget/README.md +29 -24
- package/src/plugins/budget/budget.js +166 -110
- package/src/plugins/cli/README.md +3 -4
- package/src/plugins/cli/cli.js +31 -5
- package/src/plugins/cloudflare/cloudflare.js +136 -0
- package/src/plugins/cp/cp.js +41 -4
- package/src/plugins/cp/cpDoc.md +5 -6
- package/src/plugins/engine/engine.sql +1 -1
- package/src/plugins/env/README.md +5 -4
- package/src/plugins/env/env.js +7 -4
- package/src/plugins/env/envDoc.md +7 -8
- package/src/plugins/error/error.js +56 -15
- package/src/plugins/file/README.md +12 -3
- package/src/plugins/file/file.js +2 -2
- package/src/plugins/get/get.js +59 -36
- package/src/plugins/get/getDoc.md +10 -34
- package/src/plugins/google/google.js +115 -0
- package/src/plugins/hedberg/hedberg.js +13 -56
- package/src/plugins/helpers.js +66 -12
- package/src/plugins/index.js +1 -2
- package/src/plugins/instructions/README.md +44 -47
- package/src/plugins/instructions/instructions-system.md +44 -0
- package/src/plugins/instructions/instructions-user.md +53 -0
- package/src/plugins/instructions/instructions.js +58 -189
- package/src/plugins/known/README.md +6 -7
- package/src/plugins/known/known.js +24 -30
- package/src/plugins/log/log.js +41 -32
- package/src/plugins/mv/mv.js +40 -1
- package/src/plugins/mv/mvDoc.md +1 -8
- package/src/plugins/ollama/ollama.js +4 -3
- package/src/plugins/openai/openai.js +4 -3
- package/src/plugins/openrouter/openrouter.js +14 -4
- package/src/plugins/persona/README.md +11 -13
- package/src/plugins/persona/default.md +29 -0
- package/src/plugins/persona/persona.js +10 -66
- package/src/plugins/policy/policy.js +23 -22
- package/src/plugins/prompt/README.md +37 -27
- package/src/plugins/prompt/prompt.js +13 -19
- package/src/plugins/rm/rm.js +18 -0
- package/src/plugins/rm/rmDoc.md +5 -6
- package/src/plugins/rpc/rpc.js +3 -3
- package/src/plugins/set/set.js +205 -323
- package/src/plugins/set/setDoc.md +47 -17
- package/src/plugins/sh/README.md +6 -5
- package/src/plugins/sh/sh.js +8 -5
- package/src/plugins/sh/shDoc.md +7 -8
- package/src/plugins/skill/README.md +37 -14
- package/src/plugins/skill/skill.js +200 -101
- package/src/plugins/skill/skillDoc.js +3 -0
- package/src/plugins/skill/skillDoc.md +9 -0
- package/src/plugins/stream/README.md +7 -6
- package/src/plugins/stream/finalize.js +100 -0
- package/src/plugins/stream/stream.js +13 -45
- package/src/plugins/telemetry/telemetry.js +27 -4
- package/src/plugins/think/think.js +2 -3
- package/src/plugins/think/thinkDoc.md +2 -4
- package/src/plugins/unknown/README.md +1 -1
- package/src/plugins/unknown/unknown.js +17 -19
- package/src/plugins/update/update.js +4 -51
- package/src/plugins/update/updateDoc.md +21 -6
- package/src/plugins/xai/xai.js +68 -102
- package/src/plugins/yolo/yolo.js +102 -75
- package/src/sql/functions/hedmatch.js +1 -1
- package/src/sql/functions/hedreplace.js +1 -1
- package/src/sql/functions/hedsearch.js +1 -1
- package/src/sql/functions/slugify.js +16 -2
- package/BENCH_ENVIRONMENT.md +0 -230
- package/CLIENT_INTERFACE.md +0 -396
- package/last_run.txt +0 -5617
- package/scriptify/ask_run.js +0 -77
- package/scriptify/cache_probe.js +0 -66
- package/scriptify/cache_probe_grok.js +0 -74
- package/src/agent/budget.js +0 -33
- package/src/agent/config.js +0 -38
- package/src/plugins/hedberg/README.md +0 -71
- package/src/plugins/hedberg/docs.md +0 -0
- package/src/plugins/hedberg/edits.js +0 -55
- package/src/plugins/hedberg/normalize.js +0 -17
- package/src/plugins/hedberg/sed.js +0 -49
- package/src/plugins/instructions/instructions.md +0 -34
- package/src/plugins/instructions/instructions_104.md +0 -8
- package/src/plugins/instructions/instructions_105.md +0 -39
- package/src/plugins/instructions/instructions_106.md +0 -22
- package/src/plugins/instructions/instructions_107.md +0 -17
- package/src/plugins/instructions/instructions_108.md +0 -0
- package/src/plugins/known/knownDoc.js +0 -3
- package/src/plugins/known/knownDoc.md +0 -8
- package/src/plugins/unknown/unknownDoc.js +0 -3
- package/src/plugins/unknown/unknownDoc.md +0 -11
- package/turns/cli_1777462658211/turn_001.txt +0 -772
- package/turns/cli_1777462658211/turn_002.txt +0 -606
- package/turns/cli_1777462658211/turn_003.txt +0 -667
- package/turns/cli_1777462658211/turn_004.txt +0 -297
- package/turns/cli_1777462658211/turn_005.txt +0 -301
- package/turns/cli_1777462658211/turn_006.txt +0 -262
- package/turns/cli_1777465095132/turn_001.txt +0 -715
- package/turns/cli_1777465095132/turn_002.txt +0 -236
- package/turns/cli_1777465095132/turn_003.txt +0 -287
- package/turns/cli_1777465095132/turn_004.txt +0 -694
- package/turns/cli_1777465095132/turn_005.txt +0 -422
- package/turns/cli_1777465095132/turn_006.txt +0 -365
- package/turns/cli_1777465095132/turn_007.txt +0 -885
- package/turns/cli_1777465095132/turn_008.txt +0 -1277
- package/turns/cli_1777465095132/turn_009.txt +0 -736
- /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
|
|
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
|
|
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
|
-
--
|
|
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
|
|
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
|
);
|
package/src/agent/runs.sql
CHANGED
|
@@ -110,24 +110,14 @@ SELECT
|
|
|
110
110
|
FROM run_views
|
|
111
111
|
WHERE run_id = :parent_run_id;
|
|
112
112
|
|
|
113
|
-
-- PREP:
|
|
114
|
-
--
|
|
115
|
-
--
|
|
116
|
-
--
|
|
117
|
-
--
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
package/src/agent/tokens.js
CHANGED
|
@@ -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;
|
package/src/agent/turns.sql
CHANGED
package/src/hooks/Hooks.js
CHANGED
|
@@ -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?.
|
|
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
|
|
package/src/llm/LlmProvider.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|
package/src/llm/openaiStream.js
CHANGED
|
@@ -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
|
}
|