@possumtech/rummy 0.4.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 -4
  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 -331
  13. package/src/agent/ContextAssembler.js +4 -2
  14. package/src/agent/Entries.js +655 -0
  15. package/src/agent/ProjectAgent.js +30 -18
  16. package/src/agent/TurnExecutor.js +232 -379
  17. package/src/agent/XmlParser.js +242 -67
  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 -118
  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 +35 -21
  31. package/src/{server → hooks}/RpcRegistry.js +2 -1
  32. package/src/hooks/RummyContext.js +140 -37
  33. package/src/hooks/ToolRegistry.js +36 -35
  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 -23
  39. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  40. package/src/plugins/budget/README.md +27 -23
  41. package/src/plugins/budget/budget.js +261 -69
  42. package/src/plugins/cp/README.md +2 -2
  43. package/src/plugins/cp/cp.js +31 -13
  44. package/src/plugins/cp/cpDoc.js +2 -23
  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 +47 -8
  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 -7
  57. package/src/plugins/get/README.md +1 -1
  58. package/src/plugins/get/get.js +125 -49
  59. package/src/plugins/get/getDoc.js +2 -43
  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 +43 -3
  66. package/src/plugins/index.js +146 -123
  67. package/src/plugins/instructions/README.md +35 -9
  68. package/src/plugins/instructions/instructions.js +126 -12
  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 +77 -45
  78. package/src/plugins/known/knownDoc.js +2 -29
  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 +57 -24
  84. package/src/plugins/mv/mvDoc.js +2 -29
  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 +63 -18
  98. package/src/plugins/rm/README.md +1 -1
  99. package/src/plugins/rm/rm.js +58 -14
  100. package/src/plugins/rm/rmDoc.js +2 -24
  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 -77
  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 +52 -8
  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 -17
  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 +148 -74
  118. package/src/plugins/think/README.md +1 -1
  119. package/src/plugins/think/think.js +14 -1
  120. package/src/plugins/think/thinkDoc.js +2 -17
  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 +56 -21
  124. package/src/plugins/unknown/unknownDoc.js +2 -25
  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 -27
  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/functions/slugify.js +13 -1
  136. package/src/sql/v_model_context.sql +27 -31
  137. package/src/sql/v_run_log.sql +9 -14
  138. package/EXCEPTIONS.md +0 -46
  139. package/src/agent/KnownStore.js +0 -338
  140. package/src/agent/ResponseHealer.js +0 -188
  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 -37
  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 -60
  149. package/src/plugins/progress/README.md +0 -16
  150. package/src/plugins/progress/progress.js +0 -26
  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 -28
@@ -1,28 +1,3 @@
1
- // Tool doc for <update>. 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
- ["## <update>[brief status]</update> - Signal continuation"],
6
- [
7
- "Example: <update>Reading config files</update>",
8
- "Progress checkpoint. Status signal, not a log entry.",
9
- ],
10
- [
11
- "Example: <update>Found 3 issues, fixing first</update>",
12
- "Multi-step progress. Ongoing work.",
13
- ],
14
- [
15
- "* YOU MUST use <update></update> if still working — describes the current state",
16
- "Continuation signal. Triggers the next turn.",
17
- ],
18
- [
19
- "* YOU MUST NOT use <update> if done — use <summarize/> instead",
20
- "Mutual exclusion with summarize.",
21
- ],
22
- [
23
- "* YOU MUST keep <update> to <= 80 characters",
24
- "Length cap.",
25
- ],
26
- ];
1
+ import { loadDoc } from "../helpers.js";
27
2
 
28
- export default LINES.map(([text]) => text).join("\n");
3
+ export default loadDoc(import.meta.url, "updateDoc.md");
@@ -0,0 +1,8 @@
1
+ ## <update status="N">{brief status}</update> - Status report (exactly one per turn, at the end)
2
+ <!-- Header defines position, frequency, and status code requirement. -->
3
+
4
+ REQUIRED: the valid values of N are defined by your current stage instructions.
5
+ <!-- Single source of truth for codes is the current phase instructions block, not this doc. Listing codes here leaks termination knowledge (e.g. 200) that strong models use to short-circuit the protocol. -->
6
+
7
+ REQUIRED: YOU MUST keep <update></update> body to <= 80 characters.
8
+ <!-- Length cap. -->
@@ -0,0 +1,23 @@
1
+ # xai
2
+
3
+ xAI (Grok) LLM provider. Handles model aliases prefixed with `xai/`
4
+ (e.g. `xai/grok-2`).
5
+
6
+ ## Env
7
+
8
+ - `XAI_BASE_URL` — full responses endpoint (e.g.
9
+ `https://api.x.ai/v1/responses`). Plugin is inert if unset.
10
+ - `XAI_API_KEY` — bearer token.
11
+
12
+ ## Response Normalization
13
+
14
+ xAI's response shape differs from OpenAI's. The plugin walks
15
+ `data.output[]`, collecting text from items of type `message` as
16
+ `content` and items of type `reasoning` as `reasoning_content`, then
17
+ emits the common OpenAI-shaped envelope.
18
+
19
+ ## Context Size
20
+
21
+ Tries `/models` first for a `context_length` field, then the
22
+ `/language-models/<id>` endpoint as a fallback. Results are cached
23
+ per model for the plugin lifetime.
@@ -1,24 +1,46 @@
1
- import msg from "../agent/messages.js";
1
+ import msg from "../../agent/messages.js";
2
2
 
3
- export default class XaiClient {
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 = "xai";
7
+
8
+ /**
9
+ * xAI (Grok) LLM provider plugin. Registers with hooks.llm.providers if
10
+ * XAI_BASE_URL is set; inert otherwise. Handles model aliases of the
11
+ * form `xai/{modelName}`. Normalizes xAI's distinct response shape
12
+ * into the common OpenAI-shaped envelope.
13
+ */
14
+ export default class Xai {
4
15
  #baseUrl;
5
16
  #apiKey;
6
17
  #contextCache = new Map();
7
18
 
8
- constructor(baseUrl, apiKey) {
19
+ constructor(core) {
20
+ const baseUrl = process.env.XAI_BASE_URL;
21
+ if (!baseUrl) return;
9
22
  this.#baseUrl = baseUrl;
10
- this.#apiKey = apiKey;
23
+ this.#apiKey = process.env.XAI_API_KEY;
24
+
25
+ const wireModel = (alias) => alias.split("/").slice(1).join("/");
26
+
27
+ core.hooks.llm.providers.push({
28
+ name: PROVIDER,
29
+ matches: (model) => model.split("/")[0] === PROVIDER,
30
+ completion: (messages, model, options) =>
31
+ this.#completion(messages, wireModel(model), options),
32
+ getContextSize: (model) => this.#getContextSize(wireModel(model)),
33
+ });
11
34
  }
12
35
 
13
- async completion(messages, model, options = {}) {
36
+ async #completion(messages, model, options = {}) {
14
37
  if (!this.#apiKey) throw new Error(msg("error.xai_api_key_missing"));
15
38
 
16
39
  const body = { model, input: messages };
17
40
  if (options.temperature !== undefined)
18
41
  body.temperature = options.temperature;
19
42
 
20
- const timeout = Number(process.env.RUMMY_FETCH_TIMEOUT) || 30_000;
21
- const timeoutSignal = AbortSignal.timeout(timeout);
43
+ const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT);
22
44
  const signal = options.signal
23
45
  ? AbortSignal.any([options.signal, timeoutSignal])
24
46
  : timeoutSignal;
@@ -37,29 +59,22 @@ export default class XaiClient {
37
59
  const error = await response.text();
38
60
  if (response.status === 401 || response.status === 403) {
39
61
  throw new Error(
40
- msg("error.xai_auth", {
41
- status: `${response.status} - ${error}`,
42
- }),
62
+ msg("error.xai_auth", { status: `${response.status} - ${error}` }),
43
63
  );
44
64
  }
45
65
  throw new Error(
46
- msg("error.xai_api", {
47
- status: `${response.status} - ${error}`,
48
- }),
66
+ msg("error.xai_api", { status: `${response.status} - ${error}` }),
49
67
  );
50
68
  }
51
69
 
52
- const data = await response.json();
53
- return this.#normalize(data);
70
+ return this.#normalize(await response.json());
54
71
  }
55
72
 
56
73
  #normalize(data) {
57
- const output = data.output || [];
58
-
59
74
  let content = "";
60
75
  let reasoningContent = null;
61
76
 
62
- for (const item of output) {
77
+ for (const item of data.output) {
63
78
  if (item.type === "reasoning") {
64
79
  const text = this.#extractText(item.content);
65
80
  if (text)
@@ -73,9 +88,13 @@ export default class XaiClient {
73
88
  }
74
89
  }
75
90
 
76
- const usage = data.usage || {};
77
- const inputTokens = usage.input_tokens || 0;
78
- const outputTokens = usage.output_tokens || 0;
91
+ const { usage } = data;
92
+ const inputTokens = usage.input_tokens;
93
+ const outputTokens = usage.output_tokens;
94
+ // Optional per xAI API; absent on providers that don't surface them.
95
+ const cached = usage.input_tokens_details?.cached_tokens;
96
+ const reasoningTokens = usage.output_tokens_details?.reasoning_tokens;
97
+ const costTicks = usage.cost_in_usd_ticks;
79
98
  return {
80
99
  choices: [
81
100
  {
@@ -88,11 +107,11 @@ export default class XaiClient {
88
107
  ],
89
108
  usage: {
90
109
  prompt_tokens: inputTokens,
91
- cached_tokens: usage.input_tokens_details?.cached_tokens || 0,
110
+ cached_tokens: cached === undefined ? 0 : cached,
92
111
  completion_tokens: outputTokens,
93
- reasoning_tokens: usage.output_tokens_details?.reasoning_tokens || 0,
112
+ reasoning_tokens: reasoningTokens === undefined ? 0 : reasoningTokens,
94
113
  total_tokens: inputTokens + outputTokens,
95
- cost: (usage.cost_in_usd_ticks || 0) / 10_000_000_000,
114
+ cost: costTicks === undefined ? 0 : costTicks / 10_000_000_000,
96
115
  },
97
116
  };
98
117
  }
@@ -100,29 +119,30 @@ export default class XaiClient {
100
119
  #extractText(content) {
101
120
  if (typeof content === "string") return content;
102
121
  if (!Array.isArray(content)) return null;
103
- return (
104
- content
105
- .filter((c) => c.type === "text" || c.type === "output_text")
106
- .map((c) => c.text)
107
- .join("\n") || null
108
- );
122
+ const joined = content
123
+ .filter((c) => c.type === "text" || c.type === "output_text")
124
+ .map((c) => c.text)
125
+ .join("\n");
126
+ return joined ? joined : null;
109
127
  }
110
128
 
111
- async getContextSize(model) {
129
+ async #getContextSize(model) {
112
130
  if (this.#contextCache.has(model)) return this.#contextCache.get(model);
113
-
114
131
  if (!this.#apiKey) throw new Error(msg("error.xai_api_key_missing"));
115
132
 
116
- // Query xAI models endpoint
117
133
  const modelsUrl = this.#baseUrl.replace(/\/responses$/, "/models");
118
134
  const res = await fetch(modelsUrl, {
119
135
  headers: { Authorization: `Bearer ${this.#apiKey}` },
120
136
  signal: AbortSignal.timeout(5000),
121
137
  });
122
-
123
138
  if (res.ok) {
124
139
  const data = await res.json();
125
- const models = data.data || data.models || [];
140
+ // xAI's /models returns either { data: [...] } or { models: [...] }
141
+ // depending on the API version; accept either and crash otherwise.
142
+ let models;
143
+ if (data.data) models = data.data;
144
+ else if (data.models) models = data.models;
145
+ else throw new Error("xAI /models response has neither data nor models");
126
146
  const entry = models.find(
127
147
  (m) => m.id === model || `${m.id}-latest` === model,
128
148
  );
@@ -132,16 +152,17 @@ export default class XaiClient {
132
152
  }
133
153
  }
134
154
 
135
- // Try /v1/language-models for richer metadata
136
155
  const langUrl = this.#baseUrl.replace(
137
156
  /\/responses$/,
138
157
  `/language-models/${model}`,
139
158
  );
159
+ // Optional endpoint probe. If the network call fails (404 on older
160
+ // API versions, timeout, etc.) we fall through to the next strategy
161
+ // below; a terminal throw fires if no strategy resolves.
140
162
  const langRes = await fetch(langUrl, {
141
163
  headers: { Authorization: `Bearer ${this.#apiKey}` },
142
164
  signal: AbortSignal.timeout(5000),
143
165
  }).catch(() => null);
144
-
145
166
  if (langRes?.ok) {
146
167
  const langData = await langRes.json();
147
168
  if (langData?.context_length) {
@@ -8,6 +8,7 @@ export default class ClientConnection {
8
8
  #hooks;
9
9
  #rpcRegistry;
10
10
  #rpcLogPending = new Map();
11
+ #shutdownPromise = null;
11
12
  #context = {
12
13
  projectId: null,
13
14
  projectRoot: null,
@@ -21,7 +22,13 @@ export default class ClientConnection {
21
22
  this.#projectAgent = new ProjectAgent(db, hooks);
22
23
 
23
24
  this.#ws.on("message", (data) => this.#handleMessage(data));
24
- this.#ws.on("close", () => this.#teardown());
25
+ this.#ws.on("close", () => {
26
+ // Fire-and-forget: the Promise is cached by `shutdown()` so
27
+ // server-initiated close can await the same work.
28
+ this.shutdown().catch((err) => {
29
+ console.warn(`[RUMMY] shutdown on ws close failed: ${err.message}`);
30
+ });
31
+ });
25
32
 
26
33
  this.#setupNotifications();
27
34
  }
@@ -63,6 +70,16 @@ export default class ClientConnection {
63
70
  }
64
71
  };
65
72
 
73
+ #onStreamCancelled = (payload) => {
74
+ if (payload.projectId === this.#context.projectId) {
75
+ this.#sendNotification("stream/cancelled", {
76
+ run: payload.run,
77
+ path: payload.path,
78
+ reason: payload.reason,
79
+ });
80
+ }
81
+ };
82
+
66
83
  #onState = (payload) => {
67
84
  if (payload.projectId === this.#context.projectId) {
68
85
  this.#sendNotification("run/state", {
@@ -79,18 +96,37 @@ export default class ClientConnection {
79
96
 
80
97
  #setupNotifications() {
81
98
  this.#hooks.run.progress.on(this.#onProgress);
82
- this.#hooks.turn.proposal.on(this.#onProposal);
99
+ this.#hooks.proposal.pending.on(this.#onProposal);
83
100
  this.#hooks.ui.render.on(this.#onRender);
84
101
  this.#hooks.ui.notify.on(this.#onNotify);
85
102
  this.#hooks.run.state.on(this.#onState);
103
+ this.#hooks.stream.cancelled.on(this.#onStreamCancelled);
86
104
  }
87
105
 
88
106
  #teardown() {
89
107
  this.#hooks.run.progress.off(this.#onProgress);
90
- this.#hooks.turn.proposal.off(this.#onProposal);
108
+ this.#hooks.proposal.pending.off(this.#onProposal);
91
109
  this.#hooks.ui.render.off(this.#onRender);
92
110
  this.#hooks.ui.notify.off(this.#onNotify);
93
111
  this.#hooks.run.state.off(this.#onState);
112
+ this.#hooks.stream.cancelled.off(this.#onStreamCancelled);
113
+ }
114
+
115
+ /**
116
+ * Abort in-flight runs on this connection and wait for them to
117
+ * settle. Idempotent: `ws.on("close")` and server-initiated close
118
+ * both call this; the cached Promise guarantees the work happens
119
+ * exactly once and both callers observe the same completion.
120
+ */
121
+ shutdown() {
122
+ if (!this.#shutdownPromise) {
123
+ this.#shutdownPromise = (async () => {
124
+ await this.#projectAgent.shutdown();
125
+ this.#teardown();
126
+ if (this.#ws.readyState === 1) this.#ws.terminate();
127
+ })();
128
+ }
129
+ return this.#shutdownPromise;
94
130
  }
95
131
 
96
132
  #buildHandlerContext() {
@@ -113,11 +149,8 @@ export default class ClientConnection {
113
149
 
114
150
  async #handleMessage(data) {
115
151
  let id = null;
116
- const debug = process.env.RUMMY_DEBUG === "true";
117
152
  try {
118
153
  const rawMessage = await this.#hooks.socket.message.raw.filter(data);
119
- if (debug) console.log(`[SOCKET] IN: ${rawMessage.toString()}`);
120
-
121
154
  const message = JSON.parse(rawMessage.toString());
122
155
 
123
156
  const filteredRequest = await this.#hooks.rpc.request.filter(message);
@@ -131,15 +164,13 @@ export default class ClientConnection {
131
164
  projectId: this.#context.projectId,
132
165
  });
133
166
 
134
- try {
135
- const logRow = await this.#db.log_rpc_call.get({
136
- project_id: this.#context.projectId ?? null,
137
- method,
138
- rpc_id: id,
139
- params: params ? JSON.stringify(params) : null,
140
- });
141
- if (logRow) this.#rpcLogPending.set(id, logRow.id);
142
- } catch {}
167
+ const logRow = await this.#db.log_rpc_call.get({
168
+ project_id: this.#context.projectId,
169
+ method,
170
+ rpc_id: id,
171
+ params: params ? JSON.stringify(params) : null,
172
+ });
173
+ if (logRow) this.#rpcLogPending.set(id, logRow.id);
143
174
 
144
175
  const resolvedMethod = method === "rpc/discover" ? "discover" : method;
145
176
  const registration = this.#rpcRegistry.get(resolvedMethod);
@@ -150,17 +181,19 @@ export default class ClientConnection {
150
181
  throw new Error(msg("error.not_initialized"));
151
182
  }
152
183
 
184
+ // JSON-RPC requests may omit `params` entirely.
185
+ const handlerParams = params === undefined ? {} : params;
153
186
  let result;
154
187
  if (registration.longRunning) {
155
188
  result = await registration.handler(
156
- params || {},
189
+ handlerParams,
157
190
  this.#buildHandlerContext(),
158
191
  );
159
192
  } else {
160
- const timeout = Number(process.env.RUMMY_RPC_TIMEOUT) || 10_000;
193
+ const timeout = Number(process.env.RUMMY_RPC_TIMEOUT);
161
194
  let timer;
162
195
  result = await Promise.race([
163
- registration.handler(params || {}, this.#buildHandlerContext()),
196
+ registration.handler(handlerParams, this.#buildHandlerContext()),
164
197
  new Promise((_, reject) => {
165
198
  timer = setTimeout(
166
199
  () =>
@@ -198,43 +231,37 @@ export default class ClientConnection {
198
231
  const logId = this.#rpcLogPending.get(id);
199
232
  if (logId) {
200
233
  this.#rpcLogPending.delete(id);
201
- try {
202
- await this.#db.log_rpc_result.run({
203
- id: logId,
204
- result: finalResult
205
- ? JSON.stringify(finalResult).slice(0, 4096)
206
- : null,
207
- });
208
- } catch {}
234
+ await this.#db.log_rpc_result.run({
235
+ id: logId,
236
+ result: finalResult
237
+ ? JSON.stringify(finalResult).slice(0, 4096)
238
+ : null,
239
+ });
209
240
  }
210
241
  } catch (error) {
211
242
  console.error(`[RUMMY] RPC Error: ${error.message}`);
212
243
  console.error(`[RUMMY] Stack: ${error.stack}`);
244
+ // JSON-RPC: error responses for malformed requests with no id
245
+ // MUST carry null per the spec.
213
246
  this.#send({
214
247
  jsonrpc: "2.0",
215
248
  error: { code: -32603, message: error.message },
216
- id: id || null,
249
+ id: id === undefined ? null : id,
217
250
  });
218
251
  await this.#hooks.rpc.error.emit({ id, error });
219
252
 
220
253
  const errLogId = this.#rpcLogPending.get(id);
221
254
  if (errLogId) {
222
255
  this.#rpcLogPending.delete(id);
223
- try {
224
- await this.#db.log_rpc_error.run({
225
- id: errLogId,
226
- error: error.message,
227
- });
228
- } catch {}
256
+ await this.#db.log_rpc_error.run({
257
+ id: errLogId,
258
+ error: error.message,
259
+ });
229
260
  }
230
261
  }
231
262
  }
232
263
 
233
264
  #send(payload) {
234
- const debug = process.env.RUMMY_DEBUG === "true";
235
- if (debug) {
236
- console.log(`[SOCKET] OUT: ${JSON.stringify(payload, null, 2)}`);
237
- }
238
265
  if (this.#ws.readyState === 1) {
239
266
  this.#ws.send(JSON.stringify(payload));
240
267
  }
@@ -5,17 +5,23 @@ export default class SocketServer {
5
5
  #db;
6
6
  #wss;
7
7
  #hooks;
8
+ #connections = new Set();
8
9
 
9
10
  constructor(db, options) {
10
11
  this.#db = db;
11
12
  this.#hooks = options.hooks;
12
13
  this.#wss = new WebSocketServer(options);
13
14
 
14
- this.#wss.on("connection", (ws, req) => {
15
- if (process.env.RUMMY_DEBUG === "true") {
16
- console.log(`[SOCKET] New connection from ${req.socket.remoteAddress}`);
17
- }
18
- new ClientConnection(ws, this.#db, this.#hooks);
15
+ this.#wss.on("connection", (ws, _req) => {
16
+ const conn = new ClientConnection(ws, this.#db, this.#hooks);
17
+ this.#connections.add(conn);
18
+ // Remove from the tracking set only after the connection's
19
+ // shutdown drain has fully settled — not on raw ws-close
20
+ // so server close() can still find and await an in-progress
21
+ // shutdown kicked off by a client-initiated disconnect.
22
+ ws.on("close", () => {
23
+ conn.shutdown().finally(() => this.#connections.delete(conn));
24
+ });
19
25
  });
20
26
 
21
27
  this.#wss.on("error", (_err) => {
@@ -31,12 +37,19 @@ export default class SocketServer {
31
37
  this.#wss.on(event, handler);
32
38
  }
33
39
 
34
- close() {
35
- return new Promise((resolve) => {
40
+ async close() {
41
+ // Drain in-flight runs on each connection before closing the
42
+ // socket — otherwise detached kickoff Promises keep the Node
43
+ // event loop alive past server shutdown.
44
+ const shutdowns = [];
45
+ for (const conn of this.#connections) {
46
+ shutdowns.push(conn.shutdown().catch(() => {}));
47
+ }
48
+ await Promise.all(shutdowns);
49
+ this.#connections.clear();
50
+
51
+ await new Promise((resolve) => {
36
52
  if (!this.#wss) return resolve();
37
- for (const client of this.#wss.clients) {
38
- client.terminate();
39
- }
40
53
  this.#wss.close(resolve);
41
54
  });
42
55
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Server↔client wire-protocol version. Bumped whenever the RPC shape
3
+ * or notification payload shape changes in a way that breaks existing
4
+ * clients. Clients pass their own version in `rummy/hello`; server
5
+ * rejects MAJOR mismatch. Git commit log is the changelog.
6
+ *
7
+ * MAJOR — breaking change (removed/renamed method, shape change)
8
+ * MINOR — additive change (new method, new optional field)
9
+ * PATCH — internal fix visible to the wire shape
10
+ */
11
+ export const RUMMY_PROTOCOL_VERSION = "2.0.0";
@@ -1,6 +1,18 @@
1
1
  export const deterministic = true;
2
2
 
3
+ // Build URI paths the model can round-trip:
4
+ // "history,mongol,khan" → "history/mongol/khan" (commas become path separators)
5
+ // "contents of Document 1" → "contents_of_Document_1" (spaces become underscores)
6
+ // Slice on decoded text, then split-encode-join per segment so / survives as
7
+ // a separator while anything URL-unsafe inside a segment gets escaped.
3
8
  export default function slugify(text) {
4
9
  if (!text) return "";
5
- return encodeURIComponent(text).slice(0, 80);
10
+ return text
11
+ .slice(0, 80)
12
+ .replace(/,/g, "/")
13
+ .replace(/ /g, "_")
14
+ .split("/")
15
+ .filter(Boolean)
16
+ .map(encodeURIComponent)
17
+ .join("/");
6
18
  }
@@ -3,30 +3,26 @@ CREATE VIEW IF NOT EXISTS v_model_context AS
3
3
  WITH
4
4
  visible AS (
5
5
  SELECT
6
- ke.run_id
7
- , ke.id
8
- , ke.path
9
- , ke.body
10
- , ke.scheme
11
- , ke.status
12
- , ke.fidelity
13
- , ke.turn
14
- , ke.updated_at
15
- , ke.attributes
16
- , ke.tokens
6
+ rv.run_id
7
+ , rv.id
8
+ , e.path
9
+ , e.body
10
+ , e.scheme
11
+ , rv.state
12
+ , rv.outcome
13
+ , rv.visibility
14
+ , rv.turn
15
+ , rv.updated_at
16
+ , e.attributes
17
17
  , COALESCE(s.category, 'logging') AS category
18
18
  , CASE
19
- -- Archived entries not in context
20
- WHEN ke.fidelity = 'archive' THEN NULL
21
- -- 202 Accepted (proposed) hidden until resolved
22
- WHEN ke.status = 202 THEN NULL
23
- -- Audit schemes (model_visible = 0) hidden
19
+ WHEN rv.visibility = 'archived' THEN NULL
24
20
  WHEN s.model_visible = 0 THEN NULL
25
- -- Everything else visible at its fidelity
26
- ELSE ke.fidelity
27
- END AS visible_fidelity
28
- FROM known_entries AS ke
29
- JOIN schemes AS s ON s.name = COALESCE(ke.scheme, 'file')
21
+ ELSE rv.visibility
22
+ END AS effective_visibility
23
+ FROM run_views AS rv
24
+ JOIN entries AS e ON e.id = rv.entry_id
25
+ JOIN schemes AS s ON s.name = COALESCE(e.scheme, 'file')
30
26
  ),
31
27
  projected AS (
32
28
  SELECT
@@ -34,27 +30,28 @@ projected AS (
34
30
  , id
35
31
  , path
36
32
  , scheme
37
- , status
38
- , visible_fidelity AS fidelity
33
+ , state
34
+ , outcome
35
+ , effective_visibility AS visibility
39
36
  , turn
40
37
  , updated_at
41
38
  , attributes
42
39
  -- Category comes from schemes table — plugins declare it via registerScheme().
43
40
  , category
44
- , tokens
45
41
  , CASE
46
- WHEN visible_fidelity IN ('full', 'summary') THEN body
42
+ WHEN effective_visibility IN ('visible', 'summarized') THEN body
47
43
  ELSE ''
48
44
  END AS body
49
45
  FROM visible
50
- WHERE visible_fidelity IS NOT NULL
46
+ WHERE effective_visibility IS NOT NULL
51
47
  )
52
48
  SELECT
53
49
  run_id
54
50
  , path
55
51
  , scheme
56
- , fidelity
57
- , status
52
+ , visibility
53
+ , state
54
+ , outcome
58
55
  , body
59
56
  , attributes
60
57
  , category
@@ -71,13 +68,12 @@ SELECT
71
68
  ELSE 5
72
69
  END
73
70
  , CASE scheme WHEN 'skill' THEN 0 ELSE 1 END
74
- , CASE fidelity
75
- WHEN 'summary' THEN 0
71
+ , CASE visibility
72
+ WHEN 'summarized' THEN 0
76
73
  ELSE 1
77
74
  END
78
75
  , turn
79
76
  , updated_at
80
77
  , id
81
78
  ) AS ordinal
82
- , countTokens(body) AS tokens
83
79
  FROM projected;
@@ -4,20 +4,15 @@ SELECT
4
4
  ke.run_id
5
5
  , ke.path
6
6
  , ke.body
7
- , ke.status
8
- , COALESCE(ke.scheme, 'file') AS tool
9
- , COALESCE(
10
- json_extract(ke.attributes, '$.command')
11
- , json_extract(ke.attributes, '$.file')
12
- , json_extract(ke.attributes, '$.path')
13
- , json_extract(ke.attributes, '$.question')
14
- , ''
15
- ) AS target
7
+ , ke.state
8
+ , ke.outcome
9
+ , ke.turn
10
+ , ke.scheme AS tool
11
+ , ke.attributes
16
12
  FROM known_entries AS ke
17
- JOIN schemes AS s ON s.name = COALESCE(ke.scheme, 'file')
13
+ JOIN schemes AS s ON s.name = ke.scheme
18
14
  WHERE
19
- ke.scheme IS NOT NULL
20
- AND ke.status != 202
21
- AND s.category NOT IN ('knowledge', 'file')
22
- AND ke.scheme NOT IN ('system', 'reasoning', 'model', 'content')
15
+ s.category IN ('logging', 'prompt', 'unknown')
16
+ AND ke.state != 'proposed'
17
+ AND ke.scheme != 'run'
23
18
  ORDER BY ke.id;