@possumtech/rummy 0.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/.env.example +21 -5
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +850 -373
  5. package/bin/demo.js +166 -0
  6. package/bin/rummy.js +9 -3
  7. package/biome/no-fallbacks.grit +50 -0
  8. package/lang/en.json +2 -2
  9. package/migrations/001_initial_schema.sql +88 -37
  10. package/package.json +6 -4
  11. package/service.js +50 -9
  12. package/src/agent/AgentLoop.js +460 -330
  13. package/src/agent/ContextAssembler.js +4 -4
  14. package/src/agent/Entries.js +655 -0
  15. package/src/agent/ProjectAgent.js +30 -18
  16. package/src/agent/TurnExecutor.js +229 -421
  17. package/src/agent/XmlParser.js +99 -33
  18. package/src/agent/budget.js +56 -0
  19. package/src/agent/errors.js +22 -0
  20. package/src/agent/httpStatus.js +39 -0
  21. package/src/agent/known_checks.sql +8 -4
  22. package/src/agent/known_queries.sql +9 -13
  23. package/src/agent/known_store.sql +275 -125
  24. package/src/agent/materializeContext.js +102 -0
  25. package/src/agent/runs.sql +10 -7
  26. package/src/agent/schemes.sql +14 -3
  27. package/src/agent/turns.sql +9 -9
  28. package/src/hooks/HookRegistry.js +6 -5
  29. package/src/hooks/Hooks.js +44 -3
  30. package/src/hooks/PluginContext.js +29 -21
  31. package/src/{server → hooks}/RpcRegistry.js +2 -1
  32. package/src/hooks/RummyContext.js +135 -35
  33. package/src/hooks/ToolRegistry.js +21 -16
  34. package/src/llm/LlmProvider.js +64 -90
  35. package/src/llm/errors.js +21 -0
  36. package/src/plugins/ask_user/README.md +1 -1
  37. package/src/plugins/ask_user/ask_user.js +37 -12
  38. package/src/plugins/ask_user/ask_userDoc.js +2 -25
  39. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  40. package/src/plugins/budget/README.md +27 -25
  41. package/src/plugins/budget/budget.js +260 -88
  42. package/src/plugins/cp/README.md +2 -2
  43. package/src/plugins/cp/cp.js +29 -11
  44. package/src/plugins/cp/cpDoc.js +2 -15
  45. package/src/plugins/cp/cpDoc.md +7 -0
  46. package/src/plugins/engine/README.md +2 -2
  47. package/src/plugins/engine/engine.sql +4 -4
  48. package/src/plugins/engine/turn_context.sql +10 -10
  49. package/src/plugins/env/README.md +20 -5
  50. package/src/plugins/env/env.js +45 -6
  51. package/src/plugins/env/envDoc.js +2 -23
  52. package/src/plugins/env/envDoc.md +13 -0
  53. package/src/plugins/error/README.md +16 -0
  54. package/src/plugins/error/error.js +151 -0
  55. package/src/plugins/file/README.md +6 -6
  56. package/src/plugins/file/file.js +15 -2
  57. package/src/plugins/get/README.md +1 -1
  58. package/src/plugins/get/get.js +103 -48
  59. package/src/plugins/get/getDoc.js +2 -32
  60. package/src/plugins/get/getDoc.md +36 -0
  61. package/src/plugins/hedberg/README.md +1 -2
  62. package/src/plugins/hedberg/hedberg.js +8 -4
  63. package/src/plugins/hedberg/matcher.js +16 -17
  64. package/src/plugins/hedberg/normalize.js +0 -48
  65. package/src/plugins/helpers.js +42 -2
  66. package/src/plugins/index.js +146 -123
  67. package/src/plugins/instructions/README.md +35 -9
  68. package/src/plugins/instructions/instructions.js +122 -9
  69. package/src/plugins/instructions/instructions.md +25 -0
  70. package/src/plugins/instructions/instructions_104.md +7 -0
  71. package/src/plugins/instructions/instructions_105.md +46 -0
  72. package/src/plugins/instructions/instructions_106.md +0 -0
  73. package/src/plugins/instructions/instructions_107.md +0 -0
  74. package/src/plugins/instructions/instructions_108.md +8 -0
  75. package/src/plugins/instructions/protocol.js +12 -0
  76. package/src/plugins/known/README.md +2 -2
  77. package/src/plugins/known/known.js +67 -36
  78. package/src/plugins/known/knownDoc.js +2 -17
  79. package/src/plugins/known/knownDoc.md +8 -0
  80. package/src/plugins/log/README.md +48 -0
  81. package/src/plugins/log/log.js +109 -0
  82. package/src/plugins/mv/README.md +2 -2
  83. package/src/plugins/mv/mv.js +55 -22
  84. package/src/plugins/mv/mvDoc.js +2 -18
  85. package/src/plugins/mv/mvDoc.md +10 -0
  86. package/src/plugins/ollama/README.md +15 -0
  87. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  88. package/src/plugins/openai/README.md +17 -0
  89. package/src/plugins/openai/openai.js +120 -0
  90. package/src/plugins/openrouter/README.md +27 -0
  91. package/src/plugins/openrouter/openrouter.js +121 -0
  92. package/src/plugins/persona/README.md +20 -0
  93. package/src/plugins/persona/persona.js +9 -16
  94. package/src/plugins/policy/README.md +21 -0
  95. package/src/plugins/policy/policy.js +29 -14
  96. package/src/plugins/prompt/README.md +1 -1
  97. package/src/plugins/prompt/prompt.js +58 -16
  98. package/src/plugins/rm/README.md +1 -1
  99. package/src/plugins/rm/rm.js +56 -12
  100. package/src/plugins/rm/rmDoc.js +2 -20
  101. package/src/plugins/rm/rmDoc.md +13 -0
  102. package/src/plugins/rpc/README.md +2 -2
  103. package/src/plugins/rpc/rpc.js +515 -296
  104. package/src/plugins/set/README.md +1 -1
  105. package/src/plugins/set/set.js +318 -75
  106. package/src/plugins/set/setDoc.js +2 -35
  107. package/src/plugins/set/setDoc.md +22 -0
  108. package/src/plugins/sh/README.md +28 -5
  109. package/src/plugins/sh/sh.js +50 -6
  110. package/src/plugins/sh/shDoc.js +2 -23
  111. package/src/plugins/sh/shDoc.md +13 -0
  112. package/src/plugins/skill/README.md +23 -0
  113. package/src/plugins/skill/skill.js +14 -18
  114. package/src/plugins/stream/README.md +101 -0
  115. package/src/plugins/stream/stream.js +290 -0
  116. package/src/plugins/telemetry/README.md +1 -1
  117. package/src/plugins/telemetry/telemetry.js +129 -80
  118. package/src/plugins/think/README.md +1 -1
  119. package/src/plugins/think/think.js +12 -0
  120. package/src/plugins/think/thinkDoc.js +2 -15
  121. package/src/plugins/think/thinkDoc.md +7 -0
  122. package/src/plugins/unknown/README.md +3 -3
  123. package/src/plugins/unknown/unknown.js +47 -19
  124. package/src/plugins/unknown/unknownDoc.js +2 -21
  125. package/src/plugins/unknown/unknownDoc.md +11 -0
  126. package/src/plugins/update/README.md +1 -1
  127. package/src/plugins/update/update.js +67 -5
  128. package/src/plugins/update/updateDoc.js +2 -30
  129. package/src/plugins/update/updateDoc.md +8 -0
  130. package/src/plugins/xai/README.md +23 -0
  131. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  132. package/src/server/ClientConnection.js +64 -37
  133. package/src/server/SocketServer.js +23 -10
  134. package/src/server/protocol.js +11 -0
  135. package/src/sql/v_model_context.sql +27 -31
  136. package/src/sql/v_run_log.sql +9 -14
  137. package/EXCEPTIONS.md +0 -46
  138. package/FIDELITY_CONTRACT.md +0 -172
  139. package/src/agent/KnownStore.js +0 -337
  140. package/src/agent/ResponseHealer.js +0 -241
  141. package/src/llm/OpenAiClient.js +0 -100
  142. package/src/llm/OpenRouterClient.js +0 -100
  143. package/src/plugins/budget/recovery.js +0 -47
  144. package/src/plugins/instructions/preamble.md +0 -45
  145. package/src/plugins/performed/README.md +0 -15
  146. package/src/plugins/performed/performed.js +0 -45
  147. package/src/plugins/previous/README.md +0 -16
  148. package/src/plugins/previous/previous.js +0 -56
  149. package/src/plugins/progress/README.md +0 -16
  150. package/src/plugins/progress/progress.js +0 -43
  151. package/src/plugins/summarize/README.md +0 -19
  152. package/src/plugins/summarize/summarize.js +0 -32
  153. package/src/plugins/summarize/summarizeDoc.js +0 -27
@@ -1,6 +1,8 @@
1
- import KnownStore from "../../agent/KnownStore.js";
1
+ import Entries from "../../agent/Entries.js";
2
2
  import docs from "./mvDoc.js";
3
3
 
4
+ const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
5
+
4
6
  export default class Mv {
5
7
  #core;
6
8
 
@@ -8,43 +10,56 @@ export default class Mv {
8
10
  this.#core = core;
9
11
  core.registerScheme();
10
12
  core.on("handler", this.handler.bind(this));
11
- core.on("promoted", this.full.bind(this));
12
- core.on("demoted", this.summary.bind(this));
13
+ core.on("visible", this.full.bind(this));
14
+ core.on("summarized", this.summary.bind(this));
13
15
  core.filter("instructions.toolDocs", async (docsMap) => {
14
16
  docsMap.mv = docs;
15
17
  return docsMap;
16
18
  });
19
+ core.on("proposal.accepted", this.#onAccepted.bind(this));
20
+ }
21
+
22
+ async #onAccepted(ctx) {
23
+ const m = LOG_ACTION_RE.exec(ctx.path);
24
+ if (m?.[1] !== "mv") return;
25
+ if (!ctx.attrs?.isMove || !ctx.attrs?.from) return;
26
+ await ctx.entries.rm({ runId: ctx.runId, path: ctx.attrs.from });
17
27
  }
18
28
 
19
29
  async handler(entry, rummy) {
20
30
  const { entries: store, sequence: turn, runId, loopId } = rummy;
21
31
  const { path, to } = entry.attributes;
22
- const VALID = { stored: 1, summary: 1, index: 1, full: 1, archive: 1 };
23
- const fidelity = VALID[entry.attributes.fidelity]
24
- ? entry.attributes.fidelity
32
+ const VALID = { visible: 1, summarized: 1, archived: 1 };
33
+ const visibility = VALID[entry.attributes.visibility]
34
+ ? entry.attributes.visibility
25
35
  : undefined;
26
36
 
27
- // Fidelity-in-place: no destination, change visibility of matched entries
28
- if (fidelity && !to) {
37
+ // Visibility-in-place: no destination, change visibility of matched entries
38
+ if (visibility && !to) {
29
39
  const matches = await store.getEntriesByPattern(runId, path);
30
40
  for (const match of matches)
31
- await store.setFidelity(runId, match.path, fidelity);
32
- const label = `set to ${fidelity}`;
33
- await store.upsert(
41
+ await store.set({
42
+ runId: runId,
43
+ path: match.path,
44
+ visibility: visibility,
45
+ });
46
+ const label = `set to ${visibility}`;
47
+ await store.set({
34
48
  runId,
35
49
  turn,
36
- entry.resultPath,
37
- `${matches.map((m) => m.path).join(", ")} ${label}`,
38
- 200,
39
- { fidelity: "archived", loopId },
40
- );
50
+ path: entry.resultPath,
51
+ body: `${matches.map((m) => m.path).join(", ")} ${label}`,
52
+ state: "resolved",
53
+ visibility: "archived",
54
+ loopId,
55
+ });
41
56
  return;
42
57
  }
43
58
 
44
59
  const source = await store.getBody(runId, path);
45
60
  if (source === null) return;
46
61
 
47
- const destScheme = KnownStore.scheme(to);
62
+ const destScheme = Entries.scheme(to);
48
63
  const existing = await store.getBody(runId, to);
49
64
  const warning =
50
65
  existing !== null && destScheme !== null
@@ -53,14 +68,32 @@ export default class Mv {
53
68
 
54
69
  const body = `${path} ${to}`;
55
70
  if (destScheme === null) {
56
- await store.upsert(runId, turn, entry.resultPath, body, 202, {
71
+ await store.set({
72
+ runId,
73
+ turn,
74
+ path: entry.resultPath,
75
+ body,
76
+ state: "proposed",
57
77
  attributes: { from: path, to, isMove: true, warning },
58
78
  loopId,
59
79
  });
60
80
  } else {
61
- await store.upsert(runId, turn, to, source, 200, { fidelity, loopId });
62
- await store.remove(runId, path);
63
- await store.upsert(runId, turn, entry.resultPath, body, 200, {
81
+ await store.set({
82
+ runId,
83
+ turn,
84
+ path: to,
85
+ body: source,
86
+ state: "resolved",
87
+ visibility,
88
+ loopId,
89
+ });
90
+ await store.rm({ runId: runId, path: path });
91
+ await store.set({
92
+ runId,
93
+ turn,
94
+ path: entry.resultPath,
95
+ body,
96
+ state: "resolved",
64
97
  attributes: { from: path, to, isMove: true, warning },
65
98
  loopId,
66
99
  });
@@ -68,7 +101,7 @@ export default class Mv {
68
101
  }
69
102
 
70
103
  full(entry) {
71
- return `# mv ${entry.attributes.from || ""} ${entry.attributes.to || ""}`;
104
+ return `# mv ${entry.attributes.from} ${entry.attributes.to}`;
72
105
  }
73
106
 
74
107
  summary() {
@@ -1,19 +1,3 @@
1
- // Tool doc for <mv>. Each entry: [text, rationale].
2
- // Text goes to the model. Rationale stays in source.
3
- // Changing ANY line requires reading ALL rationales first.
4
- const LINES = [
5
- [
6
- '## <mv path="[source]">[destination]</mv> - Move or rename a file or entry',
7
- ],
8
- [
9
- 'Example: <mv path="known://active_task">known://completed_task</mv>',
10
- "Entry rename. Most common mv use case.",
11
- ],
12
- ['Example: <mv path="src/old_name.js">src/new_name.js</mv>', "File rename."],
13
- [
14
- 'Example: <mv path="known://project/*" fidelity="demoted"/>',
15
- "Batch fidelity change via pattern. No destination = fidelity in place.",
16
- ],
17
- ];
1
+ import { loadDoc } from "../helpers.js";
18
2
 
19
- export default LINES.map(([text]) => text).join("\n");
3
+ export default loadDoc(import.meta.url, "mvDoc.md");
@@ -0,0 +1,10 @@
1
+ ## <mv path="[source]">[destination]</mv> - Move or rename a file or entry
2
+
3
+ Example: <mv path="known://active_task">known://completed_task</mv>
4
+ <!-- Entry rename. Most common mv use case. -->
5
+
6
+ Example: <mv path="src/old_name.js">src/new_name.js</mv>
7
+ <!-- File rename. -->
8
+
9
+ Example: <mv path="known://project/*" visibility="summarized"/>
10
+ <!-- Batch visibility change via pattern. No destination = visibility in place. -->
@@ -0,0 +1,15 @@
1
+ # ollama
2
+
3
+ Ollama LLM provider. Handles model aliases prefixed with `ollama/`
4
+ (e.g. `ollama/llama3.1:8b`).
5
+
6
+ ## Env
7
+
8
+ - `OLLAMA_BASE_URL` — base URL (e.g. `http://localhost:11434`).
9
+ Plugin is inert if unset.
10
+
11
+ ## Context Size
12
+
13
+ Calls `/api/show` for the requested model and scans `model_info` for
14
+ any `*.context_length` key. Retries up to 3× with exponential backoff
15
+ on non-Ollama transient errors.
@@ -1,19 +1,42 @@
1
- import msg from "../agent/messages.js";
1
+ import msg from "../../agent/messages.js";
2
2
 
3
- export default class OllamaClient {
3
+ const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
+ if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
5
+
6
+ const PROVIDER = "ollama";
7
+
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
+ */
15
+ export default class Ollama {
4
16
  #baseUrl;
5
17
 
6
- constructor(baseUrl) {
18
+ constructor(core) {
19
+ const baseUrl = process.env.OLLAMA_BASE_URL;
20
+ if (!baseUrl) return;
7
21
  this.#baseUrl = baseUrl;
22
+
23
+ const wireModel = (alias) => alias.split("/").slice(1).join("/");
24
+
25
+ core.hooks.llm.providers.push({
26
+ name: PROVIDER,
27
+ matches: (model) => model.split("/")[0] === PROVIDER,
28
+ completion: (messages, model, options) =>
29
+ this.#completion(messages, wireModel(model), options),
30
+ getContextSize: (model) => this.#getContextSize(wireModel(model)),
31
+ });
8
32
  }
9
33
 
10
- async completion(messages, model, options = {}) {
34
+ async #completion(messages, model, options = {}) {
11
35
  const body = { model, messages, think: true };
12
36
  if (options.temperature !== undefined)
13
37
  body.temperature = options.temperature;
14
38
 
15
- const timeout = Number(process.env.RUMMY_FETCH_TIMEOUT) || 30_000;
16
- const timeoutSignal = AbortSignal.timeout(timeout);
39
+ const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT);
17
40
  const signal = options.signal
18
41
  ? AbortSignal.any([options.signal, timeoutSignal])
19
42
  : timeoutSignal;
@@ -34,29 +57,27 @@ export default class OllamaClient {
34
57
 
35
58
  const data = await response.json();
36
59
 
37
- for (const choice of data.choices || []) {
38
- const msg = choice.message;
39
- if (!msg) continue;
40
- const parts = [msg.reasoning_content, msg.reasoning, msg.thinking].filter(
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(
41
64
  Boolean,
42
65
  );
43
- msg.reasoning_content =
66
+ m.reasoning_content =
44
67
  parts.length > 0 ? [...new Set(parts)].join("\n") : null;
45
68
  }
46
69
 
47
70
  return data;
48
71
  }
49
72
 
50
- async getContextSize(model) {
73
+ async #getContextSize(model) {
51
74
  for (let attempt = 0; attempt < 3; attempt++) {
52
75
  try {
53
76
  const response = await fetch(`${this.#baseUrl}/api/show`, {
54
77
  method: "POST",
55
78
  headers: { "Content-Type": "application/json" },
56
79
  body: JSON.stringify({ model }),
57
- signal: AbortSignal.timeout(
58
- Number(process.env.RUMMY_FETCH_TIMEOUT) || 30_000,
59
- ),
80
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
60
81
  });
61
82
  if (!response.ok) {
62
83
  throw new Error(
@@ -67,9 +88,10 @@ export default class OllamaClient {
67
88
  );
68
89
  }
69
90
  const data = await response.json();
70
- const info = data.model_info || {};
71
- for (const [key, value] of Object.entries(info)) {
72
- if (key.endsWith(".context_length")) return value;
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
+ }
73
95
  }
74
96
  throw new Error(msg("error.ollama_no_context_length", { model }));
75
97
  } catch (err) {
@@ -0,0 +1,17 @@
1
+ # openai
2
+
3
+ OpenAI-compatible LLM provider. Handles any model whose alias doesn't
4
+ carry a provider prefix — the default fallback provider. Works with
5
+ OpenAI itself, llama.cpp, vLLM, and any other service that implements
6
+ the `/v1/chat/completions` and `/v1/models` shape.
7
+
8
+ ## Env
9
+
10
+ - `OPENAI_BASE_URL` — base URL (e.g. `https://api.openai.com` or
11
+ `http://localhost:8080`). Plugin is inert if unset.
12
+ - `OPENAI_API_KEY` — bearer token (optional for local servers).
13
+
14
+ ## Context Size
15
+
16
+ Probes `/props` first (llama.cpp runtime) for `n_ctx`, falls back to
17
+ `/v1/models` for the training context length.
@@ -0,0 +1,120 @@
1
+ import msg from "../../agent/messages.js";
2
+
3
+ const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
+ if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
5
+
6
+ const PROVIDER = "openai";
7
+
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
+ */
14
+ export default class OpenAi {
15
+ #baseUrl;
16
+ #apiKey;
17
+
18
+ constructor(core) {
19
+ const baseUrl = process.env.OPENAI_BASE_URL;
20
+ if (!baseUrl) return;
21
+ this.#baseUrl = String(baseUrl).replace(/\/v1\/?$/, "");
22
+ this.#apiKey = process.env.OPENAI_API_KEY;
23
+
24
+ const wireModel = (alias) => alias.split("/").slice(1).join("/");
25
+
26
+ core.hooks.llm.providers.push({
27
+ name: PROVIDER,
28
+ matches: (model) => model.split("/")[0] === PROVIDER,
29
+ completion: (messages, model, options) =>
30
+ this.#completion(messages, wireModel(model), options),
31
+ getContextSize: (model) => this.#getContextSize(wireModel(model)),
32
+ });
33
+ }
34
+
35
+ async #completion(messages, model, options = {}) {
36
+ const body = { model, messages, think: true };
37
+ if (options.temperature !== undefined)
38
+ body.temperature = options.temperature;
39
+
40
+ const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT);
41
+ const signal = options.signal
42
+ ? AbortSignal.any([options.signal, timeoutSignal])
43
+ : timeoutSignal;
44
+
45
+ const headers = { "Content-Type": "application/json" };
46
+ if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
47
+
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.
76
+ }
77
+
78
+ return data;
79
+ }
80
+
81
+ async #getContextSize(_model) {
82
+ const headers = { "Content-Type": "application/json" };
83
+ if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
84
+
85
+ // Try /props first — llama.cpp exposes runtime n_ctx here.
86
+ try {
87
+ const propsResponse = await fetch(`${this.#baseUrl}/props`, {
88
+ headers,
89
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
90
+ });
91
+ if (propsResponse.ok) {
92
+ const props = await propsResponse.json();
93
+ const runtimeCtx = props?.default_generation_settings?.n_ctx;
94
+ if (runtimeCtx) return runtimeCtx;
95
+ }
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
+ }
100
+
101
+ // Fall back to /v1/models for training context.
102
+ const response = await fetch(`${this.#baseUrl}/v1/models`, {
103
+ headers,
104
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
105
+ });
106
+ if (!response.ok) {
107
+ throw new Error(
108
+ msg("error.openai_models_failed", {
109
+ status: response.status,
110
+ baseUrl: this.#baseUrl,
111
+ }),
112
+ );
113
+ }
114
+ const data = await response.json();
115
+ const model = data.data?.[0];
116
+ const ctx = model?.meta?.n_ctx_train || model?.context_length;
117
+ if (!ctx) throw new Error(msg("error.openai_no_context_length"));
118
+ return ctx;
119
+ }
120
+ }
@@ -0,0 +1,27 @@
1
+ # openrouter
2
+
3
+ OpenRouter LLM provider. Handles model aliases prefixed with
4
+ `openrouter/` (e.g. `openrouter/anthropic/claude-3-opus`). Strips the
5
+ provider segment and passes the rest (`publisher/model`) straight to
6
+ OpenRouter's API.
7
+
8
+ ## Env
9
+
10
+ - `OPENROUTER_BASE_URL` — base URL (e.g. `https://openrouter.ai/api/v1`).
11
+ Plugin is inert if `OPENROUTER_API_KEY` or base URL is unset.
12
+ - `OPENROUTER_API_KEY` — bearer token.
13
+ - `RUMMY_HTTP_REFERER` / `RUMMY_X_TITLE` — attribution headers
14
+ OpenRouter uses for rankings.
15
+
16
+ ## Reasoning Normalization
17
+
18
+ OpenRouter's response shape varies by underlying provider. The plugin
19
+ merges `reasoning_content` / `reasoning` / `thinking` /
20
+ `reasoning_details[].text` into a deduplicated `reasoning_content`
21
+ string on each choice's message.
22
+
23
+ ## Context Size
24
+
25
+ Calls `/models` and reads `context_length` on the matching entry.
26
+ Cached per model for the plugin lifetime. If the endpoint fails or the
27
+ model is missing, the call throws — no hardcoded fallback.
@@ -0,0 +1,121 @@
1
+ import msg from "../../agent/messages.js";
2
+
3
+ const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
+ if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
5
+
6
+ const PROVIDER = "openrouter";
7
+
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
+ */
18
+ export default class OpenRouter {
19
+ #apiKey;
20
+ #baseUrl;
21
+ #contextCache = new Map();
22
+
23
+ constructor(core) {
24
+ const apiKey = process.env.OPENROUTER_API_KEY;
25
+ const baseUrl = process.env.OPENROUTER_BASE_URL;
26
+ if (!apiKey || !baseUrl) return;
27
+ this.#apiKey = apiKey;
28
+ this.#baseUrl = baseUrl;
29
+
30
+ const wireModel = (alias) => alias.split("/").slice(1).join("/");
31
+
32
+ core.hooks.llm.providers.push({
33
+ name: PROVIDER,
34
+ matches: (model) => model.split("/")[0] === PROVIDER,
35
+ completion: (messages, model, options) =>
36
+ this.#completion(messages, wireModel(model), options),
37
+ getContextSize: (model) => this.#getContextSize(wireModel(model)),
38
+ });
39
+ }
40
+
41
+ async #completion(messages, model, options = {}) {
42
+ const body = { model, messages, include_reasoning: true };
43
+ if (options.temperature !== undefined)
44
+ body.temperature = options.temperature;
45
+
46
+ const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT);
47
+ const signal = options.signal
48
+ ? AbortSignal.any([options.signal, timeoutSignal])
49
+ : timeoutSignal;
50
+
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
+ });
62
+
63
+ if (!response.ok) {
64
+ const error = await response.text();
65
+ if (response.status === 401 || response.status === 403) {
66
+ throw new Error(
67
+ msg("error.openrouter_auth", {
68
+ status: `${response.status} - ${error}`,
69
+ }),
70
+ );
71
+ }
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;
94
+ }
95
+
96
+ return data;
97
+ }
98
+
99
+ async #getContextSize(model) {
100
+ if (this.#contextCache.has(model)) return this.#contextCache.get(model);
101
+
102
+ const res = await fetch(`${this.#baseUrl}/models`, {
103
+ headers: { Authorization: `Bearer ${this.#apiKey}` },
104
+ signal: AbortSignal.timeout(5000),
105
+ });
106
+ if (!res.ok) {
107
+ throw new Error(
108
+ `OpenRouter /models returned ${res.status}; cannot resolve context size for "${model}".`,
109
+ );
110
+ }
111
+ const data = await res.json();
112
+ const entry = data.data?.find((m) => m.id === model);
113
+ if (!entry?.context_length) {
114
+ throw new Error(
115
+ `OpenRouter /models has no context_length for "${model}".`,
116
+ );
117
+ }
118
+ this.#contextCache.set(model, entry.context_length);
119
+ return entry.context_length;
120
+ }
121
+ }
@@ -0,0 +1,20 @@
1
+ # persona {#persona_plugin}
2
+
3
+ Runtime persona management. A persona is free-form text that gets
4
+ prepended to the model's system prompt for a run.
5
+
6
+ ## Files
7
+
8
+ - **persona.js** — RPC registration and persona file loading.
9
+
10
+ ## RPC Methods
11
+
12
+ | Method | Params | Notes |
13
+ |--------|--------|-------|
14
+ | `persona/set` | `{ run, name?, text? }` | Set persona by filename (`${RUMMY_HOME}/personas/<name>.md`) or raw text. Pass neither to clear. |
15
+ | `listPersonas` | — | Return `[{name, path}]` for available persona files. |
16
+
17
+ ## Behavior
18
+
19
+ Persona is stored on the run row (`runs.persona`). The instructions
20
+ plugin reads it during system-prompt assembly.
@@ -20,10 +20,13 @@ export default class Persona {
20
20
  text = await loadFile(params.name);
21
21
  }
22
22
 
23
+ // "Pass neither to clear" — empty string counts as clear too.
24
+ let persona = null;
25
+ if (text) persona = text;
23
26
  await ctx.db.update_run_config.run({
24
27
  id: runRow.id,
25
28
  temperature: null,
26
- persona: text || null,
29
+ persona,
27
30
  context_limit: null,
28
31
  model: null,
29
32
  });
@@ -44,14 +47,10 @@ export default class Persona {
44
47
  handler: async () => {
45
48
  const dir = configDir();
46
49
  if (!dir) return [];
47
- try {
48
- const files = await fs.readdir(dir);
49
- return files
50
- .filter((f) => f.endsWith(".md"))
51
- .map((f) => ({ name: f.replace(".md", ""), path: join(dir, f) }));
52
- } catch {
53
- return [];
54
- }
50
+ const files = await fs.readdir(dir);
51
+ return files
52
+ .filter((f) => f.endsWith(".md"))
53
+ .map((f) => ({ name: f.replace(".md", ""), path: join(dir, f) }));
55
54
  },
56
55
  description: "List available persona files. Returns [{ name, path }].",
57
56
  requiresInit: true,
@@ -68,11 +67,5 @@ function configDir() {
68
67
  async function loadFile(name) {
69
68
  const dir = configDir();
70
69
  if (!dir) throw new Error("RUMMY_HOME not configured");
71
- const path = join(dir, `${name}.md`);
72
- try {
73
- return await fs.readFile(path, "utf8");
74
- } catch (err) {
75
- if (err.code === "ENOENT") throw new Error(`Not found: ${path}`);
76
- throw err;
77
- }
70
+ return fs.readFile(join(dir, `${name}.md`), "utf8");
78
71
  }
@@ -0,0 +1,21 @@
1
+ # policy {#policy_plugin}
2
+
3
+ Per-invocation enforcement of ask-mode restrictions. Rejects
4
+ model-emitted commands that would mutate the filesystem when the run
5
+ was started in `ask` mode.
6
+
7
+ ## Registration
8
+
9
+ - **Filter**: `entry.recording` (priority 1) — runs before a command
10
+ becomes an entry.
11
+
12
+ ## Rejections (ask mode only)
13
+
14
+ - `<sh>` — any shell command.
15
+ - `<set path="file.txt">` — file-scheme writes (bare path, non-scheme).
16
+ - `<rm path="file.txt">` — file-scheme deletes.
17
+ - `<mv>` / `<cp>` into a file-scheme destination.
18
+
19
+ Each rejection logs via `error.log` and returns an entry with
20
+ `state: "failed"`, `outcome: "permission"` so it still appears in the
21
+ turn's audit trail.