@possumtech/rummy 0.2.7 → 0.3.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 (119) hide show
  1. package/.env.example +12 -3
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +454 -197
  4. package/SPEC.md +284 -93
  5. package/migrations/001_initial_schema.sql +57 -70
  6. package/package.json +16 -10
  7. package/service.js +1 -1
  8. package/src/agent/AgentLoop.js +254 -70
  9. package/src/agent/ContextAssembler.js +18 -4
  10. package/src/agent/KnownStore.js +156 -23
  11. package/src/agent/ProjectAgent.js +5 -4
  12. package/src/agent/ResponseHealer.js +21 -1
  13. package/src/agent/TurnExecutor.js +393 -115
  14. package/src/agent/XmlParser.js +92 -39
  15. package/src/agent/known_checks.sql +5 -4
  16. package/src/agent/known_queries.sql +4 -3
  17. package/src/agent/known_store.sql +45 -15
  18. package/src/agent/loops.sql +63 -0
  19. package/src/agent/runs.sql +7 -7
  20. package/src/agent/schemes.sql +5 -2
  21. package/src/agent/tokens.js +6 -21
  22. package/src/agent/turns.sql +13 -4
  23. package/src/hooks/Hooks.js +18 -0
  24. package/src/hooks/PluginContext.js +14 -10
  25. package/src/hooks/RummyContext.js +30 -10
  26. package/src/hooks/ToolRegistry.js +83 -19
  27. package/src/llm/LlmProvider.js +27 -8
  28. package/src/llm/OpenAiClient.js +20 -0
  29. package/src/llm/OpenRouterClient.js +24 -2
  30. package/src/llm/XaiClient.js +47 -2
  31. package/src/plugins/ask_user/README.md +4 -4
  32. package/src/plugins/ask_user/ask_user.js +8 -7
  33. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  34. package/src/plugins/budget/BudgetGuard.js +74 -0
  35. package/src/plugins/budget/README.md +43 -0
  36. package/src/plugins/budget/budget.js +79 -0
  37. package/src/plugins/cp/README.md +5 -4
  38. package/src/plugins/cp/cp.js +16 -12
  39. package/src/plugins/cp/cpDoc.js +29 -0
  40. package/src/plugins/current/README.md +4 -4
  41. package/src/plugins/current/current.js +12 -10
  42. package/src/plugins/engine/engine.sql +5 -10
  43. package/src/plugins/engine/turn_context.sql +13 -13
  44. package/src/plugins/env/README.md +3 -4
  45. package/src/plugins/env/env.js +8 -7
  46. package/src/plugins/env/envDoc.js +29 -0
  47. package/src/plugins/file/README.md +9 -12
  48. package/src/plugins/file/file.js +34 -45
  49. package/src/plugins/get/README.md +2 -2
  50. package/src/plugins/get/get.js +28 -11
  51. package/src/plugins/get/getDoc.js +41 -0
  52. package/src/plugins/hedberg/docs.md +0 -9
  53. package/src/plugins/hedberg/hedberg.js +4 -6
  54. package/src/plugins/hedberg/matcher.js +1 -1
  55. package/src/plugins/hedberg/normalize.js +28 -0
  56. package/src/plugins/hedberg/patterns.js +31 -33
  57. package/src/plugins/hedberg/sed.js +17 -10
  58. package/src/plugins/helpers.js +2 -2
  59. package/src/plugins/index.js +93 -28
  60. package/src/plugins/instructions/README.md +6 -2
  61. package/src/plugins/instructions/instructions.js +21 -5
  62. package/src/plugins/instructions/preamble.md +9 -5
  63. package/src/plugins/known/README.md +10 -7
  64. package/src/plugins/known/known.js +33 -23
  65. package/src/plugins/known/knownDoc.js +33 -0
  66. package/src/plugins/mv/README.md +5 -4
  67. package/src/plugins/mv/mv.js +16 -12
  68. package/src/plugins/mv/mvDoc.js +31 -0
  69. package/src/plugins/persona/persona.js +78 -0
  70. package/src/plugins/previous/README.md +2 -2
  71. package/src/plugins/previous/previous.js +12 -8
  72. package/src/plugins/progress/progress.js +44 -12
  73. package/src/plugins/prompt/README.md +5 -5
  74. package/src/plugins/prompt/prompt.js +23 -19
  75. package/src/plugins/rm/README.md +4 -4
  76. package/src/plugins/rm/rm.js +29 -12
  77. package/src/plugins/rm/rmDoc.js +30 -0
  78. package/src/plugins/rpc/README.md +15 -28
  79. package/src/plugins/rpc/rpc.js +63 -107
  80. package/src/plugins/set/README.md +13 -12
  81. package/src/plugins/set/set.js +82 -21
  82. package/src/plugins/set/setDoc.js +45 -0
  83. package/src/plugins/sh/README.md +4 -4
  84. package/src/plugins/sh/sh.js +8 -7
  85. package/src/plugins/sh/shDoc.js +29 -0
  86. package/src/plugins/{skills/skills.js → skill/skill.js} +12 -54
  87. package/src/plugins/summarize/README.md +6 -5
  88. package/src/plugins/summarize/summarize.js +7 -6
  89. package/src/plugins/summarize/summarizeDoc.js +33 -0
  90. package/src/plugins/telemetry/telemetry.js +20 -8
  91. package/src/plugins/think/README.md +20 -0
  92. package/src/plugins/think/think.js +5 -0
  93. package/src/plugins/unknown/README.md +5 -5
  94. package/src/plugins/unknown/unknown.js +11 -8
  95. package/src/plugins/unknown/unknownDoc.js +31 -0
  96. package/src/plugins/update/README.md +3 -8
  97. package/src/plugins/update/update.js +7 -6
  98. package/src/plugins/update/updateDoc.js +33 -0
  99. package/src/server/ClientConnection.js +3 -5
  100. package/src/server/RpcRegistry.js +52 -4
  101. package/src/sql/v_model_context.sql +31 -39
  102. package/src/sql/v_run_log.sql +3 -3
  103. package/src/agent/prompt_queue.sql +0 -39
  104. package/src/plugins/ask_user/docs.md +0 -2
  105. package/src/plugins/cp/docs.md +0 -2
  106. package/src/plugins/env/docs.md +0 -2
  107. package/src/plugins/get/docs.md +0 -6
  108. package/src/plugins/known/docs.md +0 -3
  109. package/src/plugins/mv/docs.md +0 -2
  110. package/src/plugins/rm/docs.md +0 -4
  111. package/src/plugins/set/docs.md +0 -4
  112. package/src/plugins/sh/docs.md +0 -2
  113. package/src/plugins/skills/README.md +0 -25
  114. package/src/plugins/store/README.md +0 -20
  115. package/src/plugins/store/docs.md +0 -5
  116. package/src/plugins/store/store.js +0 -52
  117. package/src/plugins/summarize/docs.md +0 -4
  118. package/src/plugins/unknown/docs.md +0 -5
  119. package/src/plugins/update/docs.md +0 -4
@@ -0,0 +1,29 @@
1
+ // Tool doc for <sh>. 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
+ // --- Syntax
6
+ ["## <sh>[command]</sh> - Run a shell command with side effects"],
7
+
8
+ // --- Examples: install and test — real mutations
9
+ [
10
+ "Example: <sh>npm install express</sh>",
11
+ "Package install. Shows a real side-effect command.",
12
+ ],
13
+ [
14
+ "Example: <sh>npm test</sh>",
15
+ "Test execution. Another common side-effect action.",
16
+ ],
17
+
18
+ // --- Constraints
19
+ [
20
+ "* YOU MUST NOT use <sh/> to read, create, or edit files — use <get/> and <set/>",
21
+ "Forces file operations through the entry system. Prevents untracked mutations.",
22
+ ],
23
+ [
24
+ "* YOU MUST use <env/> for commands without side effects",
25
+ "Reinforces the env/sh split. Read = env, mutate = sh.",
26
+ ],
27
+ ];
28
+
29
+ export default LINES.map(([text]) => text).join("\n");
@@ -1,16 +1,17 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
 
4
- export default class Skills {
4
+ export default class Skill {
5
5
  #core;
6
6
 
7
7
  constructor(core) {
8
8
  this.#core = core;
9
9
  core.registerScheme({
10
10
  name: "skill",
11
- validStates: ["full", "stored"],
12
- category: "knowledge",
11
+ category: "data",
13
12
  });
13
+ core.hooks.tools.onView("skill", (entry) => entry.body);
14
+
14
15
  const r = core.hooks.rpc.registry;
15
16
 
16
17
  r.register("skill/add", {
@@ -23,19 +24,12 @@ export default class Skills {
23
24
 
24
25
  const body = await loadFile("skills", params.name);
25
26
  const store = ctx.projectAgent.entries;
26
- await store.upsert(
27
- runRow.id,
28
- runRow.next_turn,
29
- `skill://${params.name}`,
30
- body,
31
- "full",
32
- {
33
- attributes: {
34
- name: params.name,
35
- source: filePath("skills", params.name),
36
- },
27
+ await store.upsert(runRow.id, 0, `skill://${params.name}`, body, 200, {
28
+ attributes: {
29
+ name: params.name,
30
+ source: filePath("skills", params.name),
37
31
  },
38
- );
32
+ });
39
33
 
40
34
  return { status: "ok", skill: params.name };
41
35
  },
@@ -84,10 +78,10 @@ export default class Skills {
84
78
  );
85
79
  return entries.map((e) => ({
86
80
  name: e.path.replace("skill://", ""),
87
- state: e.state,
81
+ status: e.status,
88
82
  }));
89
83
  },
90
- description: "List skills active on a run. Returns [{ name, state }].",
84
+ description: "List skills active on a run. Returns [{ name, status }].",
91
85
  params: { run: "string — run alias" },
92
86
  requiresInit: true,
93
87
  });
@@ -98,43 +92,7 @@ export default class Skills {
98
92
  requiresInit: true,
99
93
  });
100
94
 
101
- r.register("persona/set", {
102
- handler: async (params, ctx) => {
103
- if (!params.run) throw new Error("run is required");
104
-
105
- const runRow = await ctx.db.get_run_by_alias.get({ alias: params.run });
106
- if (!runRow) throw new Error(`Run not found: ${params.run}`);
107
-
108
- let text = params.text;
109
- if (params.name && !text) {
110
- text = await loadFile("personas", params.name);
111
- }
112
-
113
- await ctx.db.update_run_config.run({
114
- id: runRow.id,
115
- temperature: null,
116
- persona: text || null,
117
- context_limit: null,
118
- model: null,
119
- });
120
-
121
- return { status: "ok" };
122
- },
123
- description:
124
- "Set persona on a run. Pass name or text. Pass neither to clear.",
125
- params: {
126
- run: "string — run alias",
127
- name: "string? — persona filename (without .md)",
128
- text: "string? — raw persona text (overrides name)",
129
- },
130
- requiresInit: true,
131
- });
132
-
133
- r.register("listPersonas", {
134
- handler: async () => listAvailable("personas"),
135
- description: "List available persona files. Returns [{ name, path }].",
136
- requiresInit: true,
137
- });
95
+ // Persona methods extracted to persona plugin.
138
96
  }
139
97
  }
140
98
 
@@ -1,13 +1,12 @@
1
1
  # summarize
2
2
 
3
- Structural tool for model-generated summaries.
3
+ Lifecycle signal the model declares it has completed the task.
4
4
 
5
5
  ## Registration
6
6
 
7
7
  - **Tool**: `summarize`
8
- - **Modes**: ask, act
9
- - **Category**: structural
10
- - **Handler**: None — projection only.
8
+ - **Category**: `logging`
9
+ - **Handler**: None — recorded by TurnExecutor as a lifecycle signal.
11
10
 
12
11
  ## Projection
13
12
 
@@ -15,4 +14,6 @@ Shows `summarize` followed by the entry body.
15
14
 
16
15
  ## Behavior
17
16
 
18
- No handler logic. The tool registration exists so the model can emit summary entries that appear in context via projection.
17
+ If the model sends `<summarize>` but actions in the same turn failed,
18
+ TurnExecutor overrides it to `<update>` — the model's assertion that
19
+ it's done is false.
@@ -1,17 +1,18 @@
1
- import { readFileSync } from "node:fs";
1
+ import docs from "./summarizeDoc.js";
2
2
 
3
3
  export default class Summarize {
4
4
  #core;
5
5
 
6
6
  constructor(core) {
7
7
  this.#core = core;
8
- core.registerScheme({ validStates: ["summary"], category: "structural" });
8
+ core.ensureTool();
9
+ core.registerScheme({ category: "logging" });
9
10
  core.on("full", this.full.bind(this));
10
11
  core.on("summary", this.summary.bind(this));
11
- const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
12
- core.filter("instructions.toolDocs", async (content) =>
13
- content ? `${content}\n\n${docs}` : docs,
14
- );
12
+ core.filter("instructions.toolDocs", async (docsMap) => {
13
+ docsMap.summarize = docs;
14
+ return docsMap;
15
+ });
15
16
  }
16
17
 
17
18
  full(entry) {
@@ -0,0 +1,33 @@
1
+ // Tool doc for <summarize>. 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
+ // --- Syntax
6
+ ["## <summarize>[answer or summary]</summarize> - Signal completion"],
7
+
8
+ // --- Examples: answer and task completion
9
+ [
10
+ "Example: <summarize>The port is 8080</summarize>",
11
+ "Direct answer. Shows summarize as the vehicle for delivering answers.",
12
+ ],
13
+ [
14
+ "Example: <summarize>Installed express, updated config</summarize>",
15
+ "Task summary. Shows summarize for action completion.",
16
+ ],
17
+
18
+ // --- Constraints: RFC-style MUST/MUST NOT
19
+ [
20
+ "* YOU MUST use <summarize> when done — describes the final state",
21
+ "Completion signal. Without this, the loop continues indefinitely.",
22
+ ],
23
+ [
24
+ "* YOU MUST NOT use <summarize> if still working — use <update/> instead",
25
+ "Mutual exclusion with update. Prevents premature completion.",
26
+ ],
27
+ [
28
+ "* YOU MUST keep <summarize> to <= 80 characters",
29
+ "Length cap. Matches the summary attribute constraint. Prevents verbose output.",
30
+ ],
31
+ ];
32
+
33
+ export default LINES.map(([text]) => text).join("\n");
@@ -75,22 +75,28 @@ export default class Telemetry {
75
75
  result,
76
76
  responseMessage,
77
77
  content,
78
- commands,
79
78
  unparsed,
79
+ assembledTokens,
80
80
  systemMsg,
81
81
  userMsg,
82
82
  }) {
83
- const { entries: store, runId } = rummy;
83
+ const { entries: store, runId, loopId } = rummy;
84
84
 
85
85
  // assistant://N — the model's raw response
86
- await store.upsert(runId, turn, `assistant://${turn}`, content, "info");
86
+ await store.upsert(runId, turn, `assistant://${turn}`, content, 200, {
87
+ loopId,
88
+ });
87
89
 
88
90
  // system://N, user://N — assembled messages as audit
89
91
  if (systemMsg) {
90
- await store.upsert(runId, turn, `system://${turn}`, systemMsg, "info");
92
+ await store.upsert(runId, turn, `system://${turn}`, systemMsg, 200, {
93
+ loopId,
94
+ });
91
95
  }
92
96
  if (userMsg) {
93
- await store.upsert(runId, turn, `user://${turn}`, userMsg, "info");
97
+ await store.upsert(runId, turn, `user://${turn}`, userMsg, 200, {
98
+ loopId,
99
+ });
94
100
  }
95
101
 
96
102
  // model://N — raw API response diagnostics
@@ -105,7 +111,8 @@ export default class Telemetry {
105
111
  usage: result.usage || null,
106
112
  model: result.model || null,
107
113
  }),
108
- "info",
114
+ 200,
115
+ { loopId },
109
116
  );
110
117
 
111
118
  // reasoning://N
@@ -115,13 +122,16 @@ export default class Telemetry {
115
122
  turn,
116
123
  `reasoning://${turn}`,
117
124
  responseMessage.reasoning_content,
118
- "info",
125
+ 200,
126
+ { loopId },
119
127
  );
120
128
  }
121
129
 
122
130
  // content://N — unparsed text
123
131
  if (unparsed) {
124
- await store.upsert(runId, turn, `content://${turn}`, unparsed, "info");
132
+ await store.upsert(runId, turn, `content://${turn}`, unparsed, 200, {
133
+ loopId,
134
+ });
125
135
  }
126
136
 
127
137
  // Commit usage stats
@@ -139,6 +149,8 @@ export default class Telemetry {
139
149
  0;
140
150
  await rummy.db.update_turn_stats.run({
141
151
  id: rummy.turnId,
152
+ context_tokens: assembledTokens ?? 0,
153
+ reasoning_content: responseMessage?.reasoning_content || null,
142
154
  prompt_tokens: usage.prompt_tokens ?? 0,
143
155
  cached_tokens: cachedTokens ?? 0,
144
156
  completion_tokens: usage.completion_tokens ?? 0,
@@ -0,0 +1,20 @@
1
+ # think
2
+
3
+ Provides a `<think>` tag for model reasoning. Not a tool — does not
4
+ appear in the tool list.
5
+
6
+ ## Registration
7
+
8
+ - **Scheme**: `think` — `category: "logging"`, `model_visible: 0`
9
+ - **No handler, no view, no tool registration**
10
+
11
+ ## Behavior
12
+
13
+ The model writes `<think>reasoning</think>` before tool commands.
14
+ XmlParser captures it, TurnExecutor records it as a `think://` entry.
15
+ Invisible to the model on subsequent turns (`model_visible: 0`).
16
+ Available for debugging and audit.
17
+
18
+ Models with server-side reasoning (extended thinking) use that
19
+ capability independently. The `<think>` tag is a floor — every model
20
+ gets at least this.
@@ -0,0 +1,5 @@
1
+ export default class Think {
2
+ constructor(core) {
3
+ core.registerScheme({ modelVisible: 0, category: "logging" });
4
+ }
5
+ }
@@ -7,9 +7,9 @@ The Rumsfeld mechanism. The model registers what it doesn't know before acting.
7
7
  ## Registration
8
8
 
9
9
  - **Tool**: `unknown`
10
- - **Modes**: ask, act
11
- - **Category**: structural
10
+ - **Category**: `unknown`
12
11
  - **Handler**: None — recorded by TurnExecutor, deduplicated against existing unknowns.
12
+ - **Filter**: `assembly.system` at priority 300 — renders `<unknowns>` section.
13
13
 
14
14
  ## Projection
15
15
 
@@ -18,6 +18,6 @@ The Rumsfeld mechanism. The model registers what it doesn't know before acting.
18
18
  ## Behavior
19
19
 
20
20
  Unknowns are sticky — they persist across turns until the model explicitly
21
- stores or removes them. The model investigates unknowns using `<get>`,
22
- `<env>`, or `<ask_user>`, then removes resolved ones with `<rm>`.
23
- Server deduplicates on insert.
21
+ removes them with `<rm>`. The model investigates unknowns using `<get>`,
22
+ `<env>`, or `<ask_user>`, then removes resolved ones. Server deduplicates
23
+ on insert. Turn numbers shown on each unknown for temporal reasoning.
@@ -1,20 +1,20 @@
1
- import { readFileSync } from "node:fs";
1
+ import docs from "./unknownDoc.js";
2
2
 
3
3
  export default class Unknown {
4
4
  #core;
5
5
 
6
6
  constructor(core) {
7
7
  this.#core = core;
8
+ core.ensureTool();
8
9
  core.registerScheme({
9
- validStates: ["full", "stored"],
10
- category: "knowledge",
10
+ category: "unknown",
11
11
  });
12
12
  core.on("full", this.full.bind(this));
13
13
  core.filter("assembly.system", this.assembleUnknowns.bind(this), 300);
14
- const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
15
- core.filter("instructions.toolDocs", async (content) =>
16
- content ? `${content}\n\n${docs}` : docs,
17
- );
14
+ core.filter("instructions.toolDocs", async (docsMap) => {
15
+ docsMap.unknown = docs;
16
+ return docsMap;
17
+ });
18
18
  }
19
19
 
20
20
  full(entry) {
@@ -25,7 +25,10 @@ export default class Unknown {
25
25
  const entries = ctx.rows.filter((r) => r.category === "unknown");
26
26
  if (entries.length === 0) return content;
27
27
 
28
- const lines = entries.map((u) => `<unknown>${u.body}</unknown>`);
28
+ const lines = entries.map(
29
+ (u) =>
30
+ `<unknown path="${u.path}" turn="${u.source_turn || u.turn}">${u.body}</unknown>`,
31
+ );
29
32
  return `${content}\n\n<unknowns>\n${lines.join("\n")}\n</unknowns>`;
30
33
  }
31
34
  }
@@ -0,0 +1,31 @@
1
+ // Tool doc for <unknown>. 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
+ // --- Syntax: body = what you need to learn
6
+ [
7
+ `## <unknown>[specific thing I need to learn]</unknown> - Track open questions`,
8
+ ],
9
+
10
+ // --- Examples: concrete unknowns, not abstract
11
+ [
12
+ `Example: <unknown path="unknown://answer">contents of answer.txt</unknown>`,
13
+ `Specific and actionable. Shows that unknowns are concrete investigation targets.`,
14
+ ],
15
+ [
16
+ `Example: <unknown>which database adapter is configured</unknown>`,
17
+ `Domain question. Shows unknowns for configuration/architecture questions.`,
18
+ ],
19
+
20
+ // --- Lifecycle: register → investigate → resolve
21
+ [
22
+ `* Investigate with Tool Commands`,
23
+ `Cross-tool lifecycle: unknowns drive get/env/ask_user actions.`,
24
+ ],
25
+ [
26
+ `* When resolved or irrelevant, remove with <rm path="unknown://..."/>`,
27
+ `Cross-tool lifecycle: rm cleans resolved unknowns from context.`,
28
+ ],
29
+ ];
30
+
31
+ export default LINES.map(([text]) => text).join("\n");
@@ -1,18 +1,13 @@
1
1
  # update
2
2
 
3
- Structural tool for model-generated progress updates.
3
+ Lifecycle signal the model declares it has more work to do.
4
4
 
5
5
  ## Registration
6
6
 
7
7
  - **Tool**: `update`
8
- - **Modes**: ask, act
9
- - **Category**: structural
10
- - **Handler**: None — projection only.
8
+ - **Category**: `logging`
9
+ - **Handler**: None — recorded by TurnExecutor as a lifecycle signal.
11
10
 
12
11
  ## Projection
13
12
 
14
13
  Shows `update` followed by the entry body.
15
-
16
- ## Behavior
17
-
18
- No handler logic. Allows the model to emit progress/status entries that appear in context via projection.
@@ -1,17 +1,18 @@
1
- import { readFileSync } from "node:fs";
1
+ import docs from "./updateDoc.js";
2
2
 
3
3
  export default class Update {
4
4
  #core;
5
5
 
6
6
  constructor(core) {
7
7
  this.#core = core;
8
- core.registerScheme({ validStates: ["info"], category: "structural" });
8
+ core.ensureTool();
9
+ core.registerScheme({ category: "logging" });
9
10
  core.on("full", this.full.bind(this));
10
11
  core.on("summary", this.summary.bind(this));
11
- const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
12
- core.filter("instructions.toolDocs", async (content) =>
13
- content ? `${content}\n\n${docs}` : docs,
14
- );
12
+ core.filter("instructions.toolDocs", async (docsMap) => {
13
+ docsMap.update = docs;
14
+ return docsMap;
15
+ });
15
16
  }
16
17
 
17
18
  full(entry) {
@@ -0,0 +1,33 @@
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
+ // --- Syntax
6
+ ["## <update>[brief status]</update> - Signal continuation"],
7
+
8
+ // --- Examples: research progress and multi-step work
9
+ [
10
+ "Example: <update>Reading config files</update>",
11
+ "Progress checkpoint. Shows update as a status signal, not a log entry.",
12
+ ],
13
+ [
14
+ "Example: <update>Found 3 issues, fixing first</update>",
15
+ "Multi-step progress. Shows update for ongoing work.",
16
+ ],
17
+
18
+ // --- Constraints: RFC-style MUST/MUST NOT
19
+ [
20
+ "* YOU MUST use <update> if still working — describes the current state",
21
+ "Continuation signal. Triggers the next turn in the loop.",
22
+ ],
23
+ [
24
+ "* YOU MUST NOT use <update> if done — use <summarize/> instead",
25
+ "Mutual exclusion with summarize. Prevents infinite loops.",
26
+ ],
27
+ [
28
+ "* YOU MUST keep <update> to <= 80 characters",
29
+ "Length cap. Prevents models from writing essays in status updates.",
30
+ ],
31
+ ];
32
+
33
+ export default LINES.map(([text]) => text).join("\n");
@@ -110,7 +110,7 @@ export default class ClientConnection {
110
110
 
111
111
  try {
112
112
  const logRow = await this.#db.log_rpc_call.get({
113
- project_id: this.#context.projectId || null,
113
+ project_id: this.#context.projectId ?? null,
114
114
  method,
115
115
  rpc_id: id,
116
116
  params: params ? JSON.stringify(params) : null,
@@ -184,10 +184,8 @@ export default class ClientConnection {
184
184
  } catch {}
185
185
  }
186
186
  } catch (error) {
187
- if (debug) {
188
- console.error(`[SOCKET] ERR: ${error.message}`);
189
- console.error(`[DEBUG] Stack: ${error.stack}`);
190
- }
187
+ console.error(`[RUMMY] RPC Error: ${error.message}`);
188
+ console.error(`[RUMMY] Stack: ${error.stack}`);
191
189
  this.#send({
192
190
  jsonrpc: "2.0",
193
191
  error: { code: -32603, message: error.message },
@@ -12,8 +12,6 @@ export default class RpcRegistry {
12
12
  longRunning = false,
13
13
  },
14
14
  ) {
15
- if (this.#methods.has(name))
16
- throw new Error(`RPC method '${name}' already registered.`);
17
15
  this.#methods.set(
18
16
  name,
19
17
  Object.freeze({
@@ -26,16 +24,57 @@ export default class RpcRegistry {
26
24
  );
27
25
  }
28
26
 
27
+ #toolFallback = null;
28
+
29
+ /**
30
+ * Set a fallback that auto-dispatches any registered tool via RPC.
31
+ * Checked at request time — tools registered after this call still work.
32
+ */
33
+ setToolFallback(hooks, buildRunContext, dispatchTool) {
34
+ this.#toolFallback = { hooks, buildRunContext, dispatchTool };
35
+ }
36
+
29
37
  registerNotification(name, description = "") {
30
38
  this.#notifications.set(name, Object.freeze({ description }));
31
39
  }
32
40
 
33
41
  get(name) {
34
- return this.#methods.get(name);
42
+ const method = this.#methods.get(name);
43
+ if (method) return method;
44
+ return this.#resolveToolFallback(name);
35
45
  }
36
46
 
37
47
  has(name) {
38
- return this.#methods.has(name);
48
+ return this.#methods.has(name) || !!this.#resolveToolFallback(name);
49
+ }
50
+
51
+ #resolveToolFallback(name) {
52
+ if (!this.#toolFallback) return undefined;
53
+ const { hooks, buildRunContext, dispatchTool } = this.#toolFallback;
54
+ if (!hooks.tools.has(name)) return undefined;
55
+ return Object.freeze({
56
+ handler: async (params, ctx) => {
57
+ if (!params.path) throw new Error("path is required");
58
+ if (!params.run) throw new Error("run is required");
59
+ const { rummy } = await buildRunContext(hooks, ctx, params.run);
60
+ await dispatchTool(hooks, rummy, name, params.path, params.body || "", {
61
+ path: params.path,
62
+ to: params.to,
63
+ ...params.attributes,
64
+ });
65
+ return { status: "ok" };
66
+ },
67
+ description: `Dispatch ${name} tool.`,
68
+ params: {
69
+ run: "string — run alias",
70
+ path: "string — entry path",
71
+ body: "string? — entry content",
72
+ to: "string? — destination path",
73
+ attributes: "object? — JSON attributes",
74
+ },
75
+ requiresInit: true,
76
+ longRunning: false,
77
+ });
39
78
  }
40
79
 
41
80
  discover() {
@@ -43,6 +82,15 @@ export default class RpcRegistry {
43
82
  for (const [name, def] of this.#methods) {
44
83
  methods[name] = { description: def.description, params: def.params };
45
84
  }
85
+ // Include auto-dispatched tools not explicitly registered
86
+ if (this.#toolFallback) {
87
+ for (const name of this.#toolFallback.hooks.tools.names) {
88
+ if (methods[name]) continue;
89
+ const def = this.#resolveToolFallback(name);
90
+ if (def)
91
+ methods[name] = { description: def.description, params: def.params };
92
+ }
93
+ }
46
94
  const notifications = {};
47
95
  for (const [name, def] of this.#notifications) {
48
96
  notifications[name] = { description: def.description };