@possumtech/rummy 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +14 -6
  2. package/service.js +18 -10
  3. package/src/agent/AgentLoop.js +2 -11
  4. package/src/agent/ContextAssembler.js +34 -3
  5. package/src/agent/Entries.js +16 -89
  6. package/src/agent/ProjectAgent.js +1 -16
  7. package/src/agent/TurnExecutor.js +12 -52
  8. package/src/agent/XmlParser.js +30 -117
  9. package/src/agent/errors.js +3 -22
  10. package/src/agent/materializeContext.js +3 -11
  11. package/src/hooks/Hooks.js +0 -29
  12. package/src/lib/hedberg/hedberg.js +4 -14
  13. package/src/lib/hedberg/marker.js +15 -59
  14. package/src/llm/LlmProvider.js +13 -26
  15. package/src/llm/errors.js +3 -11
  16. package/src/llm/openaiStream.js +6 -46
  17. package/src/plugins/ask_user/ask_user.js +12 -17
  18. package/src/plugins/budget/README.md +46 -8
  19. package/src/plugins/budget/budget.js +23 -42
  20. package/src/plugins/cp/cp.js +28 -18
  21. package/src/plugins/env/env.js +11 -7
  22. package/src/plugins/error/error.js +8 -37
  23. package/src/plugins/get/get.js +42 -24
  24. package/src/plugins/google/google.js +23 -3
  25. package/src/plugins/helpers.js +34 -50
  26. package/src/plugins/instructions/README.md +2 -2
  27. package/src/plugins/instructions/instructions-user.md +1 -1
  28. package/src/plugins/instructions/instructions.js +19 -6
  29. package/src/plugins/known/known.js +1 -8
  30. package/src/plugins/log/log.js +15 -1
  31. package/src/plugins/mv/mv.js +29 -19
  32. package/src/plugins/persona/persona.js +4 -4
  33. package/src/plugins/prompt/README.md +1 -1
  34. package/src/plugins/prompt/prompt.js +1 -1
  35. package/src/plugins/rm/rm.js +26 -15
  36. package/src/plugins/rm/rmDoc.md +0 -2
  37. package/src/plugins/set/set.js +37 -84
  38. package/src/plugins/set/setDoc.md +16 -16
  39. package/src/plugins/sh/sh.js +10 -8
  40. package/src/plugins/skill/skillDoc.md +1 -1
  41. package/src/plugins/unknown/README.md +1 -1
  42. package/src/plugins/unknown/unknown.js +2 -6
  43. package/src/plugins/update/update.js +3 -2
  44. package/src/plugins/update/updateDoc.md +1 -1
  45. package/.env.example +0 -152
  46. package/.xai.key +0 -1
  47. package/PLUGINS.md +0 -962
  48. package/SPEC.md +0 -1897
  49. package/biome/no-fallbacks.grit +0 -50
  50. package/gemini.key +0 -1
@@ -1,5 +1,5 @@
1
1
  import { SOFT_FAILURE_OUTCOMES } from "../../agent/errors.js";
2
- import { SUMMARY_MAX_CHARS } from "../helpers.js";
2
+ import { projectEmission, summarizeEmission } from "../helpers.js";
3
3
 
4
4
  const MAX_STRIKES = Number(process.env.RUMMY_MAX_STRIKES);
5
5
  const MIN_CYCLES = Number(process.env.RUMMY_MIN_CYCLES);
@@ -40,19 +40,14 @@ export default class ErrorPlugin {
40
40
  constructor(core) {
41
41
  this.#core = core;
42
42
  core.registerScheme({ category: "logging" });
43
- core.on("visible", (entry) => `# error\n${entry.body}`);
44
- core.on("summarized", (entry) => entry.body.slice(0, SUMMARY_MAX_CHARS));
43
+ core.on("visible", (entry) => projectEmission(entry.body));
44
+ core.on("summarized", (entry) => summarizeEmission(entry.body));
45
45
 
46
46
  core.hooks.error.log.on(this.#onErrorLog.bind(this));
47
47
  core.hooks.loop.started.on(this.#onLoopStarted.bind(this));
48
48
  core.hooks.loop.completed.on(this.#onLoopCompleted.bind(this));
49
49
  core.hooks.turn.started.on(this.#onTurnStarted.bind(this));
50
50
 
51
- // Subscribe to the turn.verdict filter chain. Multi-plugin
52
- // surface — strike streak, cycle detection, stagnation
53
- // pressure all flow through here. Future voters (e.g. budget
54
- // overflow termination, runaway-on-context-grow) participate
55
- // via the same chain.
56
51
  core.filter("turn.verdict", this.#verdict.bind(this));
57
52
  }
58
53
 
@@ -85,15 +80,8 @@ export default class ErrorPlugin {
85
80
  }) {
86
81
  const statusValue = status ?? 400;
87
82
  const path = await store.logPath(runId, turn, "error", message);
88
- // Soft errors record but don't strike: the issue was already
89
- // recovered (e.g. parser auto-corrected a closing-tag mismatch)
90
- // and the entry exists only so the model can see what happened.
91
- // state="resolved" keeps recordedFailed clean; skipping
92
- // turnErrors++ keeps the strike machinery from firing. Per SPEC
93
- // #entries, outcome is reserved for state ∈ {failed, cancelled}
94
- // — soft entries land with outcome=null. Status carrier for
95
- // rendering is attributes.status, consulted before outcome by
96
- // log.js's renderLogTag.
83
+ // Soft errors record without striking recovered issues the model
84
+ // should see but not be punished for. SPEC #entries.
97
85
  await store.set({
98
86
  runId,
99
87
  turn,
@@ -113,31 +101,19 @@ export default class ErrorPlugin {
113
101
  _currentVerdict,
114
102
  { store, runId, loopId, recorded, summaryText, turn: _turn },
115
103
  ) {
116
- // _currentVerdict is the upstream filter's result. Today this is
117
- // the only voter so it's always { continue: true }. When other
118
- // plugins join the chain, they can short-circuit by setting
119
- // continue=false; this implementation could honor that via an
120
- // early return. Left noop for now to preserve current semantics.
121
104
  const state = this.#loopState.get(loopId);
122
105
 
123
106
  let cycleReason = null;
124
- // Empty turns share a blank fingerprint; intentional.
125
107
  const fp = recorded.map(fingerprint).toSorted().join("|");
126
108
  state.history.push(fp);
127
109
  const cycle = detectCycle(state.history);
128
110
  if (cycle.detected) {
129
111
  cycleReason = "Loop detected";
130
- // Silent strike: increment turn-errors without a model-facing entry.
131
112
  state.turnErrors++;
132
113
  }
133
114
 
134
- // Some failure outcomes are findings the model should adapt to,
135
- // not contract violations. `not_found` (model tried to act on an
136
- // entry that doesn't exist) and `conflict` (SEARCH text didn't
137
- // match current body) are recoverable: the model reads the new
138
- // state and tries again. Striking on these punishes legitimate
139
- // state-discovery and accumulates 499s on otherwise productive
140
- // runs. Hard outcomes (validation, permission, exit:N) still strike.
115
+ // Soft outcomes (not_found, conflict) are state-discovery findings
116
+ // the model adapts to; only hard failures count toward the strike.
141
117
  let recordedFailed = false;
142
118
  for (const e of recorded) {
143
119
  const current = await store.getState(runId, e.path);
@@ -161,7 +137,7 @@ export default class ErrorPlugin {
161
137
  if (struck) {
162
138
  state.streak++;
163
139
  if (state.streak >= MAX_STRIKES) {
164
- // Abandoning-strike turn: same-turn terminal update wins over 499.
140
+ // Same-turn terminal update wins over 499.
165
141
  if (summaryText) {
166
142
  state.streak = 0;
167
143
  const updateEntry = recorded?.findLast?.(
@@ -178,11 +154,6 @@ export default class ErrorPlugin {
178
154
  `Abandoned after ${state.streak} consecutive strikes.`,
179
155
  };
180
156
  }
181
- // No reason on continue: the model sees the actual failure
182
- // entries directly in <log> next turn. Hardcoding "Missing
183
- // update" mislabels strikes that fire on validation /
184
- // permission / dispatch failures or cycles, when the update
185
- // itself was emitted correctly.
186
157
  return { continue: true };
187
158
  }
188
159
 
@@ -1,5 +1,10 @@
1
1
  import Entries from "../../agent/Entries.js";
2
- import { storePatternResult } from "../helpers.js";
2
+ import { countTokens } from "../../agent/tokens.js";
3
+ import {
4
+ projectEmission,
5
+ storePatternResult,
6
+ summarizeEmission,
7
+ } from "../helpers.js";
3
8
  import docs from "./getDoc.js";
4
9
 
5
10
  export default class Get {
@@ -19,11 +24,8 @@ export default class Get {
19
24
 
20
25
  async handler(entry, rummy) {
21
26
  const { entries: store, sequence: turn, runId, loopId } = rummy;
22
- // Search-by-tags: same `tags` attribute that <set> writes onto
23
- // entries. Same name on both ends — no in/out semantic split.
24
27
  const tagsAttr = entry.attributes.tags;
25
- // Tags-only get defaults path to "**" so the model can recall by
26
- // folksonomic tags without remembering exact paths.
28
+ // Tags-only get defaults path to "**" for tag-only recall.
27
29
  const target = entry.attributes.path || (tagsAttr ? "**" : null);
28
30
  if (!target) {
29
31
  await store.set({
@@ -74,7 +76,6 @@ export default class Get {
74
76
  });
75
77
  }
76
78
 
77
- // Manifest: list matches + full-body token costs; no promotion.
78
79
  if (manifest) {
79
80
  await storePatternResult(
80
81
  store,
@@ -89,27 +90,36 @@ export default class Get {
89
90
  return;
90
91
  }
91
92
 
92
- // Partial read: line slice in the log entry; no promotion.
93
- // Per getDoc: "line/limit works on any scheme — files, sh
94
- // stdout, knowns, urls." Multi-match (glob, tags, or body
95
- // filter narrowing) emits one slice section per match —
96
- // model can scope further with body filter or tighter path.
93
+ // Partial read: slice into attrs.slice, no promotion. Multi-match
94
+ // emits one section per match.
97
95
  if (line !== null || limit !== null) {
98
96
  if (matches.length === 0) {
99
97
  await store.set({
100
98
  runId,
101
99
  turn,
102
100
  path: entry.resultPath,
103
- body: `${target} not found`,
101
+ body: "",
104
102
  state: "resolved",
103
+ outcome: "not_found",
105
104
  loopId,
106
- attributes: { path: target },
105
+ attributes: {
106
+ path: target,
107
+ line,
108
+ limit,
109
+ error: `${target} not found`,
110
+ },
107
111
  });
108
112
  return;
109
113
  }
110
114
  const sections = matches.map((match) => sliceSection(match, line, limit));
111
- const body = sections.map((s) => s.text).join("\n\n");
112
- const attributes = { path: target };
115
+ const sliceBody = sections.map((s) => s.text).join("\n\n");
116
+ const attributes = {
117
+ path: target,
118
+ line,
119
+ limit,
120
+ beforeActionTokens: 0,
121
+ afterActionTokens: countTokens(sliceBody),
122
+ };
113
123
  if (sections.length === 1) {
114
124
  const only = sections[0];
115
125
  attributes.lineStart = only.startLine;
@@ -122,7 +132,7 @@ export default class Get {
122
132
  runId,
123
133
  turn,
124
134
  path: entry.resultPath,
125
- body,
135
+ body: sliceBody,
126
136
  state: "resolved",
127
137
  loopId,
128
138
  attributes,
@@ -170,31 +180,39 @@ export default class Get {
170
180
  runId,
171
181
  turn,
172
182
  path: entry.resultPath,
173
- body: `${target} not found`,
183
+ body: "",
174
184
  state: "resolved",
185
+ outcome: "not_found",
175
186
  loopId,
176
- attributes: { path: target },
187
+ attributes: { path: target, error: `${target} not found` },
177
188
  });
178
189
  } else {
179
- // Log line in <log> proves the promotion happened so the model doesn't re-fetch.
190
+ const promotedTokens = matches.reduce(
191
+ (n, m) => n + countTokens(m.body),
192
+ 0,
193
+ );
180
194
  await store.set({
181
195
  runId,
182
196
  turn,
183
197
  path: entry.resultPath,
184
- body: `${target} promoted`,
198
+ body: "",
185
199
  state: "resolved",
186
200
  loopId,
187
- attributes: { path: target },
201
+ attributes: {
202
+ path: target,
203
+ beforeActionTokens: 0,
204
+ afterActionTokens: promotedTokens,
205
+ },
188
206
  });
189
207
  }
190
208
  }
191
209
 
192
210
  full(entry) {
193
- return `# get ${entry.attributes.path || entry.path}\n${entry.body}`;
211
+ return projectEmission(entry.body);
194
212
  }
195
213
 
196
- summary() {
197
- return "";
214
+ summary(entry) {
215
+ return summarizeEmission(entry.body);
198
216
  }
199
217
  }
200
218
 
@@ -10,6 +10,21 @@ const PROVIDER = "google";
10
10
  const BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
11
11
  const COMPAT_URL = `${BASE_URL}/openai`;
12
12
 
13
+ // Documented input-token limits, prefix-matched. The native introspection
14
+ // endpoint (/v1beta/models/{model}) requires a key permission separate from
15
+ // generateContent — keys provisioned for chat-only return 403 here, crashing
16
+ // any run that depends on the lookup. Trust the docs first; hit the API only
17
+ // for unknown models.
18
+ const KNOWN_CONTEXT = [
19
+ ["gemini-3.1-flash-lite", 1_048_576],
20
+ ["gemini-3.1-flash", 1_048_576],
21
+ ["gemini-3.1-pro", 1_048_576],
22
+ ["gemini-3.0", 1_048_576],
23
+ ["gemini-2.5", 1_048_576],
24
+ ["gemini-2.0", 1_048_576],
25
+ ["gemini-1.5", 1_048_576],
26
+ ];
27
+
13
28
  // Repo-root-relative key file. Resolved relative to this source file so
14
29
  // CWD changes during runs (programbench/tbench cd into workspaces) don't
15
30
  // break the lookup. Plugin is inert if the file is missing. Tests may
@@ -94,9 +109,14 @@ export default class Google {
94
109
  async #getContextSize(model) {
95
110
  if (this.#contextCache.has(model)) return this.#contextCache.get(model);
96
111
 
97
- // Native /v1beta/models/{model} requires API key as `?key=` query
98
- // parameter — Bearer auth (which works on the OpenAI-compat layer)
99
- // returns 401 here. Different auth surface, same key.
112
+ const known = KNOWN_CONTEXT.find(([prefix]) => model.startsWith(prefix));
113
+ if (known) {
114
+ this.#contextCache.set(model, known[1]);
115
+ return known[1];
116
+ }
117
+
118
+ // /v1beta/models/{model} requires `?key=` (Bearer 401s here) AND a
119
+ // key scope that includes models.get — chat-only keys return 403.
100
120
  const url = `${BASE_URL}/models/${model}?key=${encodeURIComponent(this.#apiKey)}`;
101
121
  const res = await fetch(url, {
102
122
  signal: AbortSignal.timeout(FETCH_TIMEOUT),
@@ -2,30 +2,32 @@ import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- // Hard system ceiling on the size of any summarized projection. Single
6
- // source of truth — every plugin's `summarized` view must produce output
7
- // ≤ this many characters; materializeContext's defensive cap fires when
8
- // a plugin breaks the contract. Change this number, everything downstream
9
- // stays consistent (no "450 here, 480 there, 500 over yonder" drift).
5
+ // Hard ceiling on summarized projections. materializeContext enforces.
10
6
  export const SUMMARY_MAX_CHARS = 500;
11
7
 
12
- // Render a single entry as a heredoc-fenced block. Replaces the prior
13
- // per-plugin XML tag rendering (`<known path="..." ...>body</known>`).
14
- //
15
- // Why heredoc, not XML: the model emits XML for actions (`<set>`,
16
- // `<get>`, `<sh>`). When entries were ALSO XML, the inner tag could
17
- // look indistinguishable from a tool call — a file containing a
18
- // `<set>` example, an env streamed-stdout containing tool-shaped text,
19
- // or the model's own `<known>` body containing accidental tag-shaped
20
- // content could leak into the model's emit pattern. Heredoc fences
21
- // have zero training prior in tool-emission position, so they're
22
- // structurally separate from the action grammar.
23
- //
24
- // Format: `{json-meta} <<:::{path}\n{body}\n:::{path}`. The path is the
25
- // terminator — collision requires the body to literally contain its own
26
- // URI on a line by itself, which is vanishingly rare without active
27
- // malice. JSON metadata is sorted-key stringified for prefix-cache
28
- // stability (different field order = different bytes = cache miss).
8
+ // Tab-indent every line so a column-zero `:::path` in the body can't
9
+ // prematurely close the outer heredoc envelope.
10
+ export function projectEmission(source) {
11
+ if (!source) return "";
12
+ return source
13
+ .split("\n")
14
+ .map((line) => `\t${line}`)
15
+ .join("\n");
16
+ }
17
+
18
+ // Same tab-indent recap signal as `projectEmission`, capped at
19
+ // SUMMARY_MAX_CHARS post-projection so tabs don't push past the contract.
20
+ export function summarizeEmission(body) {
21
+ if (!body) return "";
22
+ const projected = projectEmission(body);
23
+ return projected.length > SUMMARY_MAX_CHARS
24
+ ? projected.slice(0, SUMMARY_MAX_CHARS)
25
+ : projected;
26
+ }
27
+
28
+ // Heredoc fence (path is the terminator) — distinct from XML so model
29
+ // emissions and entry projections can't collide. JSON meta sorted for
30
+ // prefix-cache stability.
29
31
  export function renderEntry(path, metadata, body) {
30
32
  const meta = canonicalJson(metadata);
31
33
  if (!body) {
@@ -35,7 +37,6 @@ export function renderEntry(path, metadata, body) {
35
37
  return `${meta} <<:::${path}\n${body}${trailingNewline}:::${path}`;
36
38
  }
37
39
 
38
- // JSON.stringify with sorted top-level keys for byte-stable output.
39
40
  function canonicalJson(obj) {
40
41
  const keys = Object.keys(obj).sort();
41
42
  const sorted = {};
@@ -43,39 +44,28 @@ function canonicalJson(obj) {
43
44
  return JSON.stringify(sorted);
44
45
  }
45
46
 
46
- // Read sibling tooldoc .md; strips HTML comments (rationale stays out of the model packet).
47
+ // Read sibling tooldoc .md, strip HTML comments (rationale stays out of
48
+ // the model packet) and collapse blank-line runs.
47
49
  export function loadDoc(metaUrl, name) {
48
50
  const dir = dirname(fileURLToPath(metaUrl));
49
51
  return readFileSync(join(dir, name), "utf8")
52
+ .replace(/^[ \t]*<!--[\s\S]*?-->[ \t]*\n?/gm, "")
50
53
  .replace(/<!--[\s\S]*?-->/g, "")
51
54
  .replace(/\n{3,}/g, "\n\n")
52
55
  .trim();
53
56
  }
54
57
 
55
- // log://turn_N/{action}/{rest} → {action}://turn_N/{rest}; null if not a log path.
56
58
  export function logPathToDataBase(logPath) {
57
59
  const m = logPath?.match(/^log:\/\/turn_(\d+)\/([^/]+)\/(.+)$/);
58
60
  if (!m) return null;
59
61
  return `${m[2]}://turn_${m[1]}/${m[3]}`;
60
62
  }
61
63
 
62
- // env/sh stdout/stderr summary projection: line-tail with a final hard
63
- // truncation to SUMMARY_MAX_CHARS. The line cap is the natural unit
64
- // when the program emits text; the final size cap is the contract floor
65
- // (see materializeContext) and protects against few-newline output like
66
- // terminal-control programs (cmatrix, htop) emitting one giant ANSI line.
67
- //
68
- // Output stays as a flat string (not a renderEntry block) because the
69
- // caller (log.assembleLog) wraps each log entry in renderEntry with its
70
- // own metadata; this is the BODY of that block. Effectively double
71
- // fencing — `<<:::log://turn_3/sh/foo` outer, then this header inside —
72
- // but that's correct: the outer fence labels "this is sh activity at
73
- // turn 3", and the body inside is the slice of stdout the model sees.
74
- export function streamSummary(label, entry, MAX_LINES = 20) {
64
+ // Tail-truncate stream output to last MAX_LINES, then chop to
65
+ // SUMMARY_MAX_CHARS for one-line giants (ANSI/cmatrix shape).
66
+ export function streamSummary(_label, entry, MAX_LINES = 20) {
75
67
  if (!entry.body) return "";
76
- const { body, attributes } = entry;
77
- const command = attributes.command;
78
- const channel = attributes.channel === 2 ? "stderr" : "stdout";
68
+ const { body } = entry;
79
69
  const trailingNewline = body.endsWith("\n");
80
70
  const lines = trailingNewline
81
71
  ? body.slice(0, -1).split("\n")
@@ -85,17 +75,11 @@ export function streamSummary(label, entry, MAX_LINES = 20) {
85
75
  total <= MAX_LINES
86
76
  ? body
87
77
  : lines.slice(-MAX_LINES).join("\n") + (trailingNewline ? "\n" : "");
88
-
89
- const header =
90
- total <= MAX_LINES
91
- ? `# ${label} ${command} (${channel}, ${total}L)`
92
- : `# ${label} ${command} (${channel}, lines ${total - MAX_LINES + 1} through ${total} of ${total}; <get line="1" limit="N"/> for head)`;
93
-
94
- const out = `${header}\n${lineTail}`;
95
- return out.length > SUMMARY_MAX_CHARS ? out.slice(0, SUMMARY_MAX_CHARS) : out;
78
+ return lineTail.length > SUMMARY_MAX_CHARS
79
+ ? lineTail.slice(0, SUMMARY_MAX_CHARS)
80
+ : lineTail;
96
81
  }
97
82
 
98
- // Pattern-result log entry shared by get/set/store/rm.
99
83
  export async function storePatternResult(
100
84
  store,
101
85
  runId,
@@ -17,7 +17,7 @@ run.
17
17
  participants mutate a docsMap keyed by tool name). Render order
18
18
  follows tool-registration order.
19
19
  - **Filter**: `assembly.user` (priority 165) — renders
20
- `instructions-user.md` as `<instructions>` late in the user
20
+ `instructions-user.md` as `<system_requirements>` late in the user
21
21
  message, between `<unknowns>` (150) and `<budget>` (175). The
22
22
  user message is a sandwich: `<prompt>` (30) leads for cache
23
23
  stability, dynamic state fills the middle, then rules and
@@ -39,7 +39,7 @@ The persona block is rendered by the persona plugin's own
39
39
  Static within a run; only `[%TOOLS%]` substitutes at render. No
40
40
  per-turn content here, ever.
41
41
  - `instructions-user.md` — the per-turn imperative reminder
42
- rendered as `<instructions>` in the user message. Same bytes
42
+ rendered as `<system_requirements>` in the user message. Same bytes
43
43
  every turn.
44
44
  - `protocol.js` / `protocol.test.js` — pass-through stub on
45
45
  `entry.recording` (priority 1) reserved for future
@@ -50,4 +50,4 @@ Example:
50
50
  <update status="200">Paris</update>
51
51
 
52
52
  YOU MUST NOT allow the `"tokens":N` sum of source entries, prompts, or log events to exceed `tokensFree="N"` budget.
53
- YOU MUST terminate your turn with <update status="{102|200}">{ direct answer or one-line summary }</update> (<= 80 chars)
53
+ YOU MUST terminate every turn with <update status="{102|200}">{ direct one-line answer or one-line summary }</update> (<= 80 chars)
@@ -28,13 +28,26 @@ export default class Instructions {
28
28
  this.#core = core;
29
29
  core.hooks.instructions.findLatestSummary =
30
30
  this.findLatestSummary.bind(this);
31
- // System message: priority chain. Base header + grammar at 50,
32
- // joined per-tool docs at 100, persona at 150 (in persona.js).
31
+ // System message: <system_commands> wraps the grammar + per-tool
32
+ // docs as a single semantic unit. Wrapper filters at 49 / 101
33
+ // sandwich the two content filters at 50 / 100. Other system
34
+ // participants (state blocks at 200/250/300/350) render after.
35
+ core.filter(
36
+ "assembly.system",
37
+ (content) => `${content}<system_commands>\n`,
38
+ 49,
39
+ );
33
40
  core.filter("assembly.system", this.assembleSystemBase.bind(this), 50);
34
41
  core.filter("assembly.system", this.assembleSystemToolDocs.bind(this), 100);
35
- // User message: per-turn reminder block at the front of the user
36
- // packet — sets discipline before the prompt.
37
- core.filter("assembly.user", this.assembleInstructions.bind(this), 30);
42
+ core.filter(
43
+ "assembly.system",
44
+ (content) => `${content}\n</system_commands>\n`,
45
+ 101,
46
+ );
47
+ // User message: <system_requirements> at the action site —
48
+ // recency keeps protocol discipline (XML tag wrapping, terminal
49
+ // `<update>`) warm right before generation.
50
+ core.filter("assembly.user", this.assembleInstructions.bind(this), 165);
38
51
  new Protocol(core);
39
52
  }
40
53
 
@@ -76,7 +89,7 @@ export default class Instructions {
76
89
 
77
90
  // assembly.user @ 165 — per-turn reminder, same body every turn.
78
91
  assembleInstructions(content, _ctx) {
79
- return `${content}<instructions>\n${userInstructions}\n</instructions>\n`;
92
+ return `${content}<system_requirements>\n${userInstructions}\n</system_requirements>\n`;
80
93
  }
81
94
 
82
95
  // Latest terminal update (status=200) — used by cli.js to print the
@@ -16,8 +16,7 @@ export default class Known {
16
16
  core.on("summarized", this.summary.bind(this));
17
17
  core.filter("assembly.system", this.assembleSummarized.bind(this), 200);
18
18
  core.filter("assembly.system", this.assembleVisible.bind(this), 250);
19
- // Hidden from the advertised tool list — written via <set path="known://...">.
20
- // The known:// scheme lifecycle is taught in instructions-user.md.
19
+ // Written via <set path="known://...">; lifecycle in instructions-user.md.
21
20
  core.markHidden();
22
21
  }
23
22
 
@@ -83,17 +82,11 @@ export default class Known {
83
82
  return entry.body;
84
83
  }
85
84
 
86
- // Summarized: first SUMMARY_MAX_CHARS of the body. The model already
87
- // knows summarized data is approximate (taught in instructions), so
88
- // we don't owe it a "[truncated]" marker that would push the body
89
- // past the contract floor.
90
85
  summary(entry) {
91
86
  if (!entry.body) return "";
92
87
  return entry.body.slice(0, SUMMARY_MAX_CHARS);
93
88
  }
94
89
 
95
- // Identity-keyed summary lines: every data entry the run is tracking
96
- // at visibility=visible or visibility=summarized.
97
90
  async assembleSummarized(content, ctx) {
98
91
  const entries = ctx.rows.filter(
99
92
  (r) =>
@@ -94,11 +94,19 @@ function renderLogTag(entry, rowsByPath) {
94
94
 
95
95
  const meta = { action };
96
96
  if (attrs?.path) meta.target = attrs.path;
97
- // Suppress status on prompts; uniform 200 carries no signal.
98
97
  if (statusValue != null && action !== "prompt") meta.status = statusValue;
99
98
  if (entry.outcome) meta.outcome = entry.outcome;
100
99
  if (typeof attrs?.query === "string") meta.query = attrs.query;
101
100
  if (typeof attrs?.command === "string") meta.command = attrs.command;
101
+ if (typeof attrs?.from === "string") meta.from = attrs.from;
102
+ if (typeof attrs?.to === "string") meta.to = attrs.to;
103
+ if (typeof attrs?.question === "string") meta.question = attrs.question;
104
+ if (typeof attrs?.answer === "string") meta.answer = attrs.answer;
105
+ if (attrs?.line != null) meta.line = attrs.line;
106
+ if (attrs?.limit != null) meta.limit = attrs.limit;
107
+ if (attrs?.manifest !== undefined) meta.manifest = true;
108
+ if (attrs?.channel === 1) meta.channel = "stdout";
109
+ else if (attrs?.channel === 2) meta.channel = "stderr";
102
110
  if (typeof attrs?.tags === "string") meta.tags = attrs.tags.slice(0, 80);
103
111
  if (isSlice) {
104
112
  meta.lines = `${attrs.lineStart}-${attrs.lineEnd}/${attrs.totalLines}`;
@@ -106,6 +114,12 @@ function renderLogTag(entry, rowsByPath) {
106
114
  meta.lines = lineSource;
107
115
  }
108
116
  if (tokenSource != null) meta.tokens = tokenSource;
117
+ if (attrs?.beforeActionTokens != null) {
118
+ meta.beforeActionTokens = attrs.beforeActionTokens;
119
+ }
120
+ if (attrs?.afterActionTokens != null) {
121
+ meta.afterActionTokens = attrs.afterActionTokens;
122
+ }
109
123
 
110
124
  return renderEntry(entry.path, meta, projectedBody(entry));
111
125
  }
@@ -1,5 +1,10 @@
1
1
  import Entries from "../../agent/Entries.js";
2
- import { storePatternResult } from "../helpers.js";
2
+ import { countTokens } from "../../agent/tokens.js";
3
+ import {
4
+ projectEmission,
5
+ storePatternResult,
6
+ summarizeEmission,
7
+ } from "../helpers.js";
3
8
  import docs from "./mvDoc.js";
4
9
 
5
10
  const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
@@ -35,7 +40,6 @@ export default class Mv {
35
40
  ? entry.attributes.visibility
36
41
  : undefined;
37
42
 
38
- // Manifest: list what would be affected without performing the mv.
39
43
  if (entry.attributes.manifest !== undefined) {
40
44
  const matches = await store.getEntriesByPattern(runId, path);
41
45
  await storePatternResult(store, runId, turn, "mv", path, null, matches, {
@@ -46,7 +50,7 @@ export default class Mv {
46
50
  return;
47
51
  }
48
52
 
49
- // Visibility-in-place: no destination, change visibility of matched entries
53
+ // Visibility-in-place: no destination, change visibility of matches.
50
54
  if (visibility && !to) {
51
55
  const matches = await store.getEntriesByPattern(runId, path);
52
56
  for (const match of matches)
@@ -70,9 +74,7 @@ export default class Mv {
70
74
 
71
75
  const source = await store.getBody(runId, path);
72
76
  if (source === null) return;
73
- // Tags propagate: explicit `tags=` on the mv wins; otherwise the
74
- // destination inherits the source entry's tags. Same shape as
75
- // visibility — explicit attr overrides, default inherits.
77
+ // Tags: explicit attr wins; otherwise destination inherits source's.
76
78
  let destTags = null;
77
79
  if (typeof entry.attributes.tags === "string") {
78
80
  destTags = entry.attributes.tags;
@@ -90,19 +92,18 @@ export default class Mv {
90
92
  ? `Overwrote existing entry at ${to}`
91
93
  : null;
92
94
 
93
- const body = `${path} ${to}`;
95
+ const sourceTokens = countTokens(source);
96
+ const destOldTokens = existing !== null ? countTokens(existing) : 0;
97
+ const beforeTokens = sourceTokens + destOldTokens;
98
+ const afterTokens = sourceTokens;
99
+
94
100
  if (destScheme === null) {
95
- // Bare-file destination: hand the shared materializer (set.js
96
- // #materializeFile, gated on attrs.path + attrs.patched) the
97
- // authoritative new body so it writes the source content to
98
- // disk on accept. Without this the source rm fired but the
99
- // destination was never created. Same shape as cp's bare-file
100
- // branch.
101
+ // Bare-file: hand the shared set.js materializer attrs.patched.
101
102
  await store.set({
102
103
  runId,
103
104
  turn,
104
105
  path: entry.resultPath,
105
- body,
106
+ body: "",
106
107
  state: "proposed",
107
108
  attributes: {
108
109
  from: path,
@@ -112,6 +113,8 @@ export default class Mv {
112
113
  path: to,
113
114
  patched: source,
114
115
  visibility,
116
+ beforeActionTokens: beforeTokens,
117
+ afterActionTokens: afterTokens,
115
118
  },
116
119
  loopId,
117
120
  });
@@ -131,19 +134,26 @@ export default class Mv {
131
134
  runId,
132
135
  turn,
133
136
  path: entry.resultPath,
134
- body,
137
+ body: "",
135
138
  state: "resolved",
136
- attributes: { from: path, to, isMove: true, warning },
139
+ attributes: {
140
+ from: path,
141
+ to,
142
+ isMove: true,
143
+ warning,
144
+ beforeActionTokens: beforeTokens,
145
+ afterActionTokens: afterTokens,
146
+ },
137
147
  loopId,
138
148
  });
139
149
  }
140
150
  }
141
151
 
142
152
  full(entry) {
143
- return `# mv ${entry.attributes.from} ${entry.attributes.to}`;
153
+ return projectEmission(entry.body);
144
154
  }
145
155
 
146
- summary() {
147
- return "";
156
+ summary(entry) {
157
+ return summarizeEmission(entry.body);
148
158
  }
149
159
  }
@@ -3,13 +3,13 @@ export default class Persona {
3
3
  core.registerScheme({ name: "persona", category: "data" });
4
4
  core.hooks.tools.onView("persona", (entry) => entry.body, "visible");
5
5
  core.hooks.tools.onView("persona", () => "", "summarized");
6
- // assembly.system @ 150last system-prompt section. Body comes
7
- // from runs.persona, plumbed in via ctx.persona by TurnExecutor.
8
- core.filter("assembly.system", this.assembleSystemPersona.bind(this), 150);
6
+ // assembly.user @ 10top of the user message. Sets voice/role
7
+ // freshly per turn, ahead of the prompt. Body from ctx.persona.
8
+ core.filter("assembly.user", this.assembleSystemPersona.bind(this), 10);
9
9
  }
10
10
 
11
11
  assembleSystemPersona(content, ctx) {
12
12
  if (!ctx.persona) return content;
13
- return `${content}\n\n## Operational Persona\n\n${ctx.persona}`;
13
+ return `${content}<system_instructions>\n${ctx.persona}\n</system_instructions>\n`;
14
14
  }
15
15
  }