@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
@@ -12,10 +12,9 @@ export default class Known {
12
12
  core.on("handler", this.handler.bind(this));
13
13
  core.on("visible", this.full.bind(this));
14
14
  core.on("summarized", this.summary.bind(this));
15
- core.filter("assembly.system", this.assembleContext.bind(this), 100);
16
- // <known> is internal — written via <set path="known://...">. Hidden
17
- // from all model-facing tool lists. Handler still dispatches if the
18
- // model emits <known> directly out of habit.
15
+ core.filter("assembly.user", this.assembleSummarized.bind(this), 50);
16
+ core.filter("assembly.user", this.assembleVisible.bind(this), 75);
17
+ // Hidden tool: written via <set path="known://...">; handler tolerates direct <known>.
19
18
  core.markHidden();
20
19
  }
21
20
 
@@ -23,23 +22,20 @@ export default class Known {
23
22
  const { entries: store, sequence: turn, runId, loopId } = rummy;
24
23
  if (!entry.body) return;
25
24
 
26
- // Size gate
27
25
  const entryTokens = countTokens(entry.body);
28
26
  if (entryTokens > MAX_ENTRY_TOKENS) {
29
- const rejectPath = await store.slugPath(runId, "known", entry.body);
30
27
  await store.set({
31
28
  runId,
32
29
  turn,
33
- path: rejectPath,
30
+ loopId,
31
+ path: entry.resultPath,
34
32
  body: `Entry too large (${entryTokens} tokens, max ${MAX_ENTRY_TOKENS}). Sort the information, ideas, or plans carefully into multiple entries.`,
35
33
  state: "failed",
36
34
  outcome: `overflow:${entryTokens}`,
37
- loopId,
38
35
  });
39
36
  return;
40
37
  }
41
38
 
42
- // Resolve path: explicit or auto-generated slug
43
39
  let knownPath = entry.attributes?.path;
44
40
  if (knownPath && !knownPath.includes("://")) {
45
41
  knownPath = `known://${knownPath}`;
@@ -53,9 +49,7 @@ export default class Known {
53
49
  );
54
50
  }
55
51
 
56
- // Dedup: if path exists, update rather than duplicate. An empty
57
- // new body means "preserve the existing entry's body" (e.g. the
58
- // model is updating attributes only).
52
+ // Dedup: existing path update; empty body preserves existing body.
59
53
  const existing = await store.getEntriesByPattern(runId, knownPath, null);
60
54
  if (existing.length > 0) {
61
55
  const nextBody = entry.body === "" ? existing[0].body : entry.body;
@@ -86,28 +80,41 @@ export default class Known {
86
80
  return entry.body;
87
81
  }
88
82
 
89
- // Summarized knowns keep the first 500 characters so the model
90
- // doesn't lose the plot when budget auto-demotion kicks in on its
91
- // own work. Anything larger gets capped so a pathologically big
92
- // known doesn't saturate the packet at summarized visibility
93
- // either. Matches the pattern on `<prompt>` summarized view.
83
+ // Summarized: first 500 chars; matches <prompt> summarized.
94
84
  summary(entry) {
95
85
  if (!entry.body) return "";
96
86
  if (entry.body.length <= 500) return entry.body;
97
87
  return `${entry.body.slice(0, 500)}\n[truncated — promote to see the full body]`;
98
88
  }
99
89
 
100
- async assembleContext(content, ctx) {
101
- const entries = ctx.rows.filter((r) => r.category === "data");
90
+ // Identity-keyed summary lines: every data entry the run is tracking
91
+ // at visibility=visible or visibility=summarized.
92
+ async assembleSummarized(content, ctx) {
93
+ const entries = ctx.rows.filter(
94
+ (r) =>
95
+ r.category === "data" &&
96
+ (r.visibility === "visible" || r.visibility === "summarized"),
97
+ );
98
+ if (entries.length === 0) return content;
99
+ const lines = entries.map((e) =>
100
+ renderContextTag(e, e.sBody != null ? e.sBody : e.body),
101
+ );
102
+ return `${content}<summarized>\n${lines.join("\n")}\n</summarized>\n`;
103
+ }
104
+
105
+ async assembleVisible(content, ctx) {
106
+ const entries = ctx.rows.filter(
107
+ (r) => r.category === "data" && r.visibility === "visible",
108
+ );
102
109
  if (entries.length === 0) return content;
103
- const demotedSet = new Set(ctx.demoted);
104
- const lines = entries.map((e) => renderContextTag(e, demotedSet));
105
- return `${content}\n\n<context>\n${lines.join("\n")}\n</context>`;
110
+ const lines = entries.map((e) =>
111
+ renderContextTag(e, e.vBody != null ? e.vBody : e.body),
112
+ );
113
+ return `${content}<visible>\n${lines.join("\n")}\n</visible>\n`;
106
114
  }
107
115
  }
108
116
 
109
- function renderContextTag(entry, demotedSet) {
110
- // schemeOf() returns NULL / "" for bare file paths; translate for the tag.
117
+ function renderContextTag(entry, projectedBody) {
111
118
  const tag = entry.scheme ? entry.scheme : "file";
112
119
  const turn = entry.source_turn ? ` turn="${entry.source_turn}"` : "";
113
120
  const tokens = entry.aTokens != null ? ` tokens="${entry.aTokens}"` : "";
@@ -129,21 +136,16 @@ function renderContextTag(entry, demotedSet) {
129
136
  const stateAttr =
130
137
  entry.state && entry.state !== "resolved" ? ` state="${entry.state}"` : "";
131
138
  const outcomeAttr = entry.outcome ? ` outcome="${entry.outcome}"` : "";
132
- const visibility = entry.visibility
133
- ? ` visibility="${entry.visibility}"`
134
- : "";
135
- const flag = demotedSet?.has(entry.path) ? " demoted" : "";
136
- // Always render summary attribute on knowns — empty value hints the model
137
- // it forgot to add searchable keywords.
139
+ const visibility =
140
+ entry.visibility === "archived" ? ` visibility="archived"` : "";
138
141
  const summaryText =
139
142
  typeof attrs?.summary === "string"
140
143
  ? attrs.summary.replace(/"/g, "'").slice(0, 80)
141
144
  : "";
142
145
  const summary = ` summary="${summaryText}"`;
143
-
144
- const attrStr = `${turn}${status}${stateAttr}${outcomeAttr}${summary}${visibility}${tokens}${lines}${flag}`;
145
- if (entry.body) {
146
- return `<${tag} path="${entry.path}"${attrStr}>${entry.body}</${tag}>`;
146
+ const attrStr = `${turn}${status}${stateAttr}${outcomeAttr}${summary}${visibility}${tokens}${lines}`;
147
+ if (projectedBody) {
148
+ return `<${tag} path="${entry.path}"${attrStr}>${projectedBody}</${tag}>`;
147
149
  }
148
150
  return `<${tag} path="${entry.path}"${attrStr}/>`;
149
151
  }
@@ -29,7 +29,7 @@ size. Resolution:
29
29
  own body tokens.
30
30
  - `sh` and `env` own multiple streaming channels (`sh://turn_N/{slug}_N`)
31
31
  — no single target to point at. `tokens=` is omitted; the channels
32
- render their own tokens in `<context>`.
32
+ render their own tokens in `<visible>`.
33
33
 
34
34
  ## Behavior
35
35
 
@@ -43,6 +43,6 @@ Log entries (`log://turn_N/{action}/{slug}`) are audit records —
43
43
  summary, exit status, references to where the data lives — and never
44
44
  carry the payload itself. Payload for streaming actions lives under the
45
45
  producer's own scheme (`sh://`, `env://`, future `search://`, etc.) at
46
- `category=data`, and is rendered inside `<context>` by the known
46
+ `category=data`, and is rendered inside `<visible>` by the known
47
47
  plugin. Scheme determines category; data and logging never share a
48
48
  scheme. See [scheme_category_split](#scheme_category_split).
@@ -1,11 +1,6 @@
1
1
  import { stateToStatus } from "../../agent/httpStatus.js";
2
2
 
3
- // Schemes whose log body is an action summary, not the cost-bearing
4
- // content. For these, the action's cost lives on a separate data entry
5
- // (sh/env: streaming channels; set/mv/cp: the target entry). Report
6
- // tokens from the target when we can resolve it (set/mv/cp via
7
- // attrs.path); omit entirely for sh/env (multiple channels, no single
8
- // target to point at).
3
+ // sh/env span multiple channels; channels render their own tokens in <visible>.
9
4
  const STREAM_NO_TOKENS = new Set(["sh", "env"]);
10
5
 
11
6
  export default class Log {
@@ -17,10 +12,7 @@ export default class Log {
17
12
  }
18
13
 
19
14
  async assembleLog(content, ctx) {
20
- // Log includes action entries (scheme=log) AND prior prompts. The
21
- // most recent prompt is rendered separately by the prompt plugin
22
- // as `<prompt>`; everything older lives in the log so the model
23
- // can see the full question history across a sustained run.
15
+ // Includes prior prompts; the latest prompt is rendered separately as <prompt>.
24
16
  const latestPrompt = ctx.rows.findLast(
25
17
  (r) => r.category === "prompt" && r.scheme === "prompt",
26
18
  );
@@ -39,10 +31,7 @@ export default class Log {
39
31
  }
40
32
  }
41
33
 
42
- // Log paths are log://turn_N/action/slug. The second segment is the
43
- // action — the plugin/tool that produced this log entry (set, get,
44
- // search, update, error, etc.). Used as the XML tag name. Prompt
45
- // entries live at prompt://N; they render as <prompt> in history.
34
+ // Action segment of log://turn_N/action/slug XML tag.
46
35
  function actionFromPath(path) {
47
36
  if (path?.startsWith("prompt://")) return "prompt";
48
37
  const match = path?.match(/^log:\/\/turn_\d+\/([^/]+)\//);
@@ -63,23 +52,13 @@ function renderLogTag(entry, rowsByPath) {
63
52
  : entry.state
64
53
  ? stateToStatus(entry.state, entry.outcome)
65
54
  : null;
66
- // Prompts are uniformly status=200 uniform value carries no signal
67
- // and read as "settled, no action needed." Suppress so cultivation
68
- // vocabulary (vary, demote, archive) applies to prompts the same
69
- // way it applies to other log entries.
55
+ // Suppress status on prompts; uniform 200 carries no signal.
70
56
  const status =
71
57
  statusValue != null && action !== "prompt"
72
58
  ? ` status="${statusValue}"`
73
59
  : "";
74
60
  const outcomeAttr = entry.outcome ? ` outcome="${entry.outcome}"` : "";
75
- // `tokens=` is the promotion premium (aTokens) of the thing this tag
76
- // represents — what the model would free by demoting it. For actions
77
- // that reference a separate data entry (get/set/mv/cp), resolve via
78
- // attrs.path and report the target's aTokens. For actions whose log
79
- // body IS the cost-bearing content (search/update/error/ask_user,
80
- // plus <get> slice reads), use the log entry's own aTokens. sh/env
81
- // span multiple channel entries and are omitted — the channels
82
- // render their own tokens in <context>.
61
+ // tokens = aTokens of the thing this tag represents (target via attrs.path, else self).
83
62
  const isSlice = attrs?.lineStart != null;
84
63
  const targetEntry = attrs?.path ? rowsByPath.get(attrs.path) : null;
85
64
  let tokenSource = null;
@@ -106,14 +85,8 @@ function renderLogTag(entry, rowsByPath) {
106
85
  typeof attrs?.query === "string" ? ` query="${attrs.query}"` : "";
107
86
  const command =
108
87
  typeof attrs?.command === "string" ? ` command="${attrs.command}"` : "";
109
- // target= is the path the action touched (e.g. the file/known that was
110
- // set, the URL that was fetched). Plugins store it in attrs.path when
111
- // they write the log entry.
112
88
  const target = attrs?.path ? ` target="${attrs.path}"` : "";
113
- // Slice reads tag the log entry with lineStart/lineEnd/totalLines so
114
- // the <get> tag surfaces `lines="a-b/total"` — a concrete handle for
115
- // the model to re-issue or compare against another slice. Non-slice
116
- // entries surface the simple `lines="N"` from the projected body.
89
+ // Slice reads emit lines="a-b/total"; others emit simple lines="N".
117
90
  const lines = isSlice
118
91
  ? ` lines="${attrs.lineStart}-${attrs.lineEnd}/${attrs.totalLines}"`
119
92
  : lineSource != null
@@ -1,17 +1,13 @@
1
+ import config from "../../agent/config.js";
1
2
  import msg from "../../agent/messages.js";
3
+ import { chatCompletionStream } from "../../llm/openaiStream.js";
4
+ import { retryWithBackoff } from "../../llm/retry.js";
2
5
 
3
- const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
- if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
6
+ const { FETCH_TIMEOUT } = config;
5
7
 
6
8
  const PROVIDER = "ollama";
7
9
 
8
- /**
9
- * Ollama LLM provider plugin. Registers with hooks.llm.providers if
10
- * OLLAMA_BASE_URL is set; inert otherwise. Handles model aliases of the
11
- * form `ollama/{modelName}` — e.g. `ollama/llama3.1:8b` or
12
- * `ollama/library/qwen:7b` (Ollama accepts both bare and
13
- * registry-qualified model names).
14
- */
10
+ // Inert unless OLLAMA_BASE_URL is set; ollama/{model[/registry]} aliases.
15
11
  export default class Ollama {
16
12
  #baseUrl;
17
13
 
@@ -41,70 +37,58 @@ export default class Ollama {
41
37
  ? AbortSignal.any([options.signal, timeoutSignal])
42
38
  : timeoutSignal;
43
39
 
44
- const response = await fetch(`${this.#baseUrl}/v1/chat/completions`, {
45
- method: "POST",
46
- headers: { "Content-Type": "application/json" },
47
- body: JSON.stringify(body),
48
- signal,
49
- });
50
-
51
- if (!response.ok) {
52
- const error = await response.text();
53
- throw new Error(
54
- msg("error.ollama_api", { status: `${response.status} - ${error}` }),
55
- );
56
- }
57
-
58
- const data = await response.json();
59
-
60
- for (const choice of data.choices) {
61
- const m = choice.message;
62
- if (!m) continue;
63
- const parts = [m.reasoning_content, m.reasoning, m.thinking].filter(
64
- Boolean,
65
- );
66
- m.reasoning_content =
67
- parts.length > 0 ? [...new Set(parts)].join("\n") : null;
40
+ try {
41
+ return await chatCompletionStream({
42
+ url: `${this.#baseUrl}/v1/chat/completions`,
43
+ headers: {},
44
+ body,
45
+ signal,
46
+ });
47
+ } catch (err) {
48
+ if (err.status) {
49
+ throw new Error(
50
+ msg("error.ollama_api", { status: `${err.status} - ${err.body}` }),
51
+ );
52
+ }
53
+ throw err;
68
54
  }
69
-
70
- return data;
71
55
  }
72
56
 
73
57
  async #getContextSize(model) {
74
- for (let attempt = 0; attempt < 3; attempt++) {
75
- try {
76
- const response = await fetch(`${this.#baseUrl}/api/show`, {
77
- method: "POST",
78
- headers: { "Content-Type": "application/json" },
79
- body: JSON.stringify({ model }),
80
- signal: AbortSignal.timeout(FETCH_TIMEOUT),
81
- });
82
- if (!response.ok) {
83
- throw new Error(
84
- msg("error.ollama_show_failed", {
85
- status: response.status,
86
- baseUrl: this.#baseUrl,
87
- }),
88
- );
89
- }
90
- const data = await response.json();
91
- if (data.model_info) {
92
- for (const [key, value] of Object.entries(data.model_info)) {
93
- if (key.endsWith(".context_length")) return value;
94
- }
95
- }
96
- throw new Error(msg("error.ollama_no_context_length", { model }));
97
- } catch (err) {
98
- if (err.message.includes("Ollama")) throw err;
99
- if (attempt < 2) {
100
- await new Promise((r) => setTimeout(r, (attempt + 1) * 2000));
101
- continue;
102
- }
58
+ const fetchContext = async () => {
59
+ const response = await fetch(`${this.#baseUrl}/api/show`, {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({ model }),
63
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
64
+ });
65
+ if (!response.ok) {
103
66
  throw new Error(
104
- msg("error.ollama_unreachable", { baseUrl: this.#baseUrl }),
105
- { cause: err },
67
+ msg("error.ollama_show_failed", {
68
+ status: response.status,
69
+ baseUrl: this.#baseUrl,
70
+ }),
106
71
  );
107
72
  }
73
+ const data = await response.json();
74
+ if (data.model_info) {
75
+ for (const [key, value] of Object.entries(data.model_info)) {
76
+ if (key.endsWith(".context_length")) return value;
77
+ }
78
+ }
79
+ throw new Error(msg("error.ollama_no_context_length", { model }));
80
+ };
81
+ try {
82
+ return await retryWithBackoff(fetchContext, {
83
+ deadlineMs: FETCH_TIMEOUT,
84
+ isRetryable: (err) => !err.message.includes("Ollama"),
85
+ });
86
+ } catch (err) {
87
+ if (err.message.includes("Ollama")) throw err;
88
+ throw new Error(
89
+ msg("error.ollama_unreachable", { baseUrl: this.#baseUrl }),
90
+ { cause: err },
91
+ );
108
92
  }
109
93
  }
110
94
  }
@@ -1,16 +1,12 @@
1
+ import config from "../../agent/config.js";
1
2
  import msg from "../../agent/messages.js";
3
+ import { chatCompletionStream } from "../../llm/openaiStream.js";
2
4
 
3
- const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
- if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
5
+ const { FETCH_TIMEOUT } = config;
5
6
 
6
7
  const PROVIDER = "openai";
7
8
 
8
- /**
9
- * OpenAI-compatible LLM provider plugin. Registers with hooks.llm.providers
10
- * if OPENAI_BASE_URL is set in env; silently inert otherwise. Handles
11
- * model aliases of the form `openai/{modelName}` — the first path
12
- * segment picks the provider, the rest is whatever the API expects.
13
- */
9
+ // Inert unless OPENAI_BASE_URL is set; openai/{model} aliases.
14
10
  export default class OpenAi {
15
11
  #baseUrl;
16
12
  #apiKey;
@@ -42,47 +38,36 @@ export default class OpenAi {
42
38
  ? AbortSignal.any([options.signal, timeoutSignal])
43
39
  : timeoutSignal;
44
40
 
45
- const headers = { "Content-Type": "application/json" };
41
+ const headers = {};
46
42
  if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
47
43
 
48
- const response = await fetch(`${this.#baseUrl}/v1/chat/completions`, {
49
- method: "POST",
50
- headers,
51
- body: JSON.stringify(body),
52
- signal,
53
- });
54
-
55
- if (!response.ok) {
56
- const error = await response.text();
57
- throw new Error(
58
- msg("error.openai_api", { status: `${response.status} - ${error}` }),
59
- );
60
- }
61
-
62
- const data = await response.json();
63
-
64
- for (const choice of data.choices) {
65
- const m = choice.message;
66
- if (!m) continue;
67
- const parts = [m.reasoning_content, m.reasoning, m.thinking].filter(
68
- Boolean,
69
- );
70
- m.reasoning_content =
71
- parts.length > 0 ? [...new Set(parts)].join("\n") : null;
72
-
73
- // Full reasoning dump is centralized in telemetry.js on every
74
- // provider — keeping it out of provider plugins avoids double
75
- // printing and per-provider drift.
44
+ try {
45
+ return await chatCompletionStream({
46
+ url: `${this.#baseUrl}/v1/chat/completions`,
47
+ headers,
48
+ body,
49
+ signal,
50
+ });
51
+ } catch (err) {
52
+ if (err.status) {
53
+ const wrapped = new Error(
54
+ msg("error.openai_api", { status: `${err.status} - ${err.body}` }),
55
+ { cause: err },
56
+ );
57
+ wrapped.status = err.status;
58
+ wrapped.body = err.body;
59
+ wrapped.retryAfter = err.retryAfter;
60
+ throw wrapped;
61
+ }
62
+ throw err;
76
63
  }
77
-
78
- return data;
79
64
  }
80
65
 
81
66
  async #getContextSize(_model) {
82
67
  const headers = { "Content-Type": "application/json" };
83
68
  if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
84
69
 
85
- // Try /props first llama.cpp exposes runtime n_ctx here.
70
+ // llama.cpp /props returns runtime n_ctx; absent on vanilla OpenAI.
86
71
  try {
87
72
  const propsResponse = await fetch(`${this.#baseUrl}/props`, {
88
73
  headers,
@@ -93,10 +78,7 @@ export default class OpenAi {
93
78
  const runtimeCtx = props?.default_generation_settings?.n_ctx;
94
79
  if (runtimeCtx) return runtimeCtx;
95
80
  }
96
- } catch (_err) {
97
- // /props is a llama.cpp extension; absent on vanilla OpenAI.
98
- // Fall through to /v1/models for the training-context-size hint.
99
- }
81
+ } catch (_err) {}
100
82
 
101
83
  // Fall back to /v1/models for training context.
102
84
  const response = await fetch(`${this.#baseUrl}/v1/models`, {
@@ -1,20 +1,12 @@
1
+ import config from "../../agent/config.js";
1
2
  import msg from "../../agent/messages.js";
3
+ import { chatCompletionStream } from "../../llm/openaiStream.js";
2
4
 
3
- const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
- if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
5
+ const { FETCH_TIMEOUT } = config;
5
6
 
6
7
  const PROVIDER = "openrouter";
7
8
 
8
- /**
9
- * OpenRouter LLM provider plugin. Handles model aliases of the form
10
- * `openrouter/{publisher}/{modelName}`. Strips only the provider
11
- * segment — OpenRouter's own API expects the `publisher/model` form,
12
- * so that's exactly what's passed through to it (e.g.
13
- * `openrouter/anthropic/claude-3-opus` → API receives
14
- * `anthropic/claude-3-opus`).
15
- *
16
- * Inert if OPENROUTER_API_KEY / OPENROUTER_BASE_URL aren't set.
17
- */
9
+ // Inert unless OPENROUTER_API_KEY+OPENROUTER_BASE_URL set; openrouter/{publisher}/{model} aliases.
18
10
  export default class OpenRouter {
19
11
  #apiKey;
20
12
  #baseUrl;
@@ -48,52 +40,36 @@ export default class OpenRouter {
48
40
  ? AbortSignal.any([options.signal, timeoutSignal])
49
41
  : timeoutSignal;
50
42
 
51
- const response = await fetch(`${this.#baseUrl}/chat/completions`, {
52
- method: "POST",
53
- headers: {
54
- Authorization: `Bearer ${this.#apiKey}`,
55
- "Content-Type": "application/json",
56
- "HTTP-Referer": process.env.RUMMY_HTTP_REFERER,
57
- "X-Title": process.env.RUMMY_X_TITLE,
58
- },
59
- body: JSON.stringify(body),
60
- signal,
61
- });
43
+ const headers = {
44
+ Authorization: `Bearer ${this.#apiKey}`,
45
+ "HTTP-Referer": process.env.RUMMY_HTTP_REFERER,
46
+ "X-Title": process.env.RUMMY_X_TITLE,
47
+ };
62
48
 
63
- if (!response.ok) {
64
- const error = await response.text();
65
- if (response.status === 401 || response.status === 403) {
49
+ try {
50
+ return await chatCompletionStream({
51
+ url: `${this.#baseUrl}/chat/completions`,
52
+ headers,
53
+ body,
54
+ signal,
55
+ });
56
+ } catch (err) {
57
+ if (err.status === 401 || err.status === 403) {
66
58
  throw new Error(
67
59
  msg("error.openrouter_auth", {
68
- status: `${response.status} - ${error}`,
60
+ status: `${err.status} - ${err.body}`,
69
61
  }),
70
62
  );
71
63
  }
72
- throw new Error(
73
- msg("error.openrouter_api", {
74
- status: `${response.status} - ${error}`,
75
- }),
76
- );
77
- }
78
- const data = await response.json();
79
-
80
- for (const choice of data.choices) {
81
- const cm = choice.message;
82
- if (!cm) continue;
83
- const details = cm.reasoning_details
84
- ? cm.reasoning_details.map((d) => d.text)
85
- : [];
86
- const parts = [
87
- cm.reasoning_content,
88
- cm.reasoning,
89
- cm.thinking,
90
- ...details,
91
- ].filter(Boolean);
92
- cm.reasoning_content =
93
- parts.length > 0 ? [...new Set(parts)].join("\n") : null;
64
+ if (err.status) {
65
+ throw new Error(
66
+ msg("error.openrouter_api", {
67
+ status: `${err.status} - ${err.body}`,
68
+ }),
69
+ );
70
+ }
71
+ throw err;
94
72
  }
95
-
96
- return data;
97
73
  }
98
74
 
99
75
  async #getContextSize(model) {
@@ -101,7 +77,7 @@ export default class OpenRouter {
101
77
 
102
78
  const res = await fetch(`${this.#baseUrl}/models`, {
103
79
  headers: { Authorization: `Bearer ${this.#apiKey}` },
104
- signal: AbortSignal.timeout(5000),
80
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
105
81
  });
106
82
  if (!res.ok) {
107
83
  throw new Error(
@@ -6,8 +6,14 @@ was started in `ask` mode.
6
6
 
7
7
  ## Registration
8
8
 
9
- - **Filter**: `entry.recording` (priority 1) — runs before a command
10
- becomes an entry.
9
+ - **Filter**: `entry.recording` (priority 1) — the validation /
10
+ transform hook in TurnExecutor's RECORD phase. Runs after the
11
+ command is parsed but before the audit row is committed. Returning
12
+ an object with `state: "failed"` (or `"cancelled"`) short-circuits
13
+ recording and skips DISPATCH for that command. Plugins may also
14
+ return a transformed entry (modified body, attributes, path) for
15
+ the recorder to commit. Filter signature:
16
+ `(entry, { store, runId, turn, loopId, mode })`.
11
17
 
12
18
  ## Rejections (ask mode only)
13
19
 
@@ -1,39 +1,28 @@
1
1
  import Entries from "../../agent/Entries.js";
2
2
 
3
3
  export default class Policy {
4
- #core;
5
-
6
4
  constructor(core) {
7
- this.#core = core;
8
5
  core.filter("entry.recording", this.#enforceAskMode.bind(this), 1);
9
6
  }
10
7
 
11
- async #reject(ctx, message) {
12
- await this.#core.hooks.error.log.emit({
13
- store: ctx.store,
14
- runId: ctx.runId,
15
- turn: ctx.turn,
16
- loopId: ctx.loopId,
17
- message,
18
- });
8
+ #fail(entry, body) {
9
+ return { ...entry, body, state: "failed", outcome: "permission" };
19
10
  }
20
11
 
21
12
  async #enforceAskMode(entry, ctx) {
22
13
  if (ctx.mode !== "ask") return entry;
23
14
 
24
15
  if (entry.scheme === "sh") {
25
- await this.#reject(ctx, "Rejected <sh> in ask mode");
26
- return { ...entry, state: "failed", outcome: "permission" };
16
+ return this.#fail(entry, "Rejected <sh> in ask mode");
27
17
  }
28
18
 
29
19
  if (entry.scheme === "set" && entry.attributes?.path) {
30
20
  const scheme = Entries.scheme(entry.attributes.path);
31
21
  if (scheme === null && entry.body) {
32
- await this.#reject(
33
- ctx,
22
+ return this.#fail(
23
+ entry,
34
24
  `Rejected file edit to ${entry.attributes.path} in ask mode`,
35
25
  );
36
- return { ...entry, state: "failed", outcome: "permission" };
37
26
  }
38
27
  }
39
28
 
@@ -41,19 +30,17 @@ export default class Policy {
41
30
  const pathAttr = entry.attributes?.path || entry.path;
42
31
  const scheme = Entries.scheme(pathAttr);
43
32
  if (scheme === null) {
44
- await this.#reject(ctx, `Rejected file rm of ${pathAttr} in ask mode`);
45
- return { ...entry, state: "failed", outcome: "permission" };
33
+ return this.#fail(entry, `Rejected file rm of ${pathAttr} in ask mode`);
46
34
  }
47
35
  }
48
36
 
49
37
  if (entry.scheme === "mv" || entry.scheme === "cp") {
50
38
  const destScheme = Entries.scheme(entry.attributes?.to);
51
39
  if (destScheme === null) {
52
- await this.#reject(
53
- ctx,
40
+ return this.#fail(
41
+ entry,
54
42
  `Rejected ${entry.scheme} to file ${entry.attributes?.to} in ask mode`,
55
43
  );
56
- return { ...entry, state: "failed", outcome: "permission" };
57
44
  }
58
45
  }
59
46