@possumtech/rummy 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +12 -7
- package/BENCH_ENVIRONMENT.md +230 -0
- package/CLIENT_INTERFACE.md +396 -0
- package/PLUGINS.md +93 -1
- package/SPEC.md +305 -28
- package/bin/postinstall.js +2 -2
- package/bin/rummy.js +2 -2
- package/last_run.txt +5617 -0
- package/migrations/001_initial_schema.sql +2 -1
- package/package.json +6 -2
- package/scriptify/cache_probe.js +66 -0
- package/scriptify/cache_probe_grok.js +74 -0
- package/service.js +22 -11
- package/src/agent/AgentLoop.js +33 -139
- package/src/agent/ContextAssembler.js +2 -9
- package/src/agent/Entries.js +36 -101
- package/src/agent/ProjectAgent.js +2 -9
- package/src/agent/TurnExecutor.js +45 -83
- package/src/agent/XmlParser.js +247 -273
- package/src/agent/budget.js +5 -28
- package/src/agent/config.js +38 -0
- package/src/agent/errors.js +7 -13
- package/src/agent/httpStatus.js +1 -19
- package/src/agent/known_store.sql +7 -2
- package/src/agent/materializeContext.js +12 -17
- package/src/agent/pathEncode.js +5 -0
- package/src/agent/rummyHome.js +9 -0
- package/src/agent/runs.sql +18 -0
- package/src/agent/tokens.js +2 -8
- package/src/hooks/HookRegistry.js +1 -16
- package/src/hooks/Hooks.js +8 -33
- package/src/hooks/PluginContext.js +3 -21
- package/src/hooks/RpcRegistry.js +1 -4
- package/src/hooks/RummyContext.js +2 -16
- package/src/hooks/ToolRegistry.js +5 -15
- package/src/llm/LlmProvider.js +28 -23
- package/src/llm/errors.js +41 -4
- package/src/llm/openaiStream.js +125 -0
- package/src/llm/retry.js +61 -15
- package/src/plugins/budget/budget.js +14 -81
- package/src/plugins/cli/README.md +87 -0
- package/src/plugins/cli/bin.js +61 -0
- package/src/plugins/cli/cli.js +120 -0
- package/src/plugins/env/README.md +2 -1
- package/src/plugins/env/env.js +4 -6
- package/src/plugins/env/envDoc.md +2 -2
- package/src/plugins/error/error.js +23 -23
- package/src/plugins/file/file.js +2 -22
- package/src/plugins/get/get.js +12 -34
- package/src/plugins/get/getDoc.md +5 -3
- package/src/plugins/hedberg/edits.js +1 -11
- package/src/plugins/hedberg/hedberg.js +3 -26
- package/src/plugins/hedberg/normalize.js +1 -5
- package/src/plugins/hedberg/patterns.js +4 -15
- package/src/plugins/hedberg/sed.js +1 -7
- package/src/plugins/helpers.js +28 -20
- package/src/plugins/index.js +25 -41
- package/src/plugins/instructions/README.md +18 -0
- package/src/plugins/instructions/instructions.js +13 -76
- package/src/plugins/instructions/instructions.md +19 -18
- package/src/plugins/instructions/instructions_104.md +5 -4
- package/src/plugins/instructions/instructions_105.md +16 -15
- package/src/plugins/instructions/instructions_106.md +15 -14
- package/src/plugins/instructions/instructions_107.md +13 -6
- package/src/plugins/known/README.md +26 -6
- package/src/plugins/known/known.js +36 -34
- package/src/plugins/log/README.md +2 -2
- package/src/plugins/log/log.js +6 -33
- package/src/plugins/ollama/ollama.js +50 -66
- package/src/plugins/openai/openai.js +26 -44
- package/src/plugins/openrouter/openrouter.js +28 -52
- package/src/plugins/policy/README.md +8 -2
- package/src/plugins/policy/policy.js +8 -21
- package/src/plugins/prompt/README.md +22 -0
- package/src/plugins/prompt/prompt.js +8 -16
- package/src/plugins/rm/rm.js +5 -2
- package/src/plugins/rm/rmDoc.md +4 -4
- package/src/plugins/rpc/README.md +2 -1
- package/src/plugins/rpc/rpc.js +51 -47
- package/src/plugins/set/README.md +5 -1
- package/src/plugins/set/set.js +23 -33
- package/src/plugins/set/setDoc.md +1 -1
- package/src/plugins/sh/README.md +2 -1
- package/src/plugins/sh/sh.js +5 -11
- package/src/plugins/sh/shDoc.md +2 -2
- package/src/plugins/stream/README.md +6 -5
- package/src/plugins/stream/stream.js +6 -35
- package/src/plugins/telemetry/telemetry.js +26 -19
- package/src/plugins/think/think.js +4 -7
- package/src/plugins/unknown/unknown.js +8 -13
- package/src/plugins/update/update.js +36 -35
- package/src/plugins/update/updateDoc.md +3 -3
- package/src/plugins/xai/xai.js +30 -20
- package/src/plugins/yolo/yolo.js +8 -41
- package/src/server/ClientConnection.js +17 -47
- package/src/server/SocketServer.js +14 -14
- package/src/server/protocol.js +1 -10
- package/src/sql/functions/slugify.js +5 -7
- package/src/sql/v_model_context.sql +4 -11
- package/turns/cli_1777462658211/turn_001.txt +772 -0
- package/turns/cli_1777462658211/turn_002.txt +606 -0
- package/turns/cli_1777462658211/turn_003.txt +667 -0
- package/turns/cli_1777462658211/turn_004.txt +297 -0
- package/turns/cli_1777462658211/turn_005.txt +301 -0
- package/turns/cli_1777462658211/turn_006.txt +262 -0
- package/turns/cli_1777465095132/turn_001.txt +715 -0
- package/turns/cli_1777465095132/turn_002.txt +236 -0
- package/turns/cli_1777465095132/turn_003.txt +287 -0
- package/turns/cli_1777465095132/turn_004.txt +694 -0
- package/turns/cli_1777465095132/turn_005.txt +422 -0
- package/turns/cli_1777465095132/turn_006.txt +365 -0
- package/turns/cli_1777465095132/turn_007.txt +885 -0
- package/turns/cli_1777465095132/turn_008.txt +1277 -0
- package/turns/cli_1777465095132/turn_009.txt +736 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import config from "../../agent/config.js";
|
|
2
|
+
import ProjectAgent from "../../agent/ProjectAgent.js";
|
|
3
|
+
|
|
4
|
+
const TERMINAL_STATUSES = new Set([200, 204, 413, 422, 499, 500]);
|
|
5
|
+
|
|
6
|
+
// Inert unless RUMMY_PROMPT is set; see plugin README.
|
|
7
|
+
export default class Cli {
|
|
8
|
+
#core;
|
|
9
|
+
|
|
10
|
+
constructor(core) {
|
|
11
|
+
this.#core = core;
|
|
12
|
+
core.on("boot.completed", this.#onBoot.bind(this));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async #onBoot({ db, hooks }) {
|
|
16
|
+
const prompt = process.env.RUMMY_PROMPT;
|
|
17
|
+
if (!prompt) return;
|
|
18
|
+
|
|
19
|
+
const model = process.env.RUMMY_MODEL;
|
|
20
|
+
if (!model) {
|
|
21
|
+
console.error("rummy-cli: RUMMY_MODEL is required");
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rawMode = process.env.RUMMY_MODE;
|
|
26
|
+
const mode = rawMode == null ? "act" : rawMode;
|
|
27
|
+
if (mode !== "ask" && mode !== "act") {
|
|
28
|
+
console.error(
|
|
29
|
+
`rummy-cli: RUMMY_MODE must be "ask" or "act" (got ${JSON.stringify(rawMode)})`,
|
|
30
|
+
);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// In-process CLI has no socket client to resolve proposals; default
|
|
35
|
+
// YOLO so any proposal-emitting tool auto-accepts. Operator can
|
|
36
|
+
// pass --RUMMY_YOLO=0 to opt out.
|
|
37
|
+
if (process.env.RUMMY_YOLO == null) process.env.RUMMY_YOLO = "1";
|
|
38
|
+
|
|
39
|
+
const projectRoot = process.cwd();
|
|
40
|
+
const alias = `cli_${Date.now()}`;
|
|
41
|
+
|
|
42
|
+
const projectAgent = new ProjectAgent(db, hooks);
|
|
43
|
+
const { projectId } = await projectAgent.init(alias, projectRoot);
|
|
44
|
+
|
|
45
|
+
// Watchdog; overridable via --RUMMY_RUN_TIMEOUT=<ms>.
|
|
46
|
+
const timeoutMs = config.RUN_TIMEOUT;
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
console.error(`rummy-cli: timed out after ${timeoutMs}ms`);
|
|
49
|
+
process.exit(124);
|
|
50
|
+
}, timeoutMs);
|
|
51
|
+
timer.unref();
|
|
52
|
+
|
|
53
|
+
// stderr progress: log update entries as they land.
|
|
54
|
+
hooks.entry.created.on((entry) => {
|
|
55
|
+
if (entry?.scheme !== "update") return;
|
|
56
|
+
const turnMatch = entry.path?.match(/^log:\/\/turn_(\d+)\//);
|
|
57
|
+
if (!turnMatch) return;
|
|
58
|
+
const status = entry.attributes?.status ?? 102;
|
|
59
|
+
console.error(`[rummy-cli] turn ${turnMatch[1]} status=${status}`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Capture enriched terminal payload (status, cost, tokens, model)
|
|
63
|
+
// from ask.completed / act.completed. Only one fires for our run.
|
|
64
|
+
let runSummary = null;
|
|
65
|
+
const captureSummary = (payload) => {
|
|
66
|
+
if (payload.run !== alias) return;
|
|
67
|
+
runSummary = payload;
|
|
68
|
+
};
|
|
69
|
+
hooks.ask.completed.on(captureSummary);
|
|
70
|
+
hooks.act.completed.on(captureSummary);
|
|
71
|
+
|
|
72
|
+
const runFn =
|
|
73
|
+
mode === "act"
|
|
74
|
+
? projectAgent.act.bind(projectAgent)
|
|
75
|
+
: projectAgent.ask.bind(projectAgent);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const result = await runFn(projectId, model, prompt, alias, {});
|
|
79
|
+
const { status } = result;
|
|
80
|
+
if (TERMINAL_STATUSES.has(status)) {
|
|
81
|
+
const text = await this.#findLatestSummary(db, alias);
|
|
82
|
+
if (text) process.stdout.write(`${text}\n`);
|
|
83
|
+
}
|
|
84
|
+
if (runSummary) {
|
|
85
|
+
process.stdout.write(
|
|
86
|
+
`__RUMMY_RUN_SUMMARY__ ${JSON.stringify({
|
|
87
|
+
run: runSummary.run,
|
|
88
|
+
status: runSummary.status,
|
|
89
|
+
turn: runSummary.turn,
|
|
90
|
+
turns: runSummary.turns,
|
|
91
|
+
cost: runSummary.cost,
|
|
92
|
+
tokens: runSummary.tokens,
|
|
93
|
+
model: runSummary.model,
|
|
94
|
+
})}\n`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
98
|
+
process.exit(status === 200 ? 0 : 1);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.error(`rummy-cli: run crashed: ${err.message}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async #findLatestSummary(db, alias) {
|
|
106
|
+
const runRow = await db.get_run_by_alias.get({ alias });
|
|
107
|
+
if (!runRow) return null;
|
|
108
|
+
const entries = await db.get_known_entries.all({ run_id: runRow.id });
|
|
109
|
+
const updates = entries
|
|
110
|
+
.filter(
|
|
111
|
+
(e) =>
|
|
112
|
+
e.scheme === "log" &&
|
|
113
|
+
/^log:\/\/turn_\d+\/update\//.test(e.path) &&
|
|
114
|
+
e.state === "resolved",
|
|
115
|
+
)
|
|
116
|
+
.toSorted((a, b) => a.turn - b.turn);
|
|
117
|
+
if (updates.length === 0) return null;
|
|
118
|
+
return updates.at(-1).body;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -17,7 +17,8 @@ side effects.
|
|
|
17
17
|
The audit record (renders inside `<log>` as `<env>`).
|
|
18
18
|
- **Data channels**: `env://turn_N/{slug}_1` (stdout), `env://turn_N/{slug}_2`
|
|
19
19
|
(stderr) — scheme=`env`, category=`data`. The captured payload
|
|
20
|
-
(renders inside `<
|
|
20
|
+
(renders inside `<visible>` as `<env>` when promoted; otherwise listed
|
|
21
|
+
in `<summarized>`).
|
|
21
22
|
|
|
22
23
|
The `env` scheme exists **only** for the data channels. See
|
|
23
24
|
[scheme_category_split](#scheme_category_split).
|
package/src/plugins/env/env.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { logPathToDataBase } from "../helpers.js";
|
|
1
|
+
import { logPathToDataBase, streamSummary } from "../helpers.js";
|
|
2
2
|
import docs from "./envDoc.js";
|
|
3
3
|
|
|
4
4
|
const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
|
|
@@ -8,9 +8,7 @@ export default class Env {
|
|
|
8
8
|
|
|
9
9
|
constructor(core) {
|
|
10
10
|
this.#core = core;
|
|
11
|
-
//
|
|
12
|
-
// for the scheme/category split rationale. env differs from sh only
|
|
13
|
-
// in ask-mode policy (env is safe/read-only; sh has side effects).
|
|
11
|
+
// env vs sh: env is read-only (allowed in ask-mode); see plugin README.
|
|
14
12
|
core.registerScheme({ category: "data" });
|
|
15
13
|
core.on("handler", this.handler.bind(this));
|
|
16
14
|
core.on("visible", this.full.bind(this));
|
|
@@ -66,7 +64,7 @@ export default class Env {
|
|
|
66
64
|
return `# env ${entry.attributes.command}\n${entry.body}`;
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
summary() {
|
|
70
|
-
return "";
|
|
67
|
+
summary(entry) {
|
|
68
|
+
return streamSummary("env", entry);
|
|
71
69
|
}
|
|
72
70
|
}
|
|
@@ -6,8 +6,8 @@ Example: <env>npm --version</env>
|
|
|
6
6
|
Example: <env>git log --oneline -5</env>
|
|
7
7
|
<!-- Git history. Shows env for read-only investigation. -->
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
YOU MUST NOT use <env></env> to read or list files — use <get path="*"/> instead
|
|
10
10
|
<!-- Prevents cat/ls through shell. Forces file access through get. -->
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
YOU MUST NOT use <env></env> for commands with side effects
|
|
13
13
|
<!-- Separates exploration from action. env = observe only. -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const MAX_CYCLE_PERIOD =
|
|
1
|
+
import config from "../../agent/config.js";
|
|
2
|
+
|
|
3
|
+
const { MAX_STRIKES, MIN_CYCLES, MAX_CYCLE_PERIOD } = config;
|
|
4
4
|
|
|
5
5
|
const CONTRACT_REMINDER = "Missing update";
|
|
6
6
|
|
|
@@ -88,28 +88,29 @@ export default class ErrorPlugin {
|
|
|
88
88
|
if (state) state.turnErrors++;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
async #verdict({ store, runId, loopId,
|
|
91
|
+
async #verdict({ store, runId, loopId, recorded, summaryText }) {
|
|
92
92
|
const state = this.#loopState.get(loopId);
|
|
93
93
|
|
|
94
94
|
let cycleReason = null;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
runId,
|
|
104
|
-
turn,
|
|
105
|
-
loopId,
|
|
106
|
-
message: cycleReason,
|
|
107
|
-
status: 429,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
95
|
+
// Empty turns share a blank fingerprint; intentional.
|
|
96
|
+
const fp = recorded.map(fingerprint).toSorted().join("|");
|
|
97
|
+
state.history.push(fp);
|
|
98
|
+
const cycle = detectCycle(state.history);
|
|
99
|
+
if (cycle.detected) {
|
|
100
|
+
cycleReason = "Loop detected";
|
|
101
|
+
// Silent strike: increment turn-errors without a model-facing entry.
|
|
102
|
+
state.turnErrors++;
|
|
110
103
|
}
|
|
111
104
|
|
|
112
|
-
|
|
105
|
+
let recordedFailed = false;
|
|
106
|
+
for (const e of recorded) {
|
|
107
|
+
const current = await store.getState(runId, e.path);
|
|
108
|
+
if (current?.state === "failed") {
|
|
109
|
+
recordedFailed = true;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const struck = state.turnErrors > 0 || recordedFailed;
|
|
113
114
|
|
|
114
115
|
if (summaryText && !struck) {
|
|
115
116
|
state.streak = 0;
|
|
@@ -121,8 +122,7 @@ export default class ErrorPlugin {
|
|
|
121
122
|
if (struck) {
|
|
122
123
|
state.streak++;
|
|
123
124
|
if (state.streak >= MAX_STRIKES) {
|
|
124
|
-
//
|
|
125
|
-
// is honored as completion rather than overridden by 499.
|
|
125
|
+
// Abandoning-strike turn: same-turn terminal update wins over 499.
|
|
126
126
|
if (summaryText) {
|
|
127
127
|
state.streak = 0;
|
|
128
128
|
const updateEntry = recorded?.findLast?.(
|
|
@@ -141,7 +141,7 @@ export default class ErrorPlugin {
|
|
|
141
141
|
}
|
|
142
142
|
return {
|
|
143
143
|
continue: true,
|
|
144
|
-
reason:
|
|
144
|
+
reason: CONTRACT_REMINDER,
|
|
145
145
|
};
|
|
146
146
|
}
|
|
147
147
|
|
package/src/plugins/file/file.js
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
import { isAbsolute, relative } from "node:path";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* File plugin: projections and constraints for filesystem entries.
|
|
5
|
-
*
|
|
6
|
-
* Bare file paths (src/app.js) have scheme=NULL in the DB because
|
|
7
|
-
* schemeOf() only recognizes "://" patterns. The schemes table has
|
|
8
|
-
* a "file" entry so v_model_context can JOIN via COALESCE(scheme, 'file').
|
|
9
|
-
* This is the one exception to "every scheme has a plugin owner" —
|
|
10
|
-
* the file plugin owns the NULL scheme through the "file" registry entry.
|
|
11
|
-
*/
|
|
3
|
+
// Owns NULL scheme (bare paths) via the "file" registry entry; see plugin README.
|
|
12
4
|
export default class File {
|
|
13
5
|
#core;
|
|
14
6
|
|
|
15
7
|
constructor(core) {
|
|
16
8
|
this.#core = core;
|
|
17
|
-
// "file" scheme covers bare paths (scheme IS NULL in DB)
|
|
18
9
|
core.registerScheme({ category: "data" });
|
|
19
10
|
core.on("visible", this.full.bind(this));
|
|
20
11
|
core.on("summarized", this.summary.bind(this));
|
|
@@ -28,10 +19,6 @@ export default class File {
|
|
|
28
19
|
return "";
|
|
29
20
|
}
|
|
30
21
|
|
|
31
|
-
/**
|
|
32
|
-
* Set a project-level file constraint. Backbone operation —
|
|
33
|
-
* constraints are project config, not tool dispatch.
|
|
34
|
-
*/
|
|
35
22
|
static async setConstraint(db, projectId, pattern, visibility = "active") {
|
|
36
23
|
const path = await normalizePath(db, projectId, pattern);
|
|
37
24
|
if (!path) return null;
|
|
@@ -45,9 +32,6 @@ export default class File {
|
|
|
45
32
|
return path;
|
|
46
33
|
}
|
|
47
34
|
|
|
48
|
-
/**
|
|
49
|
-
* Remove a project-level file constraint.
|
|
50
|
-
*/
|
|
51
35
|
static async dropConstraint(db, projectId, pattern) {
|
|
52
36
|
const path = await normalizePath(db, projectId, pattern);
|
|
53
37
|
if (!path) return null;
|
|
@@ -60,11 +44,7 @@ export default class File {
|
|
|
60
44
|
return path;
|
|
61
45
|
}
|
|
62
46
|
|
|
63
|
-
|
|
64
|
-
* True if `path` is covered by any readonly constraint for the project.
|
|
65
|
-
* Constraints can be globs; hedberg.match provides the pattern engine.
|
|
66
|
-
* Called from AgentLoop set-accept to refuse writes to protected paths.
|
|
67
|
-
*/
|
|
47
|
+
// True if any readonly constraint matches; called from set-accept gate.
|
|
68
48
|
static async isReadonly(db, projectId, path) {
|
|
69
49
|
const rows = await db.get_file_constraints.all({ project_id: projectId });
|
|
70
50
|
const { hedmatch } = await import("./../hedberg/patterns.js");
|
package/src/plugins/get/get.js
CHANGED
|
@@ -21,32 +21,23 @@ export default class Get {
|
|
|
21
21
|
const { entries: store, sequence: turn, runId, loopId } = rummy;
|
|
22
22
|
const target = entry.attributes.path;
|
|
23
23
|
if (!target) {
|
|
24
|
-
|
|
25
|
-
// as `<error>` in <log> with a readable body AND the failure
|
|
26
|
-
// counts as a strike. The previous direct `store.set` wrote a
|
|
27
|
-
// blank-bodied failed entry whose `error` attribute was never
|
|
28
|
-
// rendered — model saw a vague 400 and repeated the mistake.
|
|
29
|
-
await rummy.hooks.error.log.emit({
|
|
30
|
-
store,
|
|
24
|
+
await store.set({
|
|
31
25
|
runId,
|
|
32
26
|
turn,
|
|
33
27
|
loopId,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
path: entry.resultPath,
|
|
29
|
+
body: 'Missing required "path" attribute on <get>. Use <get path="..."/>.',
|
|
30
|
+
state: "failed",
|
|
31
|
+
outcome: "validation",
|
|
37
32
|
});
|
|
38
33
|
return;
|
|
39
34
|
}
|
|
40
35
|
const normalized = Entries.normalizePath(target);
|
|
41
|
-
// XmlParser passes attributes through; `body` attr is optional.
|
|
42
36
|
const bodyFilter = entry.attributes.body;
|
|
43
|
-
const
|
|
37
|
+
const manifest = entry.attributes.manifest !== undefined;
|
|
44
38
|
const isPattern = bodyFilter || normalized.includes("*");
|
|
45
39
|
|
|
46
|
-
// Negative
|
|
47
|
-
// "start 50 lines from the end," enabling `tail -n N` behavior.
|
|
48
|
-
// Positive `line` is 1-indexed from start (classic). `limit` is
|
|
49
|
-
// always a positive count.
|
|
40
|
+
// Negative line = tail-from-end (line=-50 starts 50 from end).
|
|
50
41
|
const lineRaw = entry.attributes.line;
|
|
51
42
|
const line = lineRaw != null ? parseInt(lineRaw, 10) : null;
|
|
52
43
|
const limit =
|
|
@@ -60,12 +51,8 @@ export default class Get {
|
|
|
60
51
|
bodyFilter,
|
|
61
52
|
);
|
|
62
53
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
// which entries to actually promote. getDoc promises this behavior; the
|
|
66
|
-
// prior implementation silently promoted anyway, burning the Token Budget
|
|
67
|
-
// on entries the model thought it was only inspecting.
|
|
68
|
-
if (preview) {
|
|
54
|
+
// Manifest: list matches + full-body token costs; no promotion.
|
|
55
|
+
if (manifest) {
|
|
69
56
|
await storePatternResult(
|
|
70
57
|
store,
|
|
71
58
|
runId,
|
|
@@ -74,12 +61,12 @@ export default class Get {
|
|
|
74
61
|
target,
|
|
75
62
|
bodyFilter,
|
|
76
63
|
matches,
|
|
77
|
-
{
|
|
64
|
+
{ manifest: true, loopId, attributes: { path: target } },
|
|
78
65
|
);
|
|
79
66
|
return;
|
|
80
67
|
}
|
|
81
68
|
|
|
82
|
-
// Partial read
|
|
69
|
+
// Partial read: line slice in the log entry; no promotion.
|
|
83
70
|
if (line !== null || limit !== null) {
|
|
84
71
|
if (isPattern) {
|
|
85
72
|
await store.set({
|
|
@@ -108,8 +95,6 @@ export default class Get {
|
|
|
108
95
|
}
|
|
109
96
|
const allLines = matches[0].body.split("\n");
|
|
110
97
|
const total = allLines.length;
|
|
111
|
-
// Negative line offsets from the end: line=-50 starts 50 lines
|
|
112
|
-
// before the end. Clamped to 1 if the offset exceeds total.
|
|
113
98
|
const startLine =
|
|
114
99
|
line == null
|
|
115
100
|
? 1
|
|
@@ -120,10 +105,6 @@ export default class Get {
|
|
|
120
105
|
const endIdx = limit !== null ? Math.min(startIdx + limit, total) : total;
|
|
121
106
|
const slice = allLines.slice(startIdx, endIdx).join("\n");
|
|
122
107
|
const endLine = endIdx;
|
|
123
|
-
// Body leads with the source path so the model can re-issue
|
|
124
|
-
// a full or different-range read without guessing the URL.
|
|
125
|
-
// lineStart/lineEnd/totalLines ride attrs so renderLogTag can
|
|
126
|
-
// surface `lines="a-b/total"` without parsing the body.
|
|
127
108
|
const header = `${target}\n[lines ${startLine}–${endLine} / ${total} total]`;
|
|
128
109
|
await store.set({
|
|
129
110
|
runId,
|
|
@@ -188,10 +169,7 @@ export default class Get {
|
|
|
188
169
|
attributes: { path: target },
|
|
189
170
|
});
|
|
190
171
|
} else {
|
|
191
|
-
// Log
|
|
192
|
-
// itself is visible in <context>; this log line is the model's
|
|
193
|
-
// proof in <log> that the get has already been done so it
|
|
194
|
-
// doesn't re-issue the same fetch on a later turn.
|
|
172
|
+
// Log line in <log> proves the promotion happened so the model doesn't re-fetch.
|
|
195
173
|
await store.set({
|
|
196
174
|
runId,
|
|
197
175
|
turn,
|
|
@@ -9,7 +9,7 @@ Example: <get path="known://*">auth</get>
|
|
|
9
9
|
Example: <get path="src/**/*.js">authentication</get>
|
|
10
10
|
<!-- Full pattern: recursive glob + content filter. -->
|
|
11
11
|
|
|
12
|
-
Example: <get path="src/**/*.js"
|
|
12
|
+
Example: <get path="src/**/*.js" manifest>authentication</get>
|
|
13
13
|
<!-- Full pattern: recursive glob + content filter. -->
|
|
14
14
|
|
|
15
15
|
Example: <get path="src/agent/AgentLoop.js" line="644" limit="80"/>
|
|
@@ -30,7 +30,9 @@ Example: <get path="https://en.wikipedia.org/wiki/Long_Page" line="1" limit="200
|
|
|
30
30
|
* `line` and `limit` read a slice without promoting the entry, which costs as many tokens as the slice contains. Negative `line` reads from the end (tail).
|
|
31
31
|
<!-- Partial read is safe: context budget unaffected. Tail idiom enables watching growing entries. -->
|
|
32
32
|
|
|
33
|
-
* `
|
|
34
|
-
<!--
|
|
33
|
+
* `manifest` lists the paths and their token amounts instead of performing the operation; useful for bulk and pattern matching tasks.
|
|
34
|
+
<!-- manifest = listing, not snippet. The natural-language reading of "preview" pulled small models toward content-sampling; for body samples use line/limit. -->
|
|
35
35
|
|
|
36
36
|
* Remember to <set path="..." visibility="summarize"/> when entries or log events are no longer relevant.
|
|
37
|
+
|
|
38
|
+
* Promotions don't appear until next turn — emit Stage Continuation (1xx), not Completion (200)
|
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Edit format detection. Identifies the edit syntax the model used
|
|
3
|
-
* and normalizes it into { search, replace } blocks.
|
|
4
|
-
*
|
|
5
|
-
* Supported formats:
|
|
6
|
-
* 1. SEARCH/REPLACE merge conflict blocks
|
|
7
|
-
* 2. Replace-only blocks (no search)
|
|
8
|
-
* 3. Unified diff
|
|
9
|
-
* 4. Claude XML (<old_text>/<new_text>)
|
|
10
|
-
*/
|
|
11
|
-
|
|
1
|
+
// Detects merge-conflict / replace-only / udiff / Claude XML edits → {search,replace}; SPEC #hedberg.
|
|
12
2
|
export function parseEditContent(content) {
|
|
13
3
|
const blocks = [];
|
|
14
4
|
|
|
@@ -4,18 +4,7 @@ import { parseJsonEdit } from "./normalize.js";
|
|
|
4
4
|
import { hedmatch, hedsearch } from "./patterns.js";
|
|
5
5
|
import { parseSed } from "./sed.js";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
* Hedberg: the interpretation boundary between stochastic model output
|
|
9
|
-
* and deterministic system operations.
|
|
10
|
-
*
|
|
11
|
-
* Registers its functions on core.hedberg so any plugin can call them:
|
|
12
|
-
* core.hedberg.match(pattern, string)
|
|
13
|
-
* core.hedberg.search(pattern, string)
|
|
14
|
-
* core.hedberg.replace(body, search, replacement, options?)
|
|
15
|
-
* core.hedberg.parseSed(input)
|
|
16
|
-
* core.hedberg.parseEdits(content)
|
|
17
|
-
* core.hedberg.generatePatch(path, old, new)
|
|
18
|
-
*/
|
|
7
|
+
// Stochastic→deterministic boundary; exposes pattern/edit utilities on core.hedberg. SPEC #hedberg.
|
|
19
8
|
export default class Hedberg {
|
|
20
9
|
#core;
|
|
21
10
|
|
|
@@ -31,17 +20,9 @@ export default class Hedberg {
|
|
|
31
20
|
parseJsonEdit,
|
|
32
21
|
generatePatch,
|
|
33
22
|
};
|
|
34
|
-
|
|
35
|
-
// Patterns documentation distributed to individual tool docs.
|
|
36
|
-
// Hedberg has no model-facing docs of its own.
|
|
37
23
|
}
|
|
38
24
|
|
|
39
|
-
|
|
40
|
-
* Apply a replacement to text. Handles sed regex, literal match,
|
|
41
|
-
* and heuristic fuzzy match — in that order.
|
|
42
|
-
*
|
|
43
|
-
* Returns { patch, searchText, replaceText, warning, error }
|
|
44
|
-
*/
|
|
25
|
+
// Order: sed regex → literal → heuristic fuzzy.
|
|
45
26
|
static replace(body, search, replacement, { sed = false, flags = "" } = {}) {
|
|
46
27
|
let patch = null;
|
|
47
28
|
let warning = null;
|
|
@@ -55,11 +36,7 @@ export default class Hedberg {
|
|
|
55
36
|
searchText,
|
|
56
37
|
flags.includes("g") ? flags : `${flags}g`,
|
|
57
38
|
);
|
|
58
|
-
//
|
|
59
|
-
// The model writes `\[x\]` meaning literal `[x]` in both search
|
|
60
|
-
// and replace. RegExp handles this in search; in the replacement
|
|
61
|
-
// string we must strip the backslashes ourselves since
|
|
62
|
-
// String.replace only interprets `$` sequences, not `\`.
|
|
39
|
+
// Strip regex-meta escapes in replacement; String.replace only interprets `$`, not `\`.
|
|
63
40
|
const unescaped = replaceText.replace(/\\([[\](){}.*+?^$|\\])/g, "$1");
|
|
64
41
|
patch = body.replace(re, unescaped);
|
|
65
42
|
if (patch === body) patch = null;
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Parse JSON-style edit from body content.
|
|
3
|
-
* Accepts: {"search":"old","replace":"new"} and {search="old",replace="new"}
|
|
4
|
-
* Returns { search, replace } or null.
|
|
5
|
-
*/
|
|
1
|
+
// {"search":"old","replace":"new"} or {search="old",replace="new"} → {search,replace}|null.
|
|
6
2
|
export function parseJsonEdit(text) {
|
|
7
3
|
const trimmed = text.trim();
|
|
8
4
|
if (!trimmed.startsWith("{") || !/search/.test(trimmed)) return null;
|
|
@@ -313,10 +313,7 @@ function collectDescendants(node, out) {
|
|
|
313
313
|
|
|
314
314
|
// --- Public API ---
|
|
315
315
|
|
|
316
|
-
|
|
317
|
-
* hedmatch — does the pattern match the ENTIRE string?
|
|
318
|
-
* For path matching, WHERE clauses, full-string comparison.
|
|
319
|
-
*/
|
|
316
|
+
// hedmatch — full-string match (path, WHERE clause).
|
|
320
317
|
export function hedmatch(pattern, string) {
|
|
321
318
|
if (string === null) return false;
|
|
322
319
|
|
|
@@ -345,11 +342,7 @@ export function hedmatch(pattern, string) {
|
|
|
345
342
|
return false;
|
|
346
343
|
}
|
|
347
344
|
|
|
348
|
-
|
|
349
|
-
* hedsearch — find the pattern anywhere IN the string.
|
|
350
|
-
* For substring search, content filtering, "does this text contain...".
|
|
351
|
-
* Returns { found, match, index } or { found: false }.
|
|
352
|
-
*/
|
|
345
|
+
// hedsearch — substring match → { found, match, index }.
|
|
353
346
|
export function hedsearch(pattern, string) {
|
|
354
347
|
if (string === null) return { found: false };
|
|
355
348
|
|
|
@@ -400,10 +393,7 @@ export function hedsearch(pattern, string) {
|
|
|
400
393
|
return { found: false };
|
|
401
394
|
}
|
|
402
395
|
|
|
403
|
-
|
|
404
|
-
* hedreplace — find pattern in string, replace with replacement.
|
|
405
|
-
* Returns the new string, or null if pattern not found.
|
|
406
|
-
*/
|
|
396
|
+
// hedreplace — substitute; null when pattern not found.
|
|
407
397
|
export function hedreplace(pattern, replacement, string) {
|
|
408
398
|
if (string === null) return null;
|
|
409
399
|
|
|
@@ -446,5 +436,4 @@ export function hedreplace(pattern, replacement, string) {
|
|
|
446
436
|
return null;
|
|
447
437
|
}
|
|
448
438
|
|
|
449
|
-
// SQL functions
|
|
450
|
-
// that import from this library. Filename = SQL function name.
|
|
439
|
+
// SQL functions live in sibling files; filename = SQL function name.
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Sed syntax parsing. Handles s/search/replace/flags with:
|
|
3
|
-
* - Escaped delimiters (\\/)
|
|
4
|
-
* - Chained commands (s/a/b/ s/c/d/)
|
|
5
|
-
* - Flag extraction (g, i, m, s, v)
|
|
6
|
-
*/
|
|
7
|
-
|
|
1
|
+
// Parses s/search/replace/flags with escaped delimiters, chains, and g/i/m/s/v flags.
|
|
8
2
|
function splitSed(str, delim) {
|
|
9
3
|
const parts = [];
|
|
10
4
|
let current = "";
|
package/src/plugins/helpers.js
CHANGED
|
@@ -2,13 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
* Read a sibling tooldoc markdown file and return its model-facing text.
|
|
7
|
-
* Strips HTML comments (rationale stays in source, never reaches the model)
|
|
8
|
-
* and collapses any blank-line runs left behind. Each plugin's Doc.js is a
|
|
9
|
-
* one-liner that defers to this so authors edit normal markdown instead of
|
|
10
|
-
* a JS array of [text, rationale] pairs.
|
|
11
|
-
*/
|
|
5
|
+
// Read sibling tooldoc .md; strips HTML comments (rationale stays out of the model packet).
|
|
12
6
|
export function loadDoc(metaUrl, name) {
|
|
13
7
|
const dir = dirname(fileURLToPath(metaUrl));
|
|
14
8
|
return readFileSync(join(dir, name), "utf8")
|
|
@@ -17,23 +11,37 @@ export function loadDoc(metaUrl, name) {
|
|
|
17
11
|
.trim();
|
|
18
12
|
}
|
|
19
13
|
|
|
20
|
-
|
|
21
|
-
* Translate a log entry path into its companion data-scheme base path.
|
|
22
|
-
* `log://turn_N/{action}/{rest}` → `{action}://turn_N/{rest}`.
|
|
23
|
-
* Streaming producers (sh, env) create data channel entries under the
|
|
24
|
-
* producer scheme while the audit record lives in the log scheme; this
|
|
25
|
-
* helper bridges the two namespaces. Returns null for non-log paths.
|
|
26
|
-
*/
|
|
14
|
+
// log://turn_N/{action}/{rest} → {action}://turn_N/{rest}; null if not a log path.
|
|
27
15
|
export function logPathToDataBase(logPath) {
|
|
28
16
|
const m = logPath?.match(/^log:\/\/turn_(\d+)\/([^/]+)\/(.+)$/);
|
|
29
17
|
if (!m) return null;
|
|
30
18
|
return `${m[2]}://turn_${m[1]}/${m[3]}`;
|
|
31
19
|
}
|
|
32
20
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
21
|
+
// env/sh stdout/stderr summary projection: header with line range + last
|
|
22
|
+
// TAIL_LINES of body. The header tells the model exactly which slice is
|
|
23
|
+
// shown so it can issue <get line="N" limit="M"/> for the rest without
|
|
24
|
+
// re-running the command.
|
|
25
|
+
export function streamSummary(label, entry, TAIL_LINES = 12) {
|
|
26
|
+
if (!entry.body) return "";
|
|
27
|
+
const { body, attributes } = entry;
|
|
28
|
+
const command = attributes.command;
|
|
29
|
+
const channel = attributes.channel === 2 ? "stderr" : "stdout";
|
|
30
|
+
const trailingNewline = body.endsWith("\n");
|
|
31
|
+
const lines = trailingNewline
|
|
32
|
+
? body.slice(0, -1).split("\n")
|
|
33
|
+
: body.split("\n");
|
|
34
|
+
const total = lines.length;
|
|
35
|
+
if (total <= TAIL_LINES) {
|
|
36
|
+
return `# ${label} ${command} (${channel}, ${total}L)\n${body}`;
|
|
37
|
+
}
|
|
38
|
+
const startLine = total - TAIL_LINES + 1;
|
|
39
|
+
const tail =
|
|
40
|
+
lines.slice(-TAIL_LINES).join("\n") + (trailingNewline ? "\n" : "");
|
|
41
|
+
return `# ${label} ${command} (${channel}, tail L${startLine}-${total}/${total}; <get line="1" limit="N"/> for head)\n${tail}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Pattern-result log entry shared by get/set/store/rm.
|
|
37
45
|
export async function storePatternResult(
|
|
38
46
|
store,
|
|
39
47
|
runId,
|
|
@@ -42,13 +50,13 @@ export async function storePatternResult(
|
|
|
42
50
|
path,
|
|
43
51
|
bodyFilter,
|
|
44
52
|
matches,
|
|
45
|
-
{
|
|
53
|
+
{ manifest = false, loopId = null, attributes = null } = {},
|
|
46
54
|
) {
|
|
47
55
|
const logSlug = await store.logPath(runId, turn, scheme, path);
|
|
48
56
|
const filter = bodyFilter ? ` body="${bodyFilter}"` : "";
|
|
49
57
|
const total = matches.reduce((s, m) => s + m.tokens, 0);
|
|
50
58
|
const listing = matches.map((m) => `${m.path} (${m.tokens})`).join("\n");
|
|
51
|
-
const prefix =
|
|
59
|
+
const prefix = manifest ? "MANIFEST " : "";
|
|
52
60
|
const body = `${prefix}${scheme} path="${path}"${filter}: ${matches.length} matched (${total} tokens)\n${listing}`;
|
|
53
61
|
await store.set({
|
|
54
62
|
runId,
|