@possumtech/rummy 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.env.example +40 -15
  2. package/.xai.key +1 -0
  3. package/PLUGINS.md +169 -53
  4. package/README.md +38 -32
  5. package/SPEC.md +366 -179
  6. package/bin/digest.js +1097 -0
  7. package/biome/no-fallbacks.grit +2 -2
  8. package/gemini.key +1 -0
  9. package/lang/en.json +10 -1
  10. package/migrations/001_initial_schema.sql +9 -2
  11. package/package.json +19 -8
  12. package/service.js +1 -0
  13. package/src/agent/AgentLoop.js +76 -26
  14. package/src/agent/ContextAssembler.js +2 -0
  15. package/src/agent/Entries.js +238 -60
  16. package/src/agent/ProjectAgent.js +44 -0
  17. package/src/agent/TurnExecutor.js +99 -30
  18. package/src/agent/XmlParser.js +206 -111
  19. package/src/agent/errors.js +35 -0
  20. package/src/agent/known_queries.sql +1 -1
  21. package/src/agent/known_store.sql +3 -42
  22. package/src/agent/materializeContext.js +30 -1
  23. package/src/agent/runs.sql +8 -18
  24. package/src/agent/tokens.js +0 -1
  25. package/src/agent/turns.sql +1 -0
  26. package/src/hooks/Hooks.js +26 -0
  27. package/src/hooks/RummyContext.js +12 -1
  28. package/src/lib/hedberg/README.md +60 -0
  29. package/src/lib/hedberg/hedberg.js +60 -0
  30. package/src/lib/hedberg/marker.js +158 -0
  31. package/src/{plugins → lib}/hedberg/matcher.js +1 -2
  32. package/src/llm/LlmProvider.js +41 -3
  33. package/src/llm/openaiStream.js +17 -0
  34. package/src/plugins/ask_user/ask_user.js +12 -2
  35. package/src/plugins/ask_user/ask_userDoc.md +1 -5
  36. package/src/plugins/budget/README.md +29 -24
  37. package/src/plugins/budget/budget.js +166 -110
  38. package/src/plugins/cli/README.md +3 -4
  39. package/src/plugins/cli/cli.js +31 -5
  40. package/src/plugins/cloudflare/cloudflare.js +136 -0
  41. package/src/plugins/cp/cp.js +41 -4
  42. package/src/plugins/cp/cpDoc.md +5 -6
  43. package/src/plugins/engine/engine.sql +1 -1
  44. package/src/plugins/env/README.md +5 -4
  45. package/src/plugins/env/env.js +7 -4
  46. package/src/plugins/env/envDoc.md +7 -8
  47. package/src/plugins/error/error.js +56 -15
  48. package/src/plugins/file/README.md +12 -3
  49. package/src/plugins/file/file.js +2 -2
  50. package/src/plugins/get/get.js +59 -36
  51. package/src/plugins/get/getDoc.md +10 -34
  52. package/src/plugins/google/google.js +115 -0
  53. package/src/plugins/hedberg/hedberg.js +13 -56
  54. package/src/plugins/helpers.js +66 -12
  55. package/src/plugins/index.js +1 -2
  56. package/src/plugins/instructions/README.md +44 -47
  57. package/src/plugins/instructions/instructions-system.md +44 -0
  58. package/src/plugins/instructions/instructions-user.md +53 -0
  59. package/src/plugins/instructions/instructions.js +58 -189
  60. package/src/plugins/known/README.md +6 -7
  61. package/src/plugins/known/known.js +24 -30
  62. package/src/plugins/log/log.js +41 -32
  63. package/src/plugins/mv/mv.js +40 -1
  64. package/src/plugins/mv/mvDoc.md +1 -8
  65. package/src/plugins/ollama/ollama.js +4 -3
  66. package/src/plugins/openai/openai.js +4 -3
  67. package/src/plugins/openrouter/openrouter.js +14 -4
  68. package/src/plugins/persona/README.md +11 -13
  69. package/src/plugins/persona/default.md +29 -0
  70. package/src/plugins/persona/persona.js +10 -66
  71. package/src/plugins/policy/policy.js +23 -22
  72. package/src/plugins/prompt/README.md +37 -27
  73. package/src/plugins/prompt/prompt.js +13 -19
  74. package/src/plugins/rm/rm.js +18 -0
  75. package/src/plugins/rm/rmDoc.md +5 -6
  76. package/src/plugins/rpc/rpc.js +3 -3
  77. package/src/plugins/set/set.js +205 -323
  78. package/src/plugins/set/setDoc.md +47 -17
  79. package/src/plugins/sh/README.md +6 -5
  80. package/src/plugins/sh/sh.js +8 -5
  81. package/src/plugins/sh/shDoc.md +7 -8
  82. package/src/plugins/skill/README.md +37 -14
  83. package/src/plugins/skill/skill.js +200 -101
  84. package/src/plugins/skill/skillDoc.js +3 -0
  85. package/src/plugins/skill/skillDoc.md +9 -0
  86. package/src/plugins/stream/README.md +7 -6
  87. package/src/plugins/stream/finalize.js +100 -0
  88. package/src/plugins/stream/stream.js +13 -45
  89. package/src/plugins/telemetry/telemetry.js +27 -4
  90. package/src/plugins/think/think.js +2 -3
  91. package/src/plugins/think/thinkDoc.md +2 -4
  92. package/src/plugins/unknown/README.md +1 -1
  93. package/src/plugins/unknown/unknown.js +17 -19
  94. package/src/plugins/update/update.js +4 -51
  95. package/src/plugins/update/updateDoc.md +21 -6
  96. package/src/plugins/xai/xai.js +68 -102
  97. package/src/plugins/yolo/yolo.js +102 -75
  98. package/src/sql/functions/hedmatch.js +1 -1
  99. package/src/sql/functions/hedreplace.js +1 -1
  100. package/src/sql/functions/hedsearch.js +1 -1
  101. package/src/sql/functions/slugify.js +16 -2
  102. package/BENCH_ENVIRONMENT.md +0 -230
  103. package/CLIENT_INTERFACE.md +0 -396
  104. package/last_run.txt +0 -5617
  105. package/scriptify/ask_run.js +0 -77
  106. package/scriptify/cache_probe.js +0 -66
  107. package/scriptify/cache_probe_grok.js +0 -74
  108. package/src/agent/budget.js +0 -33
  109. package/src/agent/config.js +0 -38
  110. package/src/plugins/hedberg/README.md +0 -71
  111. package/src/plugins/hedberg/docs.md +0 -0
  112. package/src/plugins/hedberg/edits.js +0 -55
  113. package/src/plugins/hedberg/normalize.js +0 -17
  114. package/src/plugins/hedberg/sed.js +0 -49
  115. package/src/plugins/instructions/instructions.md +0 -34
  116. package/src/plugins/instructions/instructions_104.md +0 -8
  117. package/src/plugins/instructions/instructions_105.md +0 -39
  118. package/src/plugins/instructions/instructions_106.md +0 -22
  119. package/src/plugins/instructions/instructions_107.md +0 -17
  120. package/src/plugins/instructions/instructions_108.md +0 -0
  121. package/src/plugins/known/knownDoc.js +0 -3
  122. package/src/plugins/known/knownDoc.md +0 -8
  123. package/src/plugins/unknown/unknownDoc.js +0 -3
  124. package/src/plugins/unknown/unknownDoc.md +0 -11
  125. package/turns/cli_1777462658211/turn_001.txt +0 -772
  126. package/turns/cli_1777462658211/turn_002.txt +0 -606
  127. package/turns/cli_1777462658211/turn_003.txt +0 -667
  128. package/turns/cli_1777462658211/turn_004.txt +0 -297
  129. package/turns/cli_1777462658211/turn_005.txt +0 -301
  130. package/turns/cli_1777462658211/turn_006.txt +0 -262
  131. package/turns/cli_1777465095132/turn_001.txt +0 -715
  132. package/turns/cli_1777465095132/turn_002.txt +0 -236
  133. package/turns/cli_1777465095132/turn_003.txt +0 -287
  134. package/turns/cli_1777465095132/turn_004.txt +0 -694
  135. package/turns/cli_1777465095132/turn_005.txt +0 -422
  136. package/turns/cli_1777465095132/turn_006.txt +0 -365
  137. package/turns/cli_1777465095132/turn_007.txt +0 -885
  138. package/turns/cli_1777465095132/turn_008.txt +0 -1277
  139. package/turns/cli_1777465095132/turn_009.txt +0 -736
  140. /package/src/{plugins → lib}/hedberg/patterns.js +0 -0
@@ -1,4 +1,5 @@
1
1
  import Entries from "../../agent/Entries.js";
2
+ import { storePatternResult } from "../helpers.js";
2
3
  import docs from "./cpDoc.js";
3
4
 
4
5
  export default class Cp {
@@ -24,25 +25,60 @@ export default class Cp {
24
25
  ? entry.attributes.visibility
25
26
  : undefined;
26
27
 
28
+ // Manifest: list what would be copied without performing the cp.
29
+ if (entry.attributes.manifest !== undefined) {
30
+ const matches = await store.getEntriesByPattern(runId, path);
31
+ await storePatternResult(store, runId, turn, "cp", path, null, matches, {
32
+ manifest: true,
33
+ loopId,
34
+ attributes: { path, to },
35
+ });
36
+ return;
37
+ }
38
+
27
39
  const source = await store.getBody(runId, path);
28
40
  if (source === null) return;
41
+ // Tags propagate: explicit `tags=` on the cp wins; otherwise the
42
+ // destination inherits the source entry's tags. Same shape as
43
+ // visibility — explicit attr overrides, default inherits.
44
+ let destTags = null;
45
+ if (typeof entry.attributes.tags === "string") {
46
+ destTags = entry.attributes.tags;
47
+ } else {
48
+ const sourceAttrs = await store.getAttributes(runId, path);
49
+ if (sourceAttrs && typeof sourceAttrs.tags === "string") {
50
+ destTags = sourceAttrs.tags;
51
+ }
52
+ }
29
53
 
30
54
  const destScheme = Entries.scheme(to);
31
55
  const existing = await store.getBody(runId, to);
32
56
  const warning =
33
- existing !== null && destScheme !== null
34
- ? `Overwrote existing entry at ${to}`
35
- : null;
57
+ existing !== null ? `Overwrote existing entry at ${to}` : null;
36
58
 
37
59
  const body = `${path} ${to}`;
38
60
  if (destScheme === null) {
61
+ // Bare-file destination: hand the shared materializer (set.js
62
+ // #materializeFile, gated on attrs.path + attrs.patched) the
63
+ // authoritative new body so it writes the source content to
64
+ // disk on accept. Without this the proposal accepted but no
65
+ // file landed — the model's "<cp src dest> then <set dest>
66
+ // SEARCH/REPLACE" sequence silently no-op'd at materialize.
39
67
  await store.set({
40
68
  runId,
41
69
  turn,
42
70
  path: entry.resultPath,
43
71
  body,
44
72
  state: "proposed",
45
- attributes: { from: path, to, isMove: false, warning },
73
+ attributes: {
74
+ from: path,
75
+ to,
76
+ isMove: false,
77
+ warning,
78
+ path: to,
79
+ patched: source,
80
+ visibility,
81
+ },
46
82
  loopId,
47
83
  });
48
84
  } else {
@@ -53,6 +89,7 @@ export default class Cp {
53
89
  body: source,
54
90
  state: "resolved",
55
91
  visibility,
92
+ attributes: destTags ? { tags: destTags } : null,
56
93
  loopId,
57
94
  });
58
95
  await store.set({
@@ -1,7 +1,6 @@
1
- ## <cp path="[source]">[destination]</cp> - Copy a file or entry
1
+ ## <cp path="[source]">[destination]</cp> - Copy an entry or file
2
2
 
3
- Example: <cp path="src/config.js">src/config.backup.js</cp>
4
- <!-- Simple file copy. Path = source, body = destination. -->
5
-
6
- Example: <cp path="known://plan_*">known://archive_</cp>
7
- <!-- Glob batch copy across known entries. -->
3
+ Example: <cp path="known://server/handler_main">src/main.c</cp>
4
+ <!-- Body is the destination path; cross-scheme copies are allowed. -->
5
+ Example: <cp path="known://countries/france/*">known://archive/countries/france/</cp>
6
+ <!-- Glob source + directory-shaped destination = batch copy preserving names. -->
@@ -1,7 +1,7 @@
1
1
  -- PREP: get_promoted_entries
2
2
  SELECT
3
3
  ke.path, ke.scheme, ke.state, ke.outcome, ke.visibility, ke.turn
4
- , countTokens(ke.body) AS tokens, ke.refs
4
+ , ke.refs, countTokens(ke.body) AS tokens
5
5
  FROM known_entries AS ke
6
6
  JOIN schemes AS s ON s.name = COALESCE(ke.scheme, 'file')
7
7
  WHERE
@@ -8,7 +8,7 @@ side effects.
8
8
  ## Registration
9
9
 
10
10
  - **Tool**: `env`
11
- - **Scheme**: `env` — `category: "data"` (channels only; see below)
11
+ - **Scheme**: `env` — `category: "logging"` (channels are time-indexed activity, not state)
12
12
  - **Handler**: Upserts the proposal entry at status 202 (proposed).
13
13
 
14
14
  ## Two namespaces per invocation
@@ -16,9 +16,10 @@ side effects.
16
16
  - **Log entry**: `log://turn_N/env/{slug}` — scheme=`log`, category=`logging`.
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
- (stderr) — scheme=`env`, category=`data`. The captured payload
20
- (renders inside `<visible>` as `<env>` when promoted; otherwise listed
21
- in `<summarized>`).
19
+ (stderr) — scheme=`env`, category=`logging` (time-indexed activity).
20
+ Render inside `<log>` adjacent to their parent `<env>` action entry;
21
+ visibility controls whether the body is full or compact, not which
22
+ block they appear in.
22
23
 
23
24
  The `env` scheme exists **only** for the data channels. See
24
25
  [scheme_category_split](#scheme_category_split).
@@ -9,7 +9,10 @@ export default class Env {
9
9
  constructor(core) {
10
10
  this.#core = core;
11
11
  // env vs sh: env is read-only (allowed in ask-mode); see plugin README.
12
- core.registerScheme({ category: "data" });
12
+ // Streaming stdout/stderr is time-indexed activity output, not
13
+ // topic-indexed state — category="logging" so it renders in <log>
14
+ // adjacent to its action entry, not in <summary>/<visible>.
15
+ core.registerScheme({ category: "logging" });
13
16
  core.on("handler", this.handler.bind(this));
14
17
  core.on("visible", this.full.bind(this));
15
18
  core.on("summarized", this.summary.bind(this));
@@ -25,7 +28,7 @@ export default class Env {
25
28
  if (m?.[1] !== "env") return;
26
29
  let command = "";
27
30
  if (ctx.attrs?.command) command = ctx.attrs.command;
28
- else if (ctx.attrs?.summary) command = ctx.attrs.summary;
31
+ else if (ctx.attrs?.tags) command = ctx.attrs.tags;
29
32
  const turn = (await ctx.db.get_run_by_id.get({ id: ctx.runId })).next_turn;
30
33
  const dataBase = logPathToDataBase(ctx.path);
31
34
  for (const ch of [1, 2]) {
@@ -36,7 +39,7 @@ export default class Env {
36
39
  body: "",
37
40
  state: "streaming",
38
41
  visibility: "summarized",
39
- attributes: { command, summary: command, channel: ch },
42
+ attributes: { command, tags: command, channel: ch },
40
43
  });
41
44
  }
42
45
  await ctx.entries.set({
@@ -55,7 +58,7 @@ export default class Env {
55
58
  path: entry.resultPath,
56
59
  body: "",
57
60
  state: "proposed",
58
- attributes: { ...entry.attributes, summary: entry.attributes.command },
61
+ attributes: { ...entry.attributes, tags: entry.attributes.command },
59
62
  loopId,
60
63
  });
61
64
  }
@@ -1,13 +1,12 @@
1
1
  ## <env>[command]</env> - Run an exploratory shell command
2
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. -->
3
+ Example:
4
+ <env><<EOF
5
+ npm --version
6
+ node --version
7
+ git log --oneline -3
8
+ EOF</env>
9
+ <!-- Heredoc body is opaque — embed multi-line scripts and special characters without escaping. Output co-locates at env://turn_N/<slug>. -->
8
10
 
9
11
  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
12
  YOU MUST NOT use <env></env> for commands with side effects
13
- <!-- Separates exploration from action. env = observe only. -->
@@ -1,8 +1,9 @@
1
- import config from "../../agent/config.js";
1
+ import { SOFT_FAILURE_OUTCOMES } from "../../agent/errors.js";
2
+ import { SUMMARY_MAX_CHARS } from "../helpers.js";
2
3
 
3
- const { MAX_STRIKES, MIN_CYCLES, MAX_CYCLE_PERIOD } = config;
4
-
5
- const CONTRACT_REMINDER = "Missing update";
4
+ const MAX_STRIKES = Number(process.env.RUMMY_MAX_STRIKES);
5
+ const MIN_CYCLES = Number(process.env.RUMMY_MIN_CYCLES);
6
+ const MAX_CYCLE_PERIOD = Number(process.env.RUMMY_MAX_CYCLE_PERIOD);
6
7
 
7
8
  function fingerprint(entry) {
8
9
  const parts = Object.keys(entry.attributes)
@@ -40,18 +41,27 @@ export default class ErrorPlugin {
40
41
  this.#core = core;
41
42
  core.registerScheme({ category: "logging" });
42
43
  core.on("visible", (entry) => `# error\n${entry.body}`);
43
- core.on("summarized", (entry) => entry.body);
44
+ core.on("summarized", (entry) => entry.body.slice(0, SUMMARY_MAX_CHARS));
44
45
 
45
46
  core.hooks.error.log.on(this.#onErrorLog.bind(this));
46
47
  core.hooks.loop.started.on(this.#onLoopStarted.bind(this));
47
48
  core.hooks.loop.completed.on(this.#onLoopCompleted.bind(this));
48
49
  core.hooks.turn.started.on(this.#onTurnStarted.bind(this));
49
50
 
50
- core.hooks.error.verdict = this.#verdict.bind(this);
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
+ core.filter("turn.verdict", this.#verdict.bind(this));
51
57
  }
52
58
 
53
59
  #onLoopStarted({ loopId }) {
54
- this.#loopState.set(loopId, { streak: 0, history: [], turnErrors: 0 });
60
+ this.#loopState.set(loopId, {
61
+ streak: 0,
62
+ history: [],
63
+ turnErrors: 0,
64
+ });
55
65
  }
56
66
 
57
67
  #onLoopCompleted({ loopId }) {
@@ -71,24 +81,43 @@ export default class ErrorPlugin {
71
81
  message,
72
82
  status,
73
83
  attributes,
84
+ soft,
74
85
  }) {
75
86
  const statusValue = status ?? 400;
76
87
  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.
77
97
  await store.set({
78
98
  runId,
79
99
  turn,
80
100
  path,
81
101
  body: message,
82
- state: "failed",
83
- outcome: `status:${statusValue}`,
102
+ state: soft ? "resolved" : "failed",
103
+ outcome: soft ? null : `status:${statusValue}`,
84
104
  loopId,
85
105
  attributes: { ...attributes, status: statusValue },
86
106
  });
107
+ if (soft) return;
87
108
  const state = this.#loopState.get(loopId);
88
109
  if (state) state.turnErrors++;
89
110
  }
90
111
 
91
- async #verdict({ store, runId, loopId, recorded, summaryText }) {
112
+ async #verdict(
113
+ _currentVerdict,
114
+ { store, runId, loopId, recorded, summaryText, turn: _turn },
115
+ ) {
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.
92
121
  const state = this.#loopState.get(loopId);
93
122
 
94
123
  let cycleReason = null;
@@ -102,10 +131,20 @@ export default class ErrorPlugin {
102
131
  state.turnErrors++;
103
132
  }
104
133
 
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.
105
141
  let recordedFailed = false;
106
142
  for (const e of recorded) {
107
143
  const current = await store.getState(runId, e.path);
108
- if (current?.state === "failed") {
144
+ if (
145
+ current?.state === "failed" &&
146
+ !SOFT_FAILURE_OUTCOMES.has(current.outcome)
147
+ ) {
109
148
  recordedFailed = true;
110
149
  break;
111
150
  }
@@ -139,10 +178,12 @@ export default class ErrorPlugin {
139
178
  `Abandoned after ${state.streak} consecutive strikes.`,
140
179
  };
141
180
  }
142
- return {
143
- continue: true,
144
- reason: CONTRACT_REMINDER,
145
- };
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
+ return { continue: true };
146
187
  }
147
188
 
148
189
  state.streak = 0;
@@ -15,8 +15,17 @@ Static methods `setConstraint` and `dropConstraint` manage per-project
15
15
  file constraints in the database. Constraints are project-level config
16
16
  (backbone), not tool dispatch. See [file_constraints](../../../SPEC.md#file_constraints).
17
17
 
18
- - `active` / `readonly` promoted into context (visibility=visible).
19
- - `ignore` excluded from scans; summarizes existing entries.
18
+ Constraint type governs **membership** and **write permission**, not
19
+ in-context visibility. Visibility (visible / summarized / archived)
20
+ is per-entry and model-controlled — files default to `archived` on
21
+ ingestion; the model promotes via `<get>` / `<set visibility=...>`.
20
22
 
21
- Promotion/demotion from constraints goes through the standard tool
23
+ - `add` file is part of the project; ingested as an entry; model
24
+ may write. Default for `setConstraint`.
25
+ - `readonly` — same ingestion; `<set>` is vetoed at the proposal-
26
+ accept gate.
27
+ - `ignore` — excluded from scans entirely. The file remains on disk
28
+ for `<sh>` / `<env>` invocation but is not present as an entry.
29
+
30
+ Promotion/demotion of an ingested file goes through the standard tool
22
31
  handler chain via `dispatchTool`.
@@ -19,7 +19,7 @@ export default class File {
19
19
  return "";
20
20
  }
21
21
 
22
- static async setConstraint(db, projectId, pattern, visibility = "active") {
22
+ static async setConstraint(db, projectId, pattern, visibility = "add") {
23
23
  const path = await normalizePath(db, projectId, pattern);
24
24
  if (!path) return null;
25
25
 
@@ -47,7 +47,7 @@ export default class File {
47
47
  // True if any readonly constraint matches; called from set-accept gate.
48
48
  static async isReadonly(db, projectId, path) {
49
49
  const rows = await db.get_file_constraints.all({ project_id: projectId });
50
- const { hedmatch } = await import("./../hedberg/patterns.js");
50
+ const { hedmatch } = await import("../../lib/hedberg/patterns.js");
51
51
  return rows.some(
52
52
  (r) => r.visibility === "readonly" && hedmatch(r.pattern, path),
53
53
  );
@@ -19,7 +19,12 @@ export default class Get {
19
19
 
20
20
  async handler(entry, rummy) {
21
21
  const { entries: store, sequence: turn, runId, loopId } = rummy;
22
- const target = entry.attributes.path;
22
+ // Search-by-tags: same `tags` attribute that <set> writes onto
23
+ // entries. Same name on both ends — no in/out semantic split.
24
+ 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.
27
+ const target = entry.attributes.path || (tagsAttr ? "**" : null);
23
28
  if (!target) {
24
29
  await store.set({
25
30
  runId,
@@ -35,7 +40,13 @@ export default class Get {
35
40
  const normalized = Entries.normalizePath(target);
36
41
  const bodyFilter = entry.attributes.body;
37
42
  const manifest = entry.attributes.manifest !== undefined;
38
- const isPattern = bodyFilter || normalized.includes("*");
43
+ const wantedTags = tagsAttr
44
+ ? tagsAttr
45
+ .split(",")
46
+ .map((t) => t.trim().toLowerCase())
47
+ .filter(Boolean)
48
+ : null;
49
+ const isPattern = bodyFilter || normalized.includes("*") || !!wantedTags;
39
50
 
40
51
  // Negative line = tail-from-end (line=-50 starts 50 from end).
41
52
  const lineRaw = entry.attributes.line;
@@ -45,11 +56,23 @@ export default class Get {
45
56
  ? Math.max(1, parseInt(entry.attributes.limit, 10))
46
57
  : null;
47
58
 
48
- const matches = await store.getEntriesByPattern(
59
+ let matches = await store.getEntriesByPattern(
49
60
  runId,
50
61
  normalized,
51
62
  bodyFilter,
52
63
  );
64
+ if (wantedTags) {
65
+ matches = matches.filter((e) => {
66
+ if (!e.attributes) return false;
67
+ const attrs =
68
+ typeof e.attributes === "string"
69
+ ? JSON.parse(e.attributes)
70
+ : e.attributes;
71
+ if (typeof attrs.tags !== "string") return false;
72
+ const tags = attrs.tags.toLowerCase();
73
+ return wantedTags.every((t) => tags.includes(t));
74
+ });
75
+ }
53
76
 
54
77
  // Manifest: list matches + full-body token costs; no promotion.
55
78
  if (manifest) {
@@ -67,20 +90,11 @@ export default class Get {
67
90
  }
68
91
 
69
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.
70
97
  if (line !== null || limit !== null) {
71
- if (isPattern) {
72
- await store.set({
73
- runId,
74
- turn,
75
- path: entry.resultPath,
76
- body: "line/limit requires a single path, not a glob or body filter",
77
- state: "failed",
78
- outcome: "validation",
79
- loopId,
80
- attributes: { path: target },
81
- });
82
- return;
83
- }
84
98
  if (matches.length === 0) {
85
99
  await store.set({
86
100
  runId,
@@ -93,32 +107,25 @@ export default class Get {
93
107
  });
94
108
  return;
95
109
  }
96
- const allLines = matches[0].body.split("\n");
97
- const total = allLines.length;
98
- const startLine =
99
- line == null
100
- ? 1
101
- : line < 0
102
- ? Math.max(1, total + line + 1)
103
- : Math.max(1, line);
104
- const startIdx = startLine - 1;
105
- const endIdx = limit !== null ? Math.min(startIdx + limit, total) : total;
106
- const slice = allLines.slice(startIdx, endIdx).join("\n");
107
- const endLine = endIdx;
108
- const header = `${target}\n[lines ${startLine}–${endLine} / ${total} total]`;
110
+ 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 };
113
+ if (sections.length === 1) {
114
+ const only = sections[0];
115
+ attributes.lineStart = only.startLine;
116
+ attributes.lineEnd = only.endLine;
117
+ attributes.totalLines = only.total;
118
+ } else {
119
+ attributes.matchCount = sections.length;
120
+ }
109
121
  await store.set({
110
122
  runId,
111
123
  turn,
112
124
  path: entry.resultPath,
113
- body: `${header}\n${slice}`,
125
+ body,
114
126
  state: "resolved",
115
127
  loopId,
116
- attributes: {
117
- path: target,
118
- lineStart: startLine,
119
- lineEnd: endLine,
120
- totalLines: total,
121
- },
128
+ attributes,
122
129
  });
123
130
  return;
124
131
  }
@@ -190,3 +197,19 @@ export default class Get {
190
197
  return "";
191
198
  }
192
199
  }
200
+
201
+ function sliceSection(match, line, limit) {
202
+ const allLines = match.body.split("\n");
203
+ const total = allLines.length;
204
+ const startLine =
205
+ line == null
206
+ ? 1
207
+ : line < 0
208
+ ? Math.max(1, total + line + 1)
209
+ : Math.max(1, line);
210
+ const startIdx = startLine - 1;
211
+ const endIdx = limit !== null ? Math.min(startIdx + limit, total) : total;
212
+ const slice = allLines.slice(startIdx, endIdx).join("\n");
213
+ const text = `${match.path}\n[lines ${startLine}–${endIdx} / ${total} total]\n${slice}`;
214
+ return { text, startLine, endLine: endIdx, total };
215
+ }
@@ -1,38 +1,14 @@
1
- ## <get path="[path/to/file]"/> - Promote an entry
2
-
3
- Example: <get path="src/app.js"/>
4
- <!-- Simplest form. Path attribute. Body is reserved for content filter. -->
1
+ ## <get path="[path]"/> - Promote an entry
5
2
 
6
3
  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" manifest>authentication</get>
13
- <!-- Full pattern: recursive glob + content filter. -->
14
-
4
+ <!-- Body is a content filter, not new content. Path glob + body keyword = filtered recall. -->
5
+ Example: <get path="src/**/!(*.test).js" manifest>auth</get>
6
+ <!-- Negation: !(pattern) excludes matches; combine with body filter for "auth in sources, not tests." -->
7
+ Example: <get path="log://turn_*/sh/**" manifest/>
8
+ <!-- ** crosses path separators (matches log://turn_5/sh/build_1, log://turn_5/sh/test/run_2); * matches one segment only. -->
15
9
  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
-
10
+ <!-- line/limit: read a slice without promoting. line=-50 tails the last 50 lines. -->
21
11
  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
- * `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
-
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)
12
+ <!-- URL slice. line/limit works on any scheme files, sh stdout, knowns, urls. -->
13
+ Example: <get path="*.md"/>
14
+ <!-- One glob per call fans out. For unrelated paths, emit one `<get>` per path — `path` is a single entry or one pattern, not a space-separated list. -->
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import msg from "../../agent/messages.js";
5
+ import { chatCompletionStream } from "../../llm/openaiStream.js";
6
+
7
+ const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
8
+
9
+ const PROVIDER = "google";
10
+ const BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
11
+ const COMPAT_URL = `${BASE_URL}/openai`;
12
+
13
+ // Repo-root-relative key file. Resolved relative to this source file so
14
+ // CWD changes during runs (programbench/tbench cd into workspaces) don't
15
+ // break the lookup. Plugin is inert if the file is missing. Tests may
16
+ // override the path via `RUMMY_GEMINI_KEY_FILE` to point at a tmpdir
17
+ // fixture; the env var is a path knob, not a runtime fallback.
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ function resolveKeyFile() {
20
+ return process.env.RUMMY_GEMINI_KEY_FILE
21
+ ? process.env.RUMMY_GEMINI_KEY_FILE
22
+ : join(__dirname, "..", "..", "..", "gemini.key");
23
+ }
24
+
25
+ // Inert unless gemini.key exists in repo root; google/{model} aliases.
26
+ //
27
+ // Uses Google AI Studio's OpenAI-compatible endpoint
28
+ // (`/v1beta/openai/chat/completions`) for completions so we share the
29
+ // streaming SSE accumulator with the other OpenAI-shaped providers.
30
+ // Context-size lookups go to the native endpoint
31
+ // (`/v1beta/models/{model}`) because the OpenAI-compat /models response
32
+ // drops `inputTokenLimit`.
33
+ //
34
+ // Auth is `Authorization: Bearer {key}` on both endpoints; the legacy
35
+ // `?key={key}` query-param form is supported by Google but the bearer
36
+ // form is consistent with our other plugins.
37
+ export default class Google {
38
+ #apiKey;
39
+ #contextCache = new Map();
40
+
41
+ constructor(core) {
42
+ const keyFile = resolveKeyFile();
43
+ if (!existsSync(keyFile)) return;
44
+ const raw = readFileSync(keyFile, "utf8").trim();
45
+ if (!raw) return;
46
+ this.#apiKey = raw;
47
+
48
+ const wireModel = (alias) => alias.split("/").slice(1).join("/");
49
+
50
+ core.hooks.llm.providers.push({
51
+ name: PROVIDER,
52
+ matches: (model) => model.split("/")[0] === PROVIDER,
53
+ completion: (messages, model, options) =>
54
+ this.#completion(messages, wireModel(model), options),
55
+ getContextSize: (model) => this.#getContextSize(wireModel(model)),
56
+ });
57
+ }
58
+
59
+ async #completion(messages, model, options = {}) {
60
+ const body = { model, messages };
61
+ if (options.maxTokens !== undefined) body.max_tokens = options.maxTokens;
62
+ if (options.temperature !== undefined)
63
+ body.temperature = options.temperature;
64
+
65
+ const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT);
66
+ const signal = options.signal
67
+ ? AbortSignal.any([options.signal, timeoutSignal])
68
+ : timeoutSignal;
69
+
70
+ const headers = { Authorization: `Bearer ${this.#apiKey}` };
71
+
72
+ try {
73
+ return await chatCompletionStream({
74
+ url: `${COMPAT_URL}/chat/completions`,
75
+ headers,
76
+ body,
77
+ signal,
78
+ });
79
+ } catch (err) {
80
+ if (err.status === 401 || err.status === 403) {
81
+ throw new Error(
82
+ msg("error.google_auth", { status: `${err.status} - ${err.body}` }),
83
+ );
84
+ }
85
+ if (err.status) {
86
+ throw new Error(
87
+ msg("error.google_api", { status: `${err.status} - ${err.body}` }),
88
+ );
89
+ }
90
+ throw err;
91
+ }
92
+ }
93
+
94
+ async #getContextSize(model) {
95
+ if (this.#contextCache.has(model)) return this.#contextCache.get(model);
96
+
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.
100
+ const url = `${BASE_URL}/models/${model}?key=${encodeURIComponent(this.#apiKey)}`;
101
+ const res = await fetch(url, {
102
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
103
+ });
104
+ if (!res.ok) {
105
+ throw new Error(
106
+ msg("error.google_models_failed", { model, status: res.status }),
107
+ );
108
+ }
109
+ const data = await res.json();
110
+ const ctx = data?.inputTokenLimit;
111
+ if (!ctx) throw new Error(msg("error.google_no_context_length", { model }));
112
+ this.#contextCache.set(model, ctx);
113
+ return ctx;
114
+ }
115
+ }