@possumtech/rummy 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +12 -7
  2. package/BENCH_ENVIRONMENT.md +230 -0
  3. package/CLIENT_INTERFACE.md +396 -0
  4. package/PLUGINS.md +93 -1
  5. package/SPEC.md +305 -28
  6. package/bin/postinstall.js +2 -2
  7. package/bin/rummy.js +2 -2
  8. package/last_run.txt +5617 -0
  9. package/migrations/001_initial_schema.sql +2 -1
  10. package/package.json +6 -2
  11. package/scriptify/cache_probe.js +66 -0
  12. package/scriptify/cache_probe_grok.js +74 -0
  13. package/service.js +22 -11
  14. package/src/agent/AgentLoop.js +33 -139
  15. package/src/agent/ContextAssembler.js +2 -9
  16. package/src/agent/Entries.js +36 -101
  17. package/src/agent/ProjectAgent.js +2 -9
  18. package/src/agent/TurnExecutor.js +45 -83
  19. package/src/agent/XmlParser.js +247 -273
  20. package/src/agent/budget.js +5 -28
  21. package/src/agent/config.js +38 -0
  22. package/src/agent/errors.js +7 -13
  23. package/src/agent/httpStatus.js +1 -19
  24. package/src/agent/known_store.sql +7 -2
  25. package/src/agent/materializeContext.js +12 -17
  26. package/src/agent/pathEncode.js +5 -0
  27. package/src/agent/rummyHome.js +9 -0
  28. package/src/agent/runs.sql +18 -0
  29. package/src/agent/tokens.js +2 -8
  30. package/src/hooks/HookRegistry.js +1 -16
  31. package/src/hooks/Hooks.js +8 -33
  32. package/src/hooks/PluginContext.js +3 -21
  33. package/src/hooks/RpcRegistry.js +1 -4
  34. package/src/hooks/RummyContext.js +2 -16
  35. package/src/hooks/ToolRegistry.js +5 -15
  36. package/src/llm/LlmProvider.js +28 -23
  37. package/src/llm/errors.js +41 -4
  38. package/src/llm/openaiStream.js +125 -0
  39. package/src/llm/retry.js +61 -15
  40. package/src/plugins/budget/budget.js +14 -81
  41. package/src/plugins/cli/README.md +87 -0
  42. package/src/plugins/cli/bin.js +61 -0
  43. package/src/plugins/cli/cli.js +120 -0
  44. package/src/plugins/env/README.md +2 -1
  45. package/src/plugins/env/env.js +4 -6
  46. package/src/plugins/env/envDoc.md +2 -2
  47. package/src/plugins/error/error.js +23 -23
  48. package/src/plugins/file/file.js +2 -22
  49. package/src/plugins/get/get.js +12 -34
  50. package/src/plugins/get/getDoc.md +5 -3
  51. package/src/plugins/hedberg/edits.js +1 -11
  52. package/src/plugins/hedberg/hedberg.js +3 -26
  53. package/src/plugins/hedberg/normalize.js +1 -5
  54. package/src/plugins/hedberg/patterns.js +4 -15
  55. package/src/plugins/hedberg/sed.js +1 -7
  56. package/src/plugins/helpers.js +28 -20
  57. package/src/plugins/index.js +25 -41
  58. package/src/plugins/instructions/README.md +18 -0
  59. package/src/plugins/instructions/instructions.js +13 -76
  60. package/src/plugins/instructions/instructions.md +19 -18
  61. package/src/plugins/instructions/instructions_104.md +5 -4
  62. package/src/plugins/instructions/instructions_105.md +16 -15
  63. package/src/plugins/instructions/instructions_106.md +15 -14
  64. package/src/plugins/instructions/instructions_107.md +13 -6
  65. package/src/plugins/known/README.md +26 -6
  66. package/src/plugins/known/known.js +36 -34
  67. package/src/plugins/log/README.md +2 -2
  68. package/src/plugins/log/log.js +6 -33
  69. package/src/plugins/ollama/ollama.js +50 -66
  70. package/src/plugins/openai/openai.js +26 -44
  71. package/src/plugins/openrouter/openrouter.js +28 -52
  72. package/src/plugins/policy/README.md +8 -2
  73. package/src/plugins/policy/policy.js +8 -21
  74. package/src/plugins/prompt/README.md +22 -0
  75. package/src/plugins/prompt/prompt.js +8 -16
  76. package/src/plugins/rm/rm.js +5 -2
  77. package/src/plugins/rm/rmDoc.md +4 -4
  78. package/src/plugins/rpc/README.md +2 -1
  79. package/src/plugins/rpc/rpc.js +51 -47
  80. package/src/plugins/set/README.md +5 -1
  81. package/src/plugins/set/set.js +23 -33
  82. package/src/plugins/set/setDoc.md +1 -1
  83. package/src/plugins/sh/README.md +2 -1
  84. package/src/plugins/sh/sh.js +5 -11
  85. package/src/plugins/sh/shDoc.md +2 -2
  86. package/src/plugins/stream/README.md +6 -5
  87. package/src/plugins/stream/stream.js +6 -35
  88. package/src/plugins/telemetry/telemetry.js +26 -19
  89. package/src/plugins/think/think.js +4 -7
  90. package/src/plugins/unknown/unknown.js +8 -13
  91. package/src/plugins/update/update.js +36 -35
  92. package/src/plugins/update/updateDoc.md +3 -3
  93. package/src/plugins/xai/xai.js +30 -20
  94. package/src/plugins/yolo/yolo.js +8 -41
  95. package/src/server/ClientConnection.js +17 -47
  96. package/src/server/SocketServer.js +14 -14
  97. package/src/server/protocol.js +1 -10
  98. package/src/sql/functions/slugify.js +5 -7
  99. package/src/sql/v_model_context.sql +4 -11
  100. package/turns/cli_1777462658211/turn_001.txt +772 -0
  101. package/turns/cli_1777462658211/turn_002.txt +606 -0
  102. package/turns/cli_1777462658211/turn_003.txt +667 -0
  103. package/turns/cli_1777462658211/turn_004.txt +297 -0
  104. package/turns/cli_1777462658211/turn_005.txt +301 -0
  105. package/turns/cli_1777462658211/turn_006.txt +262 -0
  106. package/turns/cli_1777465095132/turn_001.txt +715 -0
  107. package/turns/cli_1777465095132/turn_002.txt +236 -0
  108. package/turns/cli_1777465095132/turn_003.txt +287 -0
  109. package/turns/cli_1777465095132/turn_004.txt +694 -0
  110. package/turns/cli_1777465095132/turn_005.txt +422 -0
  111. package/turns/cli_1777465095132/turn_006.txt +365 -0
  112. package/turns/cli_1777465095132/turn_007.txt +885 -0
  113. package/turns/cli_1777465095132/turn_008.txt +1277 -0
  114. package/turns/cli_1777465095132/turn_009.txt +736 -0
@@ -1,46 +1,23 @@
1
+ import config from "./config.js";
1
2
  import { countTokens } from "./tokens.js";
2
3
 
3
- const CEILING_RATIO = Number(process.env.RUMMY_BUDGET_CEILING);
4
- if (!CEILING_RATIO) throw new Error("RUMMY_BUDGET_CEILING must be set");
4
+ const CEILING_RATIO = config.BUDGET_CEILING;
5
5
 
6
6
  export function ceiling(contextSize) {
7
7
  return Math.floor(contextSize * CEILING_RATIO);
8
8
  }
9
9
 
10
- /**
11
- * Sum assembled-message token counts.
12
- * Used by the budget enforce gate, which has the real messages.
13
- */
10
+ // Sum assembled-message token counts; used by the budget enforce gate.
14
11
  export function measureMessages(messages) {
15
12
  return messages.reduce((sum, m) => sum + countTokens(m.content), 0);
16
13
  }
17
14
 
18
- /**
19
- * Sum projected row body token counts — what's actually in the packet
20
- * for each entry at its current visibility. Used by prompt.js while
21
- * generating the <prompt> tag (before assembly completes).
22
- */
15
+ // Sum projected row body token counts; used by prompt.js pre-assembly.
23
16
  export function measureRows(rows) {
24
17
  return rows.reduce((sum, r) => sum + countTokens(r.body), 0);
25
18
  }
26
19
 
27
- /**
28
- * Single source of truth for budget numbers. Every caller — prompt.js
29
- * generating the <prompt> tag, budget.js enforcing the ceiling,
30
- * AgentLoop emitting telemetry — passes in its own measured totalTokens
31
- * and reads the same object back. No fallbacks: callers produce the
32
- * measurement they have.
33
- *
34
- * Returns:
35
- * ceiling — floor(contextSize × CEILING_RATIO), the hard wall
36
- * totalTokens — echoed back (the full packet size the caller measured)
37
- * tokenUsage — same as totalTokens. Kept under this name for the
38
- * `<prompt tokenUsage="N">` attribute on the wire. Must
39
- * agree with totalTokens so the model's math is honest.
40
- * tokensFree — ceiling − totalTokens (floor 0)
41
- * overflow — max(0, totalTokens − ceiling)
42
- * ok — overflow === 0
43
- */
20
+ // Single source of truth for budget numbers; tokenUsage echoes totalTokens for the wire attribute.
44
21
  export function computeBudget({ contextSize, totalTokens }) {
45
22
  const cap = ceiling(contextSize);
46
23
  const tokensFree = Math.max(0, cap - totalTokens);
@@ -0,0 +1,38 @@
1
+ // Validates required RUMMY_* env at module load; defaults in .env.example.
2
+
3
+ const REQUIRED = {
4
+ BUDGET_CEILING: { env: "RUMMY_BUDGET_CEILING", parse: Number },
5
+ LLM_DEADLINE: { env: "RUMMY_LLM_DEADLINE", parse: Number },
6
+ LLM_MAX_BACKOFF: { env: "RUMMY_LLM_MAX_BACKOFF", parse: Number },
7
+ FETCH_TIMEOUT: { env: "RUMMY_FETCH_TIMEOUT", parse: Number },
8
+ MAX_STRIKES: { env: "RUMMY_MAX_STRIKES", parse: Number },
9
+ MIN_CYCLES: { env: "RUMMY_MIN_CYCLES", parse: Number },
10
+ MAX_CYCLE_PERIOD: { env: "RUMMY_MAX_CYCLE_PERIOD", parse: Number },
11
+ RUN_TIMEOUT: { env: "RUMMY_RUN_TIMEOUT", parse: Number },
12
+ PLUGINS_LOAD_TIMEOUT: { env: "RUMMY_PLUGINS_LOAD_TIMEOUT", parse: Number },
13
+ THINK: { env: "RUMMY_THINK", parse: (v) => v },
14
+ };
15
+
16
+ const config = {};
17
+ const missing = [];
18
+ for (const [key, spec] of Object.entries(REQUIRED)) {
19
+ const raw = process.env[spec.env];
20
+ if (raw === undefined || raw === "") {
21
+ missing.push(spec.env);
22
+ continue;
23
+ }
24
+ const parsed = spec.parse(raw);
25
+ if (typeof parsed === "number" && Number.isNaN(parsed)) {
26
+ missing.push(`${spec.env} (got "${raw}", expected number)`);
27
+ continue;
28
+ }
29
+ config[key] = parsed;
30
+ }
31
+ if (missing.length > 0) {
32
+ throw new Error(
33
+ `RUMMY config missing or invalid: ${missing.join(", ")}. ` +
34
+ "Set in .env, .env.example, or shell env.",
35
+ );
36
+ }
37
+
38
+ export default Object.freeze(config);
@@ -1,21 +1,15 @@
1
- /**
2
- * Typed errors for the agent/Entries layer. Callers catch by type,
3
- * not by regex.
4
- */
5
-
6
- /**
7
- * Thrown when a writer tier isn't permitted to write to a scheme.
8
- * See SPEC writer_tiers: schemes declare writable_by = subset of
9
- * {system, plugin, client, model}. A write from an excluded tier
10
- * rejects with this error.
11
- */
1
+ // Writer tier excluded from scheme.writable_by; see SPEC writer_tiers.
12
2
  export class PermissionError extends Error {
13
3
  constructor(scheme, writer, allowed) {
4
+ // Paths without `://` have a null scheme. Report null verbatim
5
+ // rather than substituting a plausible-sounding "file" — there is
6
+ // no scheme called "file" and the error must reflect actual state.
7
+ const schemeLabel = scheme === null ? "(none)" : scheme;
14
8
  super(
15
- `403: writer "${writer}" not permitted for scheme "${scheme ?? "file"}" (allowed: ${allowed.join(", ")})`,
9
+ `403: writer "${writer}" not permitted for scheme "${schemeLabel}" (allowed: ${allowed.join(", ")})`,
16
10
  );
17
11
  this.name = "PermissionError";
18
- this.scheme = scheme ?? "file";
12
+ this.scheme = scheme;
19
13
  this.writer = writer;
20
14
  this.allowed = [...allowed];
21
15
  }
@@ -1,22 +1,4 @@
1
- /**
2
- * Map the entry-layer (state, outcome) tuple to an HTTP status number for
3
- * model-facing tag rendering.
4
- *
5
- * Model-facing tags still carry `status="NNN"` because the model's
6
- * vocabulary (instructions + tooldocs + training) is HTTP-shaped. The DB
7
- * stores categorical state + textual outcome (see SPEC entries); this helper
8
- * is the one-way translation for rendering.
9
- *
10
- * Outcome strings prefixed with a 3-digit HTTP code (e.g.
11
- * `"overflow:413:..."` or `"permission:403:..."`) extract the code
12
- * verbatim. Otherwise state maps to a canonical HTTP:
13
- *
14
- * resolved → 200
15
- * proposed → 202
16
- * streaming → 102
17
- * cancelled → 499
18
- * failed → 500 (unless outcome carries a code)
19
- */
1
+ // (state, outcome) → HTTP status for model-facing tags; outcome's 3-digit prefix wins.
20
2
  export function stateToStatus(state, outcome = null) {
21
3
  if (outcome) {
22
4
  const match = /(\d{3})/.exec(outcome);
@@ -226,8 +226,10 @@ WHERE run_id = :run_id AND entry_id IN (
226
226
  -- Default excludes audit schemes (system://, reasoning://, model://, user://,
227
227
  -- assistant://, content://, instructions://) so model-facing tools never leak
228
228
  -- internal entries. Internal callers that need them pass include_audit_schemes=1.
229
+ -- :since filters to entries created after a given id; when set, results order
230
+ -- by id (insertion order) for streaming consumers; otherwise by path.
229
231
  SELECT
230
- e.path, e.body, e.scheme, rv.state, rv.outcome, rv.visibility
232
+ e.id, e.path, e.body, e.scheme, rv.state, rv.outcome, rv.visibility, rv.turn
231
233
  , countTokens(e.body) AS tokens, e.attributes
232
234
  FROM run_views AS rv
233
235
  JOIN entries AS e ON e.id = rv.entry_id
@@ -237,7 +239,10 @@ WHERE
237
239
  AND hedmatch(:path, e.path)
238
240
  AND (:body IS NULL OR hedsearch(:body, e.body))
239
241
  AND (:include_audit_schemes IS NOT NULL OR s.model_visible = 1)
240
- ORDER BY e.path
242
+ AND (:since IS NULL OR e.id > :since)
243
+ ORDER BY
244
+ CASE WHEN :since IS NOT NULL THEN e.id ELSE 0 END,
245
+ e.path
241
246
  LIMIT
242
247
  COALESCE(:limit, -1)
243
248
  OFFSET COALESCE(:offset, 0);
@@ -1,11 +1,7 @@
1
1
  import ContextAssembler from "./ContextAssembler.js";
2
2
  import { countLines, countTokens } from "./tokens.js";
3
3
 
4
- /**
5
- * Rebuild turn_context from v_model_context, then assemble messages.
6
- * Called at turn start and again by the budget plugin when it needs a
7
- * fresh measurement after mutating visibility.
8
- */
4
+ // Rebuild turn_context from v_model_context and assemble messages.
9
5
  export default async function materializeContext({
10
6
  db,
11
7
  hooks,
@@ -16,22 +12,15 @@ export default async function materializeContext({
16
12
  mode,
17
13
  toolSet,
18
14
  contextSize,
19
- demoted,
20
15
  }) {
21
16
  await db.clear_turn_context.run({ run_id: runId, turn });
22
17
  const viewRows = await db.get_model_context.all({ run_id: runId });
23
- // Per-entry token accounting (see SPEC @token_accounting): captured
24
- // here while we still have the raw body, then merged onto rows after
25
- // the read-back roundtrip through turn_context.
18
+ // Per-entry token accounting; merged back after the turn_context roundtrip.
26
19
  const tokenAccounting = new Map();
27
20
  for (const row of viewRows) {
28
- // schemeOf() yields NULL (or "") for bare file paths — translate
29
- // to "file" so the view lookup finds the file scheme handler.
30
21
  const scheme = row.scheme ? row.scheme : "file";
31
22
  const attrs = row.attributes ? JSON.parse(row.attributes) : null;
32
- // Log entries live at log://turn_N/action/slug. Dispatch projection
33
- // to the action plugin's view (set, update, search, etc.) by
34
- // extracting the action segment from the path.
23
+ // Dispatch log entries to their action plugin's view via path segment.
35
24
  let projectionKey = scheme;
36
25
  if (scheme === "log") {
37
26
  const m = row.path.match(/^log:\/\/turn_\d+\/([^/]+)\//);
@@ -55,7 +44,13 @@ export default async function materializeContext({
55
44
  const vTokens = countTokens(visibleProjection);
56
45
  const sTokens = countTokens(summarizedProjection);
57
46
  const vLines = countLines(visibleProjection);
58
- tokenAccounting.set(row.path, { vTokens, sTokens, vLines });
47
+ tokenAccounting.set(row.path, {
48
+ vTokens,
49
+ sTokens,
50
+ vLines,
51
+ vBody: visibleProjection,
52
+ sBody: summarizedProjection,
53
+ });
59
54
  const projectedBody =
60
55
  row.visibility === "visible" ? visibleProjection : summarizedProjection;
61
56
  await db.insert_turn_context.run({
@@ -81,9 +76,10 @@ export default async function materializeContext({
81
76
  row.sTokens = t.sTokens;
82
77
  row.aTokens = t.vTokens - t.sTokens;
83
78
  row.vLines = t.vLines;
79
+ row.vBody = t.vBody;
80
+ row.sBody = t.sBody;
84
81
  }
85
82
  const lastCtx = await db.get_last_context_tokens.get({ run_id: runId });
86
- // First turn of a new run has no prior context.
87
83
  let lastContextTokens = 0;
88
84
  if (lastCtx) lastContextTokens = lastCtx.context_tokens;
89
85
 
@@ -93,7 +89,6 @@ export default async function materializeContext({
93
89
  type: mode,
94
90
  systemPrompt,
95
91
  contextSize,
96
- demoted,
97
92
  toolSet,
98
93
  lastContextTokens,
99
94
  turn,
@@ -0,0 +1,5 @@
1
+ // Single source of truth for path-segment encoding: spaces → _, then URL-encode.
2
+ // Used by slugify (for summary-derived slugs) and Entries (for normalize/dedup/logPath).
3
+ export default function encodeSegment(s) {
4
+ return encodeURIComponent(String(s).replace(/ /g, "_"));
5
+ }
@@ -0,0 +1,9 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ // RUMMY_HOME default per README §Installation; resolved here because
5
+ // entrypoints run before env files load.
6
+ export default function resolveRummyHome() {
7
+ if (process.env.RUMMY_HOME) return process.env.RUMMY_HOME;
8
+ return join(homedir(), ".rummy");
9
+ }
@@ -56,6 +56,24 @@ LIMIT
56
56
  OFFSET
57
57
  COALESCE(:offset, 0);
58
58
 
59
+ -- PREP: get_run_summary
60
+ -- Per-run aggregation across all turns. LEFT JOIN: a run with zero
61
+ -- recorded turns (e.g. signal abort before first turn) returns 0s,
62
+ -- not NULL.
63
+ SELECT
64
+ r.model AS model
65
+ , COUNT(t.id) AS turns
66
+ , COALESCE(SUM(t.cost), 0) AS cost
67
+ , COALESCE(SUM(t.prompt_tokens), 0) AS prompt_tokens
68
+ , COALESCE(SUM(t.cached_tokens), 0) AS cached_tokens
69
+ , COALESCE(SUM(t.completion_tokens), 0) AS completion_tokens
70
+ , COALESCE(SUM(t.reasoning_tokens), 0) AS reasoning_tokens
71
+ , COALESCE(SUM(t.total_tokens), 0) AS total_tokens
72
+ FROM runs AS r
73
+ LEFT JOIN turns AS t ON t.run_id = r.id
74
+ WHERE r.id = :id
75
+ GROUP BY r.id;
76
+
59
77
  -- PREP: rename_run
60
78
  UPDATE runs
61
79
  SET alias = :new_alias
@@ -1,10 +1,4 @@
1
- /**
2
- * Token estimation. Conservative character-based approximation.
3
- * RUMMY_TOKEN_DIVISOR controls characters per token.
4
- * No external dependencies. The budget contract is exact.
5
- * contextSize is the ceiling. countTokens is the measurement.
6
- */
7
-
1
+ // Conservative chars/token approximation; RUMMY_TOKEN_DIVISOR controls the divisor.
8
2
  const DIVISOR = Number(process.env.RUMMY_TOKEN_DIVISOR);
9
3
  if (!DIVISOR) throw new Error("RUMMY_TOKEN_DIVISOR must be a non-zero number");
10
4
 
@@ -15,6 +9,6 @@ export function countTokens(text) {
15
9
 
16
10
  export function countLines(text) {
17
11
  if (!text) return 0;
18
- const newlines = (text.match(/\n/g) || []).length;
12
+ const newlines = text.split("\n").length - 1;
19
13
  return text.endsWith("\n") ? newlines : newlines + 1;
20
14
  }
@@ -1,7 +1,4 @@
1
- /**
2
- * HookRegistry manages a simple, priority-ordered pipeline of processors.
3
- * It also supports basic event emitters for side-effects.
4
- */
1
+ // Priority-ordered processors + filters + events.
5
2
  export default class HookRegistry {
6
3
  #processors = [];
7
4
  #events = new Map();
@@ -12,17 +9,11 @@ export default class HookRegistry {
12
9
  this.#debug = debug;
13
10
  }
14
11
 
15
- /**
16
- * Register a processor for the Turn XML Document.
17
- */
18
12
  onTurn(callback, priority = 10) {
19
13
  this.#processors.push({ callback, priority });
20
14
  this.#processors.sort((a, b) => a.priority - b.priority);
21
15
  }
22
16
 
23
- /**
24
- * Run all registered Turn processors.
25
- */
26
17
  async processTurn(rummy) {
27
18
  for (const p of this.#processors) {
28
19
  const start = performance.now();
@@ -35,9 +26,6 @@ export default class HookRegistry {
35
26
  }
36
27
  }
37
28
 
38
- /**
39
- * Standard WordPress-style Filters for non-DOM data.
40
- */
41
29
  addFilter(tag, callback, priority = 10) {
42
30
  if (!this.#filters.has(tag)) this.#filters.set(tag, []);
43
31
  this.#filters.get(tag).push({ callback, priority });
@@ -54,9 +42,6 @@ export default class HookRegistry {
54
42
  return result;
55
43
  }
56
44
 
57
- /**
58
- * Standard WordPress-style Events for side-effects.
59
- */
60
45
  addEvent(tag, callback, priority = 10) {
61
46
  if (!this.#events.has(tag)) this.#events.set(tag, []);
62
47
  this.#events.get(tag).push({ callback, priority });
@@ -2,10 +2,7 @@ import HookRegistry from "./HookRegistry.js";
2
2
  import RpcRegistry from "./RpcRegistry.js";
3
3
  import ToolRegistry from "./ToolRegistry.js";
4
4
 
5
- /**
6
- * createHooks returns a structured, strictly-typed API for registering
7
- * and emitting hooks, removing the dynamic stringly-typed Proxy magic.
8
- */
5
+ // Strictly-typed hook surface; replaces the previous Proxy magic.
9
6
  export default function createHooks(debug = false) {
10
7
  const registry = new HookRegistry(debug);
11
8
  const tools = new ToolRegistry();
@@ -28,6 +25,10 @@ export default function createHooks(debug = false) {
28
25
  processTurn: registry.processTurn.bind(registry),
29
26
 
30
27
  // Explicit Hook Schema
28
+ boot: {
29
+ // Post-init, pre-accept-connections; one-shot post-init actions subscribe here.
30
+ completed: createEvent("boot.completed"),
31
+ },
31
32
  project: {
32
33
  init: {
33
34
  started: createEvent("project.init.started"),
@@ -43,8 +44,6 @@ export default function createHooks(debug = false) {
43
44
  run: {
44
45
  created: createEvent("run.created"),
45
46
  started: createEvent("run.started"),
46
- progress: createEvent("run.progress"),
47
- state: createEvent("run.state"),
48
47
  config: createFilter("run.config"),
49
48
  step: {
50
49
  completed: createEvent("run.step.completed"),
@@ -59,21 +58,13 @@ export default function createHooks(debug = false) {
59
58
  response: createEvent("turn.response"),
60
59
  completed: createEvent("turn.completed"),
61
60
  },
61
+ // SPEC #resolution covers the proposal hook chain.
62
62
  proposal: {
63
63
  prepare: createEvent("proposal.prepare"),
64
64
  pending: createEvent("proposal.pending"),
65
- // Plugins veto acceptance by returning {allow:false, outcome, body}.
66
- // Used e.g. by set plugin's readonly constraint check.
67
65
  accepting: createFilter("proposal.accepting"),
68
- // Plugins compose the resolved body based on path/action. Default
69
- // is output || "". Used e.g. by set plugin to preserve the
70
- // model's proposed content as the resolved body.
71
66
  content: createFilter("proposal.content"),
72
- // Fires after a proposal resolves with action="accept". Plugins
73
- // perform their side effects (file materialize, unlink, stream
74
- // setup, etc.) here — NOT in AgentLoop.resolve.
75
67
  accepted: createEvent("proposal.accepted"),
76
- // Fires after a proposal resolves with action="error" or "reject".
77
68
  rejected: createEvent("proposal.rejected"),
78
69
  },
79
70
  assembly: {
@@ -98,24 +89,9 @@ export default function createHooks(debug = false) {
98
89
  },
99
90
  messages: createFilter("llm.messages"),
100
91
  response: createFilter("llm.response"),
101
- // Reasoning merge filter. Subscribers contribute per-tag
102
- // reasoning text (e.g. the think plugin's <think>…</think>)
103
- // to the model's reasoning_content field. Fires between parse
104
- // and turn.response.
92
+ // Plugins contribute reasoning text into reasoning_content; fires between parse and turn.response.
105
93
  reasoning: createFilter("llm.reasoning"),
106
- // LLM provider registry. Plugins contribute entries shaped:
107
- // {
108
- // name: string,
109
- // matches: (modelAlias) => boolean,
110
- // completion: (messages, modelAlias, options) => Promise<response>,
111
- // getContextSize: (modelAlias) => Promise<number>,
112
- // }
113
- // Each provider owns a prefix namespace (e.g. "openai/", "ollama/",
114
- // "openrouter/"). LlmProvider picks the first provider whose
115
- // matches() returns true. No catchall — if a model alias doesn't
116
- // match any registered provider, the request fails with a clear
117
- // "no provider registered" error. External plugins add new
118
- // prefixes without namespace collision.
94
+ // Provider entries: { name, matches, completion, getContextSize }.
119
95
  providers: [],
120
96
  },
121
97
  file: {},
@@ -163,7 +139,6 @@ export default function createHooks(debug = false) {
163
139
  agent: {},
164
140
  tools,
165
141
 
166
- // Utility to add raw filters/events directly if needed for tests
167
142
  addFilter: registry.addFilter.bind(registry),
168
143
  applyFilters: registry.applyFilters.bind(registry),
169
144
  addEvent: registry.addEvent.bind(registry),
@@ -1,12 +1,4 @@
1
- /**
2
- * PluginContext is the plugin-only interface to the rummy system.
3
- * Available as `rummy.core` on the per-turn RummyContext, and as the
4
- * direct object passed to plugin constructors at startup.
5
- *
6
- * Carries plugin identity, hook registration, and infrastructure access.
7
- * The unified API (tool verbs, queries) lives on RummyContext.
8
- * This is the tier boundary: clients can't reach core.
9
- */
1
+ // Plugin-only registration interface; tool verbs live on RummyContext. PLUGINS.md.
10
2
  export default class PluginContext {
11
3
  #name;
12
4
  #hooks;
@@ -77,19 +69,12 @@ export default class PluginContext {
77
69
  this.#hooks.tools.ensureTool(this.#name);
78
70
  }
79
71
 
80
- // Mark this plugin's tool as hidden from model-facing tool lists.
81
- // Handler still dispatches if the model emits the tag.
72
+ // Hide from tool lists; handler still dispatches if the model emits the tag.
82
73
  markHidden() {
83
74
  this.#hooks.tools.markHidden(this.#name);
84
75
  }
85
76
 
86
- /**
87
- * Register a named callback for this plugin.
88
- * "handler" registers the tool handler.
89
- * "visible"/"summarized" register visibility projections.
90
- * "docs" sets tool documentation.
91
- * Everything else resolves to a hook event.
92
- */
77
+ // "handler" / "visible" / "summarized" are special; everything else is a hook event name.
93
78
  on(event, callback, priority = 10) {
94
79
  if (event === "handler") {
95
80
  this.#hooks.tools.ensureTool(this.#name);
@@ -104,9 +89,6 @@ export default class PluginContext {
104
89
  if (hook) hook.on(callback, priority);
105
90
  }
106
91
 
107
- /**
108
- * Register a filter callback.
109
- */
110
92
  filter(name, callback, priority = 10) {
111
93
  const hook = this.#resolveFilter(name);
112
94
  if (hook) hook.addFilter(callback, priority);
@@ -26,10 +26,7 @@ export default class RpcRegistry {
26
26
 
27
27
  #toolFallback = null;
28
28
 
29
- /**
30
- * Set a fallback that auto-dispatches any registered tool via RPC.
31
- * Checked at request time — tools registered after this call still work.
32
- */
29
+ // Late-binding tool dispatcher; resolved per request.
33
30
  setToolFallback(hooks, buildRunContext, dispatchTool) {
34
31
  this.#toolFallback = { hooks, buildRunContext, dispatchTool };
35
32
  }
@@ -1,15 +1,6 @@
1
- /**
2
- * RummyContext provides a unified, semantic API for plugins to interact with
3
- * the Turn node tree and core resources like the Database and Project metadata.
4
- */
5
- // Entries write verbs that should automatically carry the caller's
6
- // writer identity. Handler-issued writes on behalf of the model default
7
- // to writer=model; plugin background writes (set via rummy from a hook
8
- // with writer: "plugin" or "system" in ctx) get the context's writer.
1
+ // Per-turn plugin API (see PLUGINS.md); write verbs auto-carry writer identity.
9
2
  const WRITE_VERBS = new Set(["set", "rm", "cp", "mv", "update"]);
10
3
 
11
- // Defaults applied at construction so every plugin-facing getter
12
- // returns a predictable shape without per-access fallbacks.
13
4
  const CONTEXT_DEFAULTS = Object.freeze({
14
5
  hooks: null,
15
6
  activeFiles: [],
@@ -126,12 +117,7 @@ export default class RummyContext {
126
117
  return this.#context.loopPrompt;
127
118
  }
128
119
 
129
- /**
130
- * Writer identity for Entries permission checks. Defaults to
131
- * 'model' — handlers write on behalf of the model's emitted command.
132
- * Non-handler plugin code (streaming callbacks, background emissions)
133
- * passes `writer: 'plugin'` or `'system'` explicitly.
134
- */
120
+ // Default 'model' (handlers write on the model's behalf); plugins pass writer explicitly.
135
121
  get writer() {
136
122
  return this.#context.writer;
137
123
  }
@@ -1,6 +1,4 @@
1
- // Tool display order: gather → reason → act → communicate.
2
- // Position in the list implies priority to the model.
3
- // `update` is pinned last — it's the turn-closer, not an action.
1
+ // gather → reason → act → communicate; update pinned last (turn-closer).
4
2
  const TOOL_ORDER = [
5
3
  "think",
6
4
  "unknown",
@@ -40,9 +38,7 @@ export default class ToolRegistry {
40
38
  this.#tools.set(scheme, Object.freeze({}));
41
39
  }
42
40
 
43
- // Hidden tools dispatch on direct emission but don't appear in any
44
- // model-facing tool list. Internal schemes (e.g. <known>, <unknown>)
45
- // the model writes via <set path="scheme://..."> instead.
41
+ // Hidden tools dispatch on direct emission but never appear in tool lists.
46
42
  markHidden(scheme) {
47
43
  this.#hidden.add(scheme);
48
44
  }
@@ -82,9 +78,7 @@ export default class ToolRegistry {
82
78
  if (!fn) return "";
83
79
 
84
80
  const body = await fn(entry);
85
- // View handlers MAY return undefined or null to mean "no projected
86
- // body at this visibility" — normalize at this boundary so callers
87
- // get a predictable string.
81
+ // undefined/null = "no projected body at this visibility"; normalize to "".
88
82
  return body == null ? "" : body;
89
83
  }
90
84
 
@@ -106,18 +100,14 @@ export default class ToolRegistry {
106
100
  return sortByPriority([...this.#tools.keys()]);
107
101
  }
108
102
 
109
- // Names advertised to the model registered tools minus hidden ones.
110
- // Use this anywhere a tool list is shown to the model.
103
+ // Registered tools minus hidden; use anywhere a list reaches the model.
111
104
  get advertisedNames() {
112
105
  return sortByPriority(
113
106
  [...this.#tools.keys()].filter((n) => !this.#hidden.has(n)),
114
107
  );
115
108
  }
116
109
 
117
- /**
118
- * Compute the active tool set for a loop.
119
- * All exclusions — mode, flags, hidden — handled here. One mechanism.
120
- */
110
+ // Single source of truth for active-tool exclusions; SPEC #mode_enforcement.
121
111
  resolveForLoop(
122
112
  mode,
123
113
  { noInteraction = false, noWeb = false, noProposals = false } = {},
@@ -1,27 +1,33 @@
1
+ import config from "../agent/config.js";
1
2
  import msg from "../agent/messages.js";
2
3
  import {
3
4
  ContextExceededError,
5
+ classifyTransient,
4
6
  isContextExceededMessage,
5
- isTransientMessage,
6
7
  } from "./errors.js";
7
- import { retryWithBackoff } from "./retry.js";
8
+ import { retryClassified } from "./retry.js";
8
9
 
9
- const DEADLINE_MS = Number(process.env.RUMMY_LLM_DEADLINE_MS);
10
- const MAX_BACKOFF_MS = Number(process.env.RUMMY_LLM_MAX_BACKOFF_MS);
11
- if (!DEADLINE_MS) throw new Error("RUMMY_LLM_DEADLINE_MS must be set");
12
- if (!MAX_BACKOFF_MS) throw new Error("RUMMY_LLM_MAX_BACKOFF_MS must be set");
10
+ const { LLM_DEADLINE, LLM_MAX_BACKOFF } = config;
13
11
 
14
- /**
15
- * Thin dispatcher over the LLM provider registry (`hooks.llm.providers`).
16
- * Resolves the model alias via the DB, finds the highest-priority provider
17
- * whose `matches()` returns true, and delegates. Wraps the call with
18
- * transient-error retry and surfaces context-exceeded as a typed
19
- * ContextExceededError.
20
- *
21
- * Vendor-specific HTTP is owned by per-vendor plugins under
22
- * `src/plugins/{openai,ollama,xai,openrouter,...}/`. Adding a new vendor
23
- * is a matter of adding a plugin — no changes here.
24
- */
12
+ // Per-category retry policies. Gateway/server are bounded short because
13
+ // upstream-down won't recover by waiting; warmup/rate_limit get the full
14
+ // LLM deadline because they're recoverable wait states with knowable bounds.
15
+ const POLICIES = Object.freeze({
16
+ gateway: { deadlineMs: 30_000, baseDelayMs: 500, maxDelayMs: 5_000 },
17
+ warmup: {
18
+ deadlineMs: LLM_DEADLINE,
19
+ baseDelayMs: 2000,
20
+ maxDelayMs: LLM_MAX_BACKOFF,
21
+ },
22
+ rate_limit: {
23
+ deadlineMs: LLM_DEADLINE,
24
+ baseDelayMs: 1000,
25
+ maxDelayMs: LLM_MAX_BACKOFF,
26
+ },
27
+ server: { deadlineMs: 60_000, baseDelayMs: 1000, maxDelayMs: 10_000 },
28
+ });
29
+
30
+ // Dispatches to hooks.llm.providers; per-category transient retry; ContextExceededError surface.
25
31
  export default class LlmProvider {
26
32
  #db;
27
33
  #hooks;
@@ -60,16 +66,15 @@ export default class LlmProvider {
60
66
  }
61
67
 
62
68
  try {
63
- return await retryWithBackoff(
69
+ return await retryClassified(
64
70
  () => provider.completion(messages, resolvedModel, resolvedOptions),
65
71
  {
66
72
  signal: options.signal,
67
- deadlineMs: DEADLINE_MS,
68
- maxDelayMs: MAX_BACKOFF_MS,
69
- isRetryable: (err) => isTransientMessage(err.message),
70
- onRetry: (err, attempt, delayMs, remainingMs) => {
73
+ classify: classifyTransient,
74
+ policies: POLICIES,
75
+ onRetry: (err, category, attempt, delayMs, remainingMs) => {
71
76
  console.error(
72
- `[LLM] transient failure on ${provider.name} attempt ${attempt}: ${err.message}; retrying in ${delayMs}ms (${Math.round(remainingMs / 1000)}s deadline remaining)`,
77
+ `[LLM] ${category} on ${provider.name} attempt ${attempt}: ${err.message}; retrying in ${delayMs}ms (${Math.round(remainingMs / 1000)}s ${category} budget remaining)`,
73
78
  );
74
79
  },
75
80
  },