@possumtech/rummy 0.5.0 → 2.0.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.
Files changed (153) hide show
  1. package/.env.example +21 -5
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +850 -373
  5. package/bin/demo.js +166 -0
  6. package/bin/rummy.js +9 -3
  7. package/biome/no-fallbacks.grit +50 -0
  8. package/lang/en.json +2 -2
  9. package/migrations/001_initial_schema.sql +88 -37
  10. package/package.json +6 -4
  11. package/service.js +50 -9
  12. package/src/agent/AgentLoop.js +460 -330
  13. package/src/agent/ContextAssembler.js +4 -4
  14. package/src/agent/Entries.js +655 -0
  15. package/src/agent/ProjectAgent.js +30 -18
  16. package/src/agent/TurnExecutor.js +229 -421
  17. package/src/agent/XmlParser.js +99 -33
  18. package/src/agent/budget.js +56 -0
  19. package/src/agent/errors.js +22 -0
  20. package/src/agent/httpStatus.js +39 -0
  21. package/src/agent/known_checks.sql +8 -4
  22. package/src/agent/known_queries.sql +9 -13
  23. package/src/agent/known_store.sql +275 -125
  24. package/src/agent/materializeContext.js +102 -0
  25. package/src/agent/runs.sql +10 -7
  26. package/src/agent/schemes.sql +14 -3
  27. package/src/agent/turns.sql +9 -9
  28. package/src/hooks/HookRegistry.js +6 -5
  29. package/src/hooks/Hooks.js +44 -3
  30. package/src/hooks/PluginContext.js +29 -21
  31. package/src/{server → hooks}/RpcRegistry.js +2 -1
  32. package/src/hooks/RummyContext.js +135 -35
  33. package/src/hooks/ToolRegistry.js +21 -16
  34. package/src/llm/LlmProvider.js +64 -90
  35. package/src/llm/errors.js +21 -0
  36. package/src/plugins/ask_user/README.md +1 -1
  37. package/src/plugins/ask_user/ask_user.js +37 -12
  38. package/src/plugins/ask_user/ask_userDoc.js +2 -25
  39. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  40. package/src/plugins/budget/README.md +27 -25
  41. package/src/plugins/budget/budget.js +260 -88
  42. package/src/plugins/cp/README.md +2 -2
  43. package/src/plugins/cp/cp.js +29 -11
  44. package/src/plugins/cp/cpDoc.js +2 -15
  45. package/src/plugins/cp/cpDoc.md +7 -0
  46. package/src/plugins/engine/README.md +2 -2
  47. package/src/plugins/engine/engine.sql +4 -4
  48. package/src/plugins/engine/turn_context.sql +10 -10
  49. package/src/plugins/env/README.md +20 -5
  50. package/src/plugins/env/env.js +45 -6
  51. package/src/plugins/env/envDoc.js +2 -23
  52. package/src/plugins/env/envDoc.md +13 -0
  53. package/src/plugins/error/README.md +16 -0
  54. package/src/plugins/error/error.js +151 -0
  55. package/src/plugins/file/README.md +6 -6
  56. package/src/plugins/file/file.js +15 -2
  57. package/src/plugins/get/README.md +1 -1
  58. package/src/plugins/get/get.js +103 -48
  59. package/src/plugins/get/getDoc.js +2 -32
  60. package/src/plugins/get/getDoc.md +36 -0
  61. package/src/plugins/hedberg/README.md +1 -2
  62. package/src/plugins/hedberg/hedberg.js +8 -4
  63. package/src/plugins/hedberg/matcher.js +16 -17
  64. package/src/plugins/hedberg/normalize.js +0 -48
  65. package/src/plugins/helpers.js +42 -2
  66. package/src/plugins/index.js +146 -123
  67. package/src/plugins/instructions/README.md +35 -9
  68. package/src/plugins/instructions/instructions.js +122 -9
  69. package/src/plugins/instructions/instructions.md +25 -0
  70. package/src/plugins/instructions/instructions_104.md +7 -0
  71. package/src/plugins/instructions/instructions_105.md +46 -0
  72. package/src/plugins/instructions/instructions_106.md +0 -0
  73. package/src/plugins/instructions/instructions_107.md +0 -0
  74. package/src/plugins/instructions/instructions_108.md +8 -0
  75. package/src/plugins/instructions/protocol.js +12 -0
  76. package/src/plugins/known/README.md +2 -2
  77. package/src/plugins/known/known.js +67 -36
  78. package/src/plugins/known/knownDoc.js +2 -17
  79. package/src/plugins/known/knownDoc.md +8 -0
  80. package/src/plugins/log/README.md +48 -0
  81. package/src/plugins/log/log.js +109 -0
  82. package/src/plugins/mv/README.md +2 -2
  83. package/src/plugins/mv/mv.js +55 -22
  84. package/src/plugins/mv/mvDoc.js +2 -18
  85. package/src/plugins/mv/mvDoc.md +10 -0
  86. package/src/plugins/ollama/README.md +15 -0
  87. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  88. package/src/plugins/openai/README.md +17 -0
  89. package/src/plugins/openai/openai.js +120 -0
  90. package/src/plugins/openrouter/README.md +27 -0
  91. package/src/plugins/openrouter/openrouter.js +121 -0
  92. package/src/plugins/persona/README.md +20 -0
  93. package/src/plugins/persona/persona.js +9 -16
  94. package/src/plugins/policy/README.md +21 -0
  95. package/src/plugins/policy/policy.js +29 -14
  96. package/src/plugins/prompt/README.md +1 -1
  97. package/src/plugins/prompt/prompt.js +58 -16
  98. package/src/plugins/rm/README.md +1 -1
  99. package/src/plugins/rm/rm.js +56 -12
  100. package/src/plugins/rm/rmDoc.js +2 -20
  101. package/src/plugins/rm/rmDoc.md +13 -0
  102. package/src/plugins/rpc/README.md +2 -2
  103. package/src/plugins/rpc/rpc.js +515 -296
  104. package/src/plugins/set/README.md +1 -1
  105. package/src/plugins/set/set.js +318 -75
  106. package/src/plugins/set/setDoc.js +2 -35
  107. package/src/plugins/set/setDoc.md +22 -0
  108. package/src/plugins/sh/README.md +28 -5
  109. package/src/plugins/sh/sh.js +50 -6
  110. package/src/plugins/sh/shDoc.js +2 -23
  111. package/src/plugins/sh/shDoc.md +13 -0
  112. package/src/plugins/skill/README.md +23 -0
  113. package/src/plugins/skill/skill.js +14 -18
  114. package/src/plugins/stream/README.md +101 -0
  115. package/src/plugins/stream/stream.js +290 -0
  116. package/src/plugins/telemetry/README.md +1 -1
  117. package/src/plugins/telemetry/telemetry.js +129 -80
  118. package/src/plugins/think/README.md +1 -1
  119. package/src/plugins/think/think.js +12 -0
  120. package/src/plugins/think/thinkDoc.js +2 -15
  121. package/src/plugins/think/thinkDoc.md +7 -0
  122. package/src/plugins/unknown/README.md +3 -3
  123. package/src/plugins/unknown/unknown.js +47 -19
  124. package/src/plugins/unknown/unknownDoc.js +2 -21
  125. package/src/plugins/unknown/unknownDoc.md +11 -0
  126. package/src/plugins/update/README.md +1 -1
  127. package/src/plugins/update/update.js +67 -5
  128. package/src/plugins/update/updateDoc.js +2 -30
  129. package/src/plugins/update/updateDoc.md +8 -0
  130. package/src/plugins/xai/README.md +23 -0
  131. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  132. package/src/server/ClientConnection.js +64 -37
  133. package/src/server/SocketServer.js +23 -10
  134. package/src/server/protocol.js +11 -0
  135. package/src/sql/v_model_context.sql +27 -31
  136. package/src/sql/v_run_log.sql +9 -14
  137. package/EXCEPTIONS.md +0 -46
  138. package/FIDELITY_CONTRACT.md +0 -172
  139. package/src/agent/KnownStore.js +0 -337
  140. package/src/agent/ResponseHealer.js +0 -241
  141. package/src/llm/OpenAiClient.js +0 -100
  142. package/src/llm/OpenRouterClient.js +0 -100
  143. package/src/plugins/budget/recovery.js +0 -47
  144. package/src/plugins/instructions/preamble.md +0 -45
  145. package/src/plugins/performed/README.md +0 -15
  146. package/src/plugins/performed/performed.js +0 -45
  147. package/src/plugins/previous/README.md +0 -16
  148. package/src/plugins/previous/previous.js +0 -56
  149. package/src/plugins/progress/README.md +0 -16
  150. package/src/plugins/progress/progress.js +0 -43
  151. package/src/plugins/summarize/README.md +0 -19
  152. package/src/plugins/summarize/summarize.js +0 -32
  153. package/src/plugins/summarize/summarizeDoc.js +0 -27
@@ -1,30 +1,69 @@
1
+ import { logPathToDataBase } from "../helpers.js";
1
2
  import docs from "./envDoc.js";
2
3
 
4
+ const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
5
+
3
6
  export default class Env {
4
7
  #core;
5
8
 
6
9
  constructor(core) {
7
10
  this.#core = core;
8
- core.registerScheme();
11
+ // `env` scheme holds the streamed stdout/stderr payload. See sh.js
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).
14
+ core.registerScheme({ category: "data" });
9
15
  core.on("handler", this.handler.bind(this));
10
- core.on("promoted", this.full.bind(this));
11
- core.on("demoted", this.summary.bind(this));
16
+ core.on("visible", this.full.bind(this));
17
+ core.on("summarized", this.summary.bind(this));
12
18
  core.filter("instructions.toolDocs", async (docsMap) => {
13
19
  docsMap.env = docs;
14
20
  return docsMap;
15
21
  });
22
+ core.on("proposal.accepted", this.#onAccepted.bind(this));
23
+ }
24
+
25
+ async #onAccepted(ctx) {
26
+ const m = LOG_ACTION_RE.exec(ctx.path);
27
+ if (m?.[1] !== "env") return;
28
+ let command = "";
29
+ if (ctx.attrs?.command) command = ctx.attrs.command;
30
+ else if (ctx.attrs?.summary) command = ctx.attrs.summary;
31
+ const turn = (await ctx.db.get_run_by_id.get({ id: ctx.runId })).next_turn;
32
+ const dataBase = logPathToDataBase(ctx.path);
33
+ for (const ch of [1, 2]) {
34
+ await ctx.entries.set({
35
+ runId: ctx.runId,
36
+ turn,
37
+ path: `${dataBase}_${ch}`,
38
+ body: "",
39
+ state: "streaming",
40
+ visibility: "summarized",
41
+ attributes: { command, summary: command, channel: ch },
42
+ });
43
+ }
44
+ await ctx.entries.set({
45
+ runId: ctx.runId,
46
+ path: ctx.path,
47
+ state: "resolved",
48
+ body: `ran '${command}' (in progress). Output: ${dataBase}_1, ${dataBase}_2`,
49
+ });
16
50
  }
17
51
 
18
52
  async handler(entry, rummy) {
19
53
  const { entries: store, sequence: turn, runId, loopId } = rummy;
20
- await store.upsert(runId, turn, entry.resultPath, entry.body, 202, {
21
- attributes: entry.attributes,
54
+ await store.set({
55
+ runId,
56
+ turn,
57
+ path: entry.resultPath,
58
+ body: "",
59
+ state: "proposed",
60
+ attributes: { ...entry.attributes, summary: entry.attributes.command },
22
61
  loopId,
23
62
  });
24
63
  }
25
64
 
26
65
  full(entry) {
27
- return `# env ${entry.attributes.command || ""}\n${entry.body}`;
66
+ return `# env ${entry.attributes.command}\n${entry.body}`;
28
67
  }
29
68
 
30
69
  summary() {
@@ -1,24 +1,3 @@
1
- // Tool doc for <env>. Each entry: [text, rationale].
2
- // Text goes to the model. Rationale stays in source.
3
- // Changing ANY line requires reading ALL rationales first.
4
- const LINES = [
5
- ["## <env>[command]</env> - Run an exploratory shell command"],
6
- [
7
- "Example: <env>npm --version</env>",
8
- "Version check. Safe, no side effects.",
9
- ],
10
- [
11
- "Example: <env>git log --oneline -5</env>",
12
- "Git history. Shows env for read-only investigation.",
13
- ],
14
- [
15
- '* YOU MUST NOT use <env></env> to read or list files — use <get path="*"/> instead',
16
- "Prevents cat/ls through shell. Forces file access through get.",
17
- ],
18
- [
19
- "* YOU MUST NOT use <env></env> for commands with side effects",
20
- "Separates exploration from action. env = observe only.",
21
- ],
22
- ];
1
+ import { loadDoc } from "../helpers.js";
23
2
 
24
- export default LINES.map(([text]) => text).join("\n");
3
+ export default loadDoc(import.meta.url, "envDoc.md");
@@ -0,0 +1,13 @@
1
+ ## <env>[command]</env> - Run an exploratory shell command
2
+
3
+ Example: <env>npm --version</env>
4
+ <!-- Version check. Safe, no side effects. -->
5
+
6
+ Example: <env>git log --oneline -5</env>
7
+ <!-- Git history. Shows env for read-only investigation. -->
8
+
9
+ * YOU MUST NOT use <env></env> to read or list files — use <get path="*"/> instead
10
+ <!-- Prevents cat/ls through shell. Forces file access through get. -->
11
+
12
+ * YOU MUST NOT use <env></env> for commands with side effects
13
+ <!-- Separates exploration from action. env = observe only. -->
@@ -0,0 +1,16 @@
1
+ # error {#error_plugin}
2
+
3
+ Subscribes to `error.log` hook and writes `error://` entries for any
4
+ runtime error a plugin or the turn executor wants surfaced to the
5
+ model.
6
+
7
+ ## Registration
8
+
9
+ - **Scheme**: `error` (category: `logging`)
10
+ - **Hook subscriber**: `error.log` → writes entry at `error://<slug>`
11
+ with `state: "failed"`, `outcome: "validation"`.
12
+
13
+ ## Projection
14
+
15
+ - **Promoted**: `# error\n{body}`
16
+ - **Demoted**: body only.
@@ -0,0 +1,151 @@
1
+ const MAX_STRIKES = Number(process.env.RUMMY_MAX_STRIKES);
2
+ const MIN_CYCLES = Number(process.env.RUMMY_MIN_CYCLES);
3
+ const MAX_CYCLE_PERIOD = Number(process.env.RUMMY_MAX_CYCLE_PERIOD);
4
+
5
+ const CONTRACT_REMINDER = "Missing update";
6
+
7
+ function fingerprint(entry) {
8
+ const parts = Object.keys(entry.attributes)
9
+ .toSorted()
10
+ .filter((k) => entry.attributes[k] != null)
11
+ .map((k) => `${k}=${entry.attributes[k]}`);
12
+ return `${entry.scheme}:${parts.join(",")}`;
13
+ }
14
+
15
+ function detectCycle(history) {
16
+ for (let k = 1; k <= MAX_CYCLE_PERIOD; k++) {
17
+ const needed = k * MIN_CYCLES;
18
+ if (history.length < needed) continue;
19
+ const tail = history.slice(-needed);
20
+ const cycle = tail.slice(0, k);
21
+ let match = true;
22
+ outer: for (let rep = 0; rep < MIN_CYCLES; rep++) {
23
+ for (let j = 0; j < k; j++) {
24
+ if (tail[rep * k + j] !== cycle[j]) {
25
+ match = false;
26
+ break outer;
27
+ }
28
+ }
29
+ }
30
+ if (match) return { detected: true, period: k, cycles: MIN_CYCLES };
31
+ }
32
+ return { detected: false };
33
+ }
34
+
35
+ export default class ErrorPlugin {
36
+ #core;
37
+ #loopState = new Map();
38
+
39
+ constructor(core) {
40
+ this.#core = core;
41
+ core.registerScheme({ category: "logging" });
42
+ core.on("visible", (entry) => `# error\n${entry.body}`);
43
+ core.on("summarized", (entry) => entry.body);
44
+
45
+ core.hooks.error.log.on(this.#onErrorLog.bind(this));
46
+ core.hooks.loop.started.on(this.#onLoopStarted.bind(this));
47
+ core.hooks.loop.completed.on(this.#onLoopCompleted.bind(this));
48
+ core.hooks.turn.started.on(this.#onTurnStarted.bind(this));
49
+
50
+ core.hooks.error.verdict = this.#verdict.bind(this);
51
+ }
52
+
53
+ #onLoopStarted({ loopId }) {
54
+ this.#loopState.set(loopId, { streak: 0, history: [], turnErrors: 0 });
55
+ }
56
+
57
+ #onLoopCompleted({ loopId }) {
58
+ this.#loopState.delete(loopId);
59
+ }
60
+
61
+ #onTurnStarted({ rummy }) {
62
+ const state = this.#loopState.get(rummy.loopId);
63
+ state.turnErrors = 0;
64
+ }
65
+
66
+ async #onErrorLog({
67
+ store,
68
+ runId,
69
+ turn,
70
+ loopId,
71
+ message,
72
+ status,
73
+ attributes,
74
+ }) {
75
+ const statusValue = status ?? 400;
76
+ const path = await store.logPath(runId, turn, "error", message);
77
+ await store.set({
78
+ runId,
79
+ turn,
80
+ path,
81
+ body: message,
82
+ state: "failed",
83
+ outcome: `status:${statusValue}`,
84
+ loopId,
85
+ attributes: { ...attributes, status: statusValue },
86
+ });
87
+ const state = this.#loopState.get(loopId);
88
+ if (state) state.turnErrors++;
89
+ }
90
+
91
+ async #verdict({ store, runId, loopId, turn, recorded, summaryText }) {
92
+ const state = this.#loopState.get(loopId);
93
+
94
+ let cycleReason = null;
95
+ if (recorded && recorded.length > 0) {
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
+ await this.#core.hooks.error.log.emit({
102
+ store,
103
+ runId,
104
+ turn,
105
+ loopId,
106
+ message: cycleReason,
107
+ status: 429,
108
+ });
109
+ }
110
+ }
111
+
112
+ const struck = state.turnErrors > 0;
113
+
114
+ if (summaryText && !struck) {
115
+ state.streak = 0;
116
+ const updateEntry = recorded?.findLast?.((e) => e.scheme === "update");
117
+ const terminalStatus = updateEntry?.attributes?.status ?? 200;
118
+ return { continue: false, status: terminalStatus };
119
+ }
120
+
121
+ if (struck) {
122
+ state.streak++;
123
+ if (state.streak >= MAX_STRIKES) {
124
+ // On the abandoning strike, a same-turn terminal update
125
+ // is honored as completion rather than overridden by 499.
126
+ if (summaryText) {
127
+ state.streak = 0;
128
+ const updateEntry = recorded?.findLast?.(
129
+ (e) => e.scheme === "update",
130
+ );
131
+ const terminalStatus = updateEntry?.attributes?.status ?? 200;
132
+ return { continue: false, status: terminalStatus };
133
+ }
134
+ return {
135
+ continue: false,
136
+ status: 499,
137
+ reason:
138
+ cycleReason ||
139
+ `Abandoned after ${state.streak} consecutive strikes.`,
140
+ };
141
+ }
142
+ return {
143
+ continue: true,
144
+ reason: cycleReason || CONTRACT_REMINDER,
145
+ };
146
+ }
147
+
148
+ state.streak = 0;
149
+ return { continue: true };
150
+ }
151
+ }
@@ -1,4 +1,4 @@
1
- # file
1
+ # file {#file_plugin}
2
2
 
3
3
  Owns file-related projections and file constraint management.
4
4
 
@@ -13,10 +13,10 @@ Owns file-related projections and file constraint management.
13
13
 
14
14
  Static methods `setConstraint` and `dropConstraint` manage per-project
15
15
  file constraints in the database. Constraints are project-level config
16
- (backbone), not tool dispatch. See SPEC.md §2.3.
16
+ (backbone), not tool dispatch. See [file_constraints](../../../SPEC.md#file_constraints).
17
17
 
18
- - `active` / `readonly` — promoted into context.
19
- - `ignore` — excluded from scans; demotes existing entries.
18
+ - `active` / `readonly` — promoted into context (visibility=visible).
19
+ - `ignore` — excluded from scans; summarizes existing entries.
20
20
 
21
- Entry promotion/demotion from constraints goes through the standard
22
- tool handler chain via `dispatchTool`.
21
+ Promotion/demotion from constraints goes through the standard tool
22
+ handler chain via `dispatchTool`.
@@ -16,8 +16,8 @@ export default class File {
16
16
  this.#core = core;
17
17
  // "file" scheme covers bare paths (scheme IS NULL in DB)
18
18
  core.registerScheme({ category: "data" });
19
- core.on("promoted", this.full.bind(this));
20
- core.on("demoted", this.summary.bind(this));
19
+ core.on("visible", this.full.bind(this));
20
+ core.on("summarized", this.summary.bind(this));
21
21
  }
22
22
 
23
23
  full(entry) {
@@ -59,6 +59,19 @@ export default class File {
59
59
 
60
60
  return path;
61
61
  }
62
+
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
+ */
68
+ static async isReadonly(db, projectId, path) {
69
+ const rows = await db.get_file_constraints.all({ project_id: projectId });
70
+ const { hedmatch } = await import("./../hedberg/patterns.js");
71
+ return rows.some(
72
+ (r) => r.visibility === "readonly" && hedmatch(r.pattern, path),
73
+ );
74
+ }
62
75
  }
63
76
 
64
77
  async function normalizePath(db, projectId, path) {
@@ -1,4 +1,4 @@
1
- # get
1
+ # get {#get_plugin}
2
2
 
3
3
  Retrieves and promotes entries by path or glob pattern.
4
4
 
@@ -1,4 +1,4 @@
1
- import KnownStore from "../../agent/KnownStore.js";
1
+ import Entries from "../../agent/Entries.js";
2
2
  import { storePatternResult } from "../helpers.js";
3
3
  import docs from "./getDoc.js";
4
4
 
@@ -9,8 +9,8 @@ export default class Get {
9
9
  this.#core = core;
10
10
  core.registerScheme();
11
11
  core.on("handler", this.handler.bind(this));
12
- core.on("promoted", this.full.bind(this));
13
- core.on("demoted", this.summary.bind(this));
12
+ core.on("visible", this.full.bind(this));
13
+ core.on("summarized", this.summary.bind(this));
14
14
  core.filter("instructions.toolDocs", async (docsMap) => {
15
15
  docsMap.get = docs;
16
16
  return docsMap;
@@ -21,21 +21,34 @@ 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
- await store.upsert(runId, turn, entry.resultPath, "", 400, {
25
- attributes: { error: "path is required" },
24
+ // Route through the unified error channel so the message lands
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,
31
+ runId,
32
+ turn,
26
33
  loopId,
34
+ message:
35
+ 'Missing required "path" attribute on <get>. Use <get path="..."/>.',
36
+ status: 400,
27
37
  });
28
38
  return;
29
39
  }
30
- const normalized = KnownStore.normalizePath(target);
31
- const bodyFilter = entry.attributes.body || null;
40
+ const normalized = Entries.normalizePath(target);
41
+ // XmlParser passes attributes through; `body` attr is optional.
42
+ const bodyFilter = entry.attributes.body;
32
43
  const preview = entry.attributes.preview !== undefined;
33
44
  const isPattern = bodyFilter || normalized.includes("*");
34
45
 
35
- const line =
36
- entry.attributes.line != null
37
- ? Math.max(1, parseInt(entry.attributes.line, 10))
38
- : null;
46
+ // Negative `line` is idiomatic tail-from-end: `line="-50"` means
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.
50
+ const lineRaw = entry.attributes.line;
51
+ const line = lineRaw != null ? parseInt(lineRaw, 10) : null;
39
52
  const limit =
40
53
  entry.attributes.limit != null
41
54
  ? Math.max(1, parseInt(entry.attributes.limit, 10))
@@ -48,7 +61,7 @@ export default class Get {
48
61
  );
49
62
 
50
63
  // Preview — list matches with their full-body token costs. No promotion,
51
- // no fidelity change, no Token Budget spent. Model uses this to plan
64
+ // no visibility change, no Token Budget spent. Model uses this to plan
52
65
  // which entries to actually promote. getDoc promises this behavior; the
53
66
  // prior implementation silently promoted anyway, burning the Token Budget
54
67
  // on entries the model thought it was only inspecting.
@@ -66,62 +79,91 @@ export default class Get {
66
79
  return;
67
80
  }
68
81
 
69
- // Partial read — no fidelity promotion, returns a line slice as the log item.
82
+ // Partial read — no visibility promotion, returns a line slice as the log item.
70
83
  if (line !== null || limit !== null) {
71
84
  if (isPattern) {
72
- await store.upsert(
85
+ await store.set({
73
86
  runId,
74
87
  turn,
75
- entry.resultPath,
76
- "line/limit requires a single path, not a glob or body filter",
77
- 400,
78
- { loopId, attributes: { path: target } },
79
- );
88
+ path: entry.resultPath,
89
+ body: "line/limit requires a single path, not a glob or body filter",
90
+ state: "failed",
91
+ outcome: "validation",
92
+ loopId,
93
+ attributes: { path: target },
94
+ });
80
95
  return;
81
96
  }
82
97
  if (matches.length === 0) {
83
- await store.upsert(
98
+ await store.set({
84
99
  runId,
85
100
  turn,
86
- entry.resultPath,
87
- `${target} not found`,
88
- 200,
89
- { loopId, attributes: { path: target } },
90
- );
101
+ path: entry.resultPath,
102
+ body: `${target} not found`,
103
+ state: "resolved",
104
+ loopId,
105
+ attributes: { path: target },
106
+ });
91
107
  return;
92
108
  }
93
109
  const allLines = matches[0].body.split("\n");
94
110
  const total = allLines.length;
95
- const startLine = line ?? 1;
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
+ const startLine =
114
+ line == null
115
+ ? 1
116
+ : line < 0
117
+ ? Math.max(1, total + line + 1)
118
+ : Math.max(1, line);
96
119
  const startIdx = startLine - 1;
97
120
  const endIdx = limit !== null ? Math.min(startIdx + limit, total) : total;
98
121
  const slice = allLines.slice(startIdx, endIdx).join("\n");
99
122
  const endLine = endIdx;
100
- const header = `[lines ${startLine}–${endLine} / ${total} total]`;
101
- await store.upsert(
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
+ const header = `${target}\n[lines ${startLine}–${endLine} / ${total} total]`;
128
+ await store.set({
102
129
  runId,
103
130
  turn,
104
- entry.resultPath,
105
- `${header}\n${slice}`,
106
- 200,
107
- { loopId, attributes: { path: target } },
108
- );
131
+ path: entry.resultPath,
132
+ body: `${header}\n${slice}`,
133
+ state: "resolved",
134
+ loopId,
135
+ attributes: {
136
+ path: target,
137
+ lineStart: startLine,
138
+ lineEnd: endLine,
139
+ totalLines: total,
140
+ },
141
+ });
109
142
  return;
110
143
  }
111
144
 
112
- const VALID_FIDELITY = {
113
- demoted: 1,
114
- promoted: 1,
145
+ const VALID_VISIBILITY = {
146
+ summarized: 1,
147
+ visible: 1,
115
148
  archived: 1,
116
149
  };
117
- const fidelityAttr = VALID_FIDELITY[entry.attributes.fidelity]
118
- ? entry.attributes.fidelity
150
+ const visibilityAttr = VALID_VISIBILITY[entry.attributes.visibility]
151
+ ? entry.attributes.visibility
119
152
  : null;
120
153
 
121
- await store.promoteByPattern(runId, normalized, bodyFilter, turn);
122
- if (fidelityAttr) {
154
+ await store.get({
155
+ runId: runId,
156
+ turn: turn,
157
+ path: normalized,
158
+ bodyFilter: bodyFilter,
159
+ });
160
+ if (visibilityAttr) {
123
161
  for (const match of matches)
124
- await store.setFidelity(runId, match.path, fidelityAttr);
162
+ await store.set({
163
+ runId: runId,
164
+ path: match.path,
165
+ visibility: visibilityAttr,
166
+ });
125
167
  }
126
168
 
127
169
  if (isPattern) {
@@ -135,14 +177,27 @@ export default class Get {
135
177
  matches,
136
178
  { loopId, attributes: { path: target } },
137
179
  );
180
+ } else if (matches.length === 0) {
181
+ await store.set({
182
+ runId,
183
+ turn,
184
+ path: entry.resultPath,
185
+ body: `${target} not found`,
186
+ state: "resolved",
187
+ loopId,
188
+ attributes: { path: target },
189
+ });
138
190
  } else {
139
- const total = matches.reduce((s, m) => s + m.tokens, 0);
140
- const paths = matches.map((m) => m.path).join(", ");
141
- const body =
142
- matches.length > 0
143
- ? `${paths} promoted (${total} tokens)`
144
- : `${target} not found`;
145
- await store.upsert(runId, turn, entry.resultPath, body, 200, {
191
+ // Log a concise record of the promotion. The promoted entry
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.
195
+ await store.set({
196
+ runId,
197
+ turn,
198
+ path: entry.resultPath,
199
+ body: `${target} promoted`,
200
+ state: "resolved",
146
201
  loopId,
147
202
  attributes: { path: target },
148
203
  });
@@ -1,33 +1,3 @@
1
- // Tool doc for <get>. Each entry: [text, rationale].
2
- // Text goes to the model. Rationale stays in source.
3
- // Changing ANY line requires reading ALL rationales first.
4
- const LINES = [
5
- ["## <get>[path/to/file]</get> - Promote an entry"],
6
- ["Example: <get>src/app.js</get>", "Simplest form. Body = path."],
7
- [
8
- 'Example: <get path="known://*">auth</get>',
9
- "Keyword recall: glob in path, search term in body.",
10
- ],
11
- [
12
- 'Example: <get path="src/**/*.js">authentication</get>',
13
- "Full pattern: recursive glob + content filter.",
14
- ],
15
- [
16
- 'Example: <get path="src/agent/AgentLoop.js" line="644" limit="80"/>',
17
- "Partial read. Returns lines 644–723 without promoting.",
18
- ],
19
- [
20
- "* Paths accept patterns: `src/**/*.js`, `known://api_*`",
21
- "Reinforces picomatch patterns work everywhere.",
22
- ],
23
- [
24
- "* Body text filters results by content match",
25
- "Body = filter, not just path.",
26
- ],
27
- [
28
- "* `line` and `limit` read a slice without promoting the entry, which costs as many tokens as the slice contains.",
29
- "Partial read is safe: context budget unaffected.",
30
- ],
31
- ];
1
+ import { loadDoc } from "../helpers.js";
32
2
 
33
- export default LINES.map(([text]) => text).join("\n");
3
+ export default loadDoc(import.meta.url, "getDoc.md");
@@ -0,0 +1,36 @@
1
+ ## <get>[path/to/file]</get> - Promote an entry
2
+
3
+ Example: <get>src/app.js</get>
4
+ <!-- Simplest form. Body = path. -->
5
+
6
+ Example: <get path="known://*">auth</get>
7
+ <!-- Keyword recall: glob in path, search term in body. -->
8
+
9
+ Example: <get path="src/**/*.js">authentication</get>
10
+ <!-- Full pattern: recursive glob + content filter. -->
11
+
12
+ Example: <get path="src/**/*.js" preview>authentication</get>
13
+ <!-- Full pattern: recursive glob + content filter. -->
14
+
15
+ Example: <get path="src/agent/AgentLoop.js" line="644" limit="80"/>
16
+ <!-- Partial read. Returns lines 644–723 without promoting. -->
17
+
18
+ Example: <get path="sh://turn_3/npm_test_1" line="-50"/>
19
+ <!-- Tail: negative line reads the last 50 lines. Works on any growing entry — streaming sh output, logs, knowns. -->
20
+
21
+ Example: <get path="https://en.wikipedia.org/wiki/Long_Page" line="1" limit="200"/>
22
+ <!-- URL partial read. When a page is too large to promote whole, read a slice. Pattern generalizes to every scheme. -->
23
+
24
+ * Paths accept patterns: `src/**/*.js`, `known://api_*`
25
+ <!-- Reinforces picomatch patterns work everywhere. -->
26
+
27
+ * Body text filters results by content match (can use glob, regex, jsonpath, or xpath patterns)
28
+ <!-- Body = filter, not just path. -->
29
+
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
+ <!-- Partial read is safe: context budget unaffected. Tail idiom enables watching growing entries. -->
32
+
33
+ * `preview` lists the paths and token budget impact of an operation without performing it.
34
+ <!-- Partial read is safe: context budget unaffected. Tail idiom enables watching growing entries. -->
35
+
36
+ * Remember to <set path="..." visibility="summarize"/> when entries or log events are no longer relevant.
@@ -1,4 +1,4 @@
1
- # hedberg
1
+ # hedberg {#hedberg_plugin}
2
2
 
3
3
  The interpretation boundary between stochastic model output and
4
4
  deterministic system operations.
@@ -26,7 +26,6 @@ constructor(core) {
26
26
  | `replace(body, search, replacement, opts?)` | Apply replacement (sed regex → literal → heuristic) |
27
27
  | `parseSed(input)` | Parse sed syntax into `[{ search, replace, flags, sed }]` |
28
28
  | `parseEdits(content)` | Detect edit format (merge conflict, udiff, Claude XML) |
29
- | `normalizeAttrs(attrs)` | Heal model attribute names (value→body, unknown→path) |
30
29
  | `generatePatch(path, old, new)` | Generate unified diff |
31
30
 
32
31
  ### Hedberg.replace(body, search, replacement, options?)