@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,4 +1,5 @@
1
1
  import HookRegistry from "./HookRegistry.js";
2
+ import RpcRegistry from "./RpcRegistry.js";
2
3
  import ToolRegistry from "./ToolRegistry.js";
3
4
 
4
5
  /**
@@ -56,10 +57,25 @@ export default function createHooks(debug = false) {
56
57
  turn: {
57
58
  started: createEvent("turn.started"),
58
59
  response: createEvent("turn.response"),
59
- proposal: createEvent("turn.proposal"),
60
- proposing: createEvent("turn.proposing"),
61
60
  completed: createEvent("turn.completed"),
62
61
  },
62
+ proposal: {
63
+ prepare: createEvent("proposal.prepare"),
64
+ pending: createEvent("proposal.pending"),
65
+ // Plugins veto acceptance by returning {allow:false, outcome, body}.
66
+ // Used e.g. by set plugin's readonly constraint check.
67
+ accepting: createFilter("proposal.accepting"),
68
+ // Plugins compose the resolved body based on path/action. Default
69
+ // is output || "". Used e.g. by set plugin to preserve the
70
+ // model's proposed content as the resolved body.
71
+ content: createFilter("proposal.content"),
72
+ // Fires after a proposal resolves with action="accept". Plugins
73
+ // perform their side effects (file materialize, unlink, stream
74
+ // setup, etc.) here — NOT in AgentLoop.resolve.
75
+ accepted: createEvent("proposal.accepted"),
76
+ // Fires after a proposal resolves with action="error" or "reject".
77
+ rejected: createEvent("proposal.rejected"),
78
+ },
63
79
  assembly: {
64
80
  system: createFilter("assembly.system"),
65
81
  user: createFilter("assembly.user"),
@@ -82,6 +98,25 @@ export default function createHooks(debug = false) {
82
98
  },
83
99
  messages: createFilter("llm.messages"),
84
100
  response: createFilter("llm.response"),
101
+ // Reasoning merge filter. Subscribers contribute per-tag
102
+ // reasoning text (e.g. the think plugin's <think>…</think>)
103
+ // to the model's reasoning_content field. Fires between parse
104
+ // and turn.response.
105
+ reasoning: createFilter("llm.reasoning"),
106
+ // LLM provider registry. Plugins contribute entries shaped:
107
+ // {
108
+ // name: string,
109
+ // matches: (modelAlias) => boolean,
110
+ // completion: (messages, modelAlias, options) => Promise<response>,
111
+ // getContextSize: (modelAlias) => Promise<number>,
112
+ // }
113
+ // Each provider owns a prefix namespace (e.g. "openai/", "ollama/",
114
+ // "openrouter/"). LlmProvider picks the first provider whose
115
+ // matches() returns true. No catchall — if a model alias doesn't
116
+ // match any registered provider, the request fails with a clear
117
+ // "no provider registered" error. External plugins add new
118
+ // prefixes without namespace collision.
119
+ providers: [],
85
120
  },
86
121
  file: {},
87
122
  prompt: {
@@ -100,6 +135,12 @@ export default function createHooks(debug = false) {
100
135
  materialized: createEvent("context.materialized"),
101
136
  },
102
137
  action: {},
138
+ error: {
139
+ log: createEvent("error.log"),
140
+ },
141
+ stream: {
142
+ cancelled: createEvent("stream.cancelled"),
143
+ },
103
144
  ui: {
104
145
  render: createEvent("ui.render"),
105
146
  notify: createEvent("ui.notify"),
@@ -117,7 +158,7 @@ export default function createHooks(debug = false) {
117
158
  response: {
118
159
  result: createFilter("rpc.response.result"),
119
160
  },
120
- registry: null, // attached by service.js after RpcRegistry creation
161
+ registry: new RpcRegistry(),
121
162
  },
122
163
  agent: {},
123
164
  tools,
@@ -10,8 +10,6 @@
10
10
  export default class PluginContext {
11
11
  #name;
12
12
  #hooks;
13
- #db = null;
14
- #store = null;
15
13
 
16
14
  constructor(name, hooks) {
17
15
  this.#name = name;
@@ -22,22 +20,6 @@ export default class PluginContext {
22
20
  return this.#name;
23
21
  }
24
22
 
25
- get db() {
26
- return this.#db;
27
- }
28
-
29
- set db(value) {
30
- this.#db = value;
31
- }
32
-
33
- get entries() {
34
- return this.#store;
35
- }
36
-
37
- set entries(value) {
38
- this.#store = value;
39
- }
40
-
41
23
  #schemes = [];
42
24
 
43
25
  get hooks() {
@@ -48,16 +30,36 @@ export default class PluginContext {
48
30
  return this.#schemes;
49
31
  }
50
32
 
51
- registerScheme({ name, modelVisible = 1, category = "logging" } = {}) {
33
+ registerScheme({
34
+ name,
35
+ modelVisible = 1,
36
+ category = "logging",
37
+ scope = "run",
38
+ writableBy = ["model", "plugin"],
39
+ } = {}) {
52
40
  if (!PluginContext.CATEGORIES.has(category)) {
53
41
  throw new Error(
54
42
  `Invalid category "${category}". Must be one of: ${[...PluginContext.CATEGORIES].join(", ")}`,
55
43
  );
56
44
  }
45
+ if (!PluginContext.SCOPES.has(scope)) {
46
+ throw new Error(
47
+ `Invalid scope "${scope}". Must be one of: ${[...PluginContext.SCOPES].join(", ")}`,
48
+ );
49
+ }
50
+ for (const w of writableBy) {
51
+ if (!PluginContext.WRITERS.has(w)) {
52
+ throw new Error(
53
+ `Invalid writer "${w}" in writableBy. Must be one of: ${[...PluginContext.WRITERS].join(", ")}`,
54
+ );
55
+ }
56
+ }
57
57
  this.#schemes.push({
58
58
  name: name || this.#name,
59
59
  model_visible: modelVisible,
60
60
  category,
61
+ default_scope: scope,
62
+ writable_by: JSON.stringify(writableBy),
61
63
  });
62
64
  }
63
65
 
@@ -65,14 +67,26 @@ export default class PluginContext {
65
67
  new Set(["data", "logging", "unknown", "prompt"]),
66
68
  );
67
69
 
70
+ static SCOPES = Object.freeze(new Set(["run", "project", "global"]));
71
+
72
+ static WRITERS = Object.freeze(
73
+ new Set(["model", "plugin", "client", "system"]),
74
+ );
75
+
68
76
  ensureTool() {
69
77
  this.#hooks.tools.ensureTool(this.#name);
70
78
  }
71
79
 
80
+ // Mark this plugin's tool as hidden from model-facing tool lists.
81
+ // Handler still dispatches if the model emits the tag.
82
+ markHidden() {
83
+ this.#hooks.tools.markHidden(this.#name);
84
+ }
85
+
72
86
  /**
73
87
  * Register a named callback for this plugin.
74
88
  * "handler" registers the tool handler.
75
- * "full"/"summary" register fidelity projections.
89
+ * "visible"/"summarized" register visibility projections.
76
90
  * "docs" sets tool documentation.
77
91
  * Everything else resolves to a hook event.
78
92
  */
@@ -82,7 +96,7 @@ export default class PluginContext {
82
96
  this.#hooks.tools.onHandle(this.#name, callback, priority);
83
97
  return;
84
98
  }
85
- if (event === "full" || event === "summary") {
99
+ if (event === "visible" || event === "summarized") {
86
100
  this.#hooks.tools.onView(this.#name, callback, event);
87
101
  return;
88
102
  }
@@ -57,7 +57,8 @@ export default class RpcRegistry {
57
57
  if (!params.path) throw new Error("path is required");
58
58
  if (!params.run) throw new Error("run is required");
59
59
  const { rummy } = await buildRunContext(hooks, ctx, params.run);
60
- await dispatchTool(hooks, rummy, name, params.path, params.body || "", {
60
+ const { body = "" } = params;
61
+ await dispatchTool(hooks, rummy, name, params.path, body, {
61
62
  path: params.path,
62
63
  to: params.to,
63
64
  ...params.attributes,
@@ -2,17 +2,40 @@
2
2
  * RummyContext provides a unified, semantic API for plugins to interact with
3
3
  * the Turn node tree and core resources like the Database and Project metadata.
4
4
  */
5
+ // Entries write verbs that should automatically carry the caller's
6
+ // writer identity. Handler-issued writes on behalf of the model default
7
+ // to writer=model; plugin background writes (set via rummy from a hook
8
+ // with writer: "plugin" or "system" in ctx) get the context's writer.
9
+ const WRITE_VERBS = new Set(["set", "rm", "cp", "mv", "update"]);
10
+
11
+ // Defaults applied at construction so every plugin-facing getter
12
+ // returns a predictable shape without per-access fallbacks.
13
+ const CONTEXT_DEFAULTS = Object.freeze({
14
+ hooks: null,
15
+ activeFiles: [],
16
+ sequence: 0,
17
+ runId: null,
18
+ turnId: null,
19
+ loopId: null,
20
+ toolSet: null,
21
+ contextSize: null,
22
+ systemPrompt: "",
23
+ loopPrompt: "",
24
+ writer: "model",
25
+ });
26
+
5
27
  export default class RummyContext {
6
28
  #root;
7
29
  #context;
30
+ #wrappedStore;
8
31
 
9
32
  constructor(root, context) {
10
33
  this.#root = root;
11
- this.#context = context;
34
+ this.#context = { ...CONTEXT_DEFAULTS, ...context };
12
35
  }
13
36
 
14
37
  get hooks() {
15
- return this.#context.hooks || null;
38
+ return this.#context.hooks;
16
39
  }
17
40
 
18
41
  get db() {
@@ -20,7 +43,19 @@ export default class RummyContext {
20
43
  }
21
44
 
22
45
  get entries() {
23
- return this.#context.store || null;
46
+ if (this.#wrappedStore) return this.#wrappedStore;
47
+ const store = this.#context.store;
48
+ if (!store) return null;
49
+ const writer = this.writer;
50
+ this.#wrappedStore = new Proxy(store, {
51
+ get(target, prop) {
52
+ const val = target[prop];
53
+ if (typeof val !== "function") return val;
54
+ if (!WRITE_VERBS.has(prop)) return val.bind(target);
55
+ return (args = {}) => val.call(target, { writer, ...args });
56
+ },
57
+ });
58
+ return this.#wrappedStore;
24
59
  }
25
60
 
26
61
  get project() {
@@ -28,7 +63,7 @@ export default class RummyContext {
28
63
  }
29
64
 
30
65
  get activeFiles() {
31
- return this.#context.activeFiles || [];
66
+ return this.#context.activeFiles;
32
67
  }
33
68
 
34
69
  get type() {
@@ -40,19 +75,19 @@ export default class RummyContext {
40
75
  }
41
76
 
42
77
  get sequence() {
43
- return this.#context.sequence || 0;
78
+ return this.#context.sequence;
44
79
  }
45
80
 
46
81
  get runId() {
47
- return this.#context.runId || null;
82
+ return this.#context.runId;
48
83
  }
49
84
 
50
85
  get turnId() {
51
- return this.#context.turnId || null;
86
+ return this.#context.turnId;
52
87
  }
53
88
 
54
89
  get loopId() {
55
- return this.#context.loopId || null;
90
+ return this.#context.loopId;
56
91
  }
57
92
 
58
93
  get noRepo() {
@@ -67,20 +102,34 @@ export default class RummyContext {
67
102
  return this.#context.noWeb === true;
68
103
  }
69
104
 
105
+ get noProposals() {
106
+ return this.#context.noProposals === true;
107
+ }
108
+
70
109
  get toolSet() {
71
- return this.#context.toolSet || null;
110
+ return this.#context.toolSet;
72
111
  }
73
112
 
74
113
  get contextSize() {
75
- return this.#context.contextSize || null;
114
+ return this.#context.contextSize;
76
115
  }
77
116
 
78
117
  get systemPrompt() {
79
- return this.#context.systemPrompt || "";
118
+ return this.#context.systemPrompt;
80
119
  }
81
120
 
82
121
  get loopPrompt() {
83
- return this.#context.loopPrompt || "";
122
+ return this.#context.loopPrompt;
123
+ }
124
+
125
+ /**
126
+ * Writer identity for Entries permission checks. Defaults to
127
+ * 'model' — handlers write on behalf of the model's emitted command.
128
+ * Non-handler plugin code (streaming callbacks, background emissions)
129
+ * passes `writer: 'plugin'` or `'system'` explicitly.
130
+ */
131
+ get writer() {
132
+ return this.#context.writer;
84
133
  }
85
134
 
86
135
  get system() {
@@ -101,48 +150,83 @@ export default class RummyContext {
101
150
 
102
151
  // --- Tool methods (same operations the model uses) ---
103
152
 
104
- async set({ path, body, status = 200, fidelity, attributes } = {}) {
153
+ async set({
154
+ path,
155
+ body = "",
156
+ state = "resolved",
157
+ outcome = null,
158
+ visibility,
159
+ attributes,
160
+ } = {}) {
105
161
  if (!path) {
106
- const slugify = (await import("../sql/functions/slugify.js")).default;
107
- const base = slugify(body || "");
108
- path = `known://${base || Date.now()}`;
162
+ path = await this.entries.slugPath(
163
+ this.runId,
164
+ "known",
165
+ body,
166
+ attributes?.summary,
167
+ );
109
168
  }
110
- await this.entries.upsert(
111
- this.runId,
112
- this.sequence,
169
+ await this.entries.set({
170
+ runId: this.runId,
171
+ turn: this.sequence,
113
172
  path,
114
- body || "",
115
- status,
116
- { fidelity, attributes, loopId: this.loopId },
117
- );
173
+ body,
174
+ state,
175
+ outcome,
176
+ visibility,
177
+ attributes,
178
+ loopId: this.loopId,
179
+ });
118
180
  return path;
119
181
  }
120
182
 
121
183
  async get(path) {
122
- await this.entries.promoteByPattern(this.runId, path, null, this.sequence);
184
+ await this.entries.get({
185
+ runId: this.runId,
186
+ turn: this.sequence,
187
+ path: path,
188
+ bodyFilter: null,
189
+ });
123
190
  }
124
191
 
125
- async store(path) {
126
- await this.entries.demoteByPattern(this.runId, path, null);
192
+ async rm(path) {
193
+ await this.entries.rm({ runId: this.runId, path: path });
127
194
  }
128
195
 
129
- async rm(path) {
130
- await this.entries.remove(this.runId, path);
196
+ async update(body, { status = 102, attributes = {} } = {}) {
197
+ return this.entries.update({
198
+ runId: this.runId,
199
+ turn: this.sequence,
200
+ body,
201
+ status,
202
+ attributes,
203
+ loopId: this.loopId,
204
+ });
131
205
  }
132
206
 
133
207
  async mv(from, to) {
134
208
  const body = await this.entries.getBody(this.runId, from);
135
209
  if (body === null) return;
136
- await this.entries.upsert(this.runId, this.sequence, to, body, 200, {
210
+ await this.entries.set({
211
+ runId: this.runId,
212
+ turn: this.sequence,
213
+ path: to,
214
+ body,
215
+ state: "resolved",
137
216
  loopId: this.loopId,
138
217
  });
139
- await this.entries.remove(this.runId, from);
218
+ await this.entries.rm({ runId: this.runId, path: from });
140
219
  }
141
220
 
142
221
  async cp(from, to) {
143
222
  const body = await this.entries.getBody(this.runId, from);
144
223
  if (body === null) return;
145
- await this.entries.upsert(this.runId, this.sequence, to, body, 200, {
224
+ await this.entries.set({
225
+ runId: this.runId,
226
+ turn: this.sequence,
227
+ path: to,
228
+ body,
229
+ state: "resolved",
146
230
  loopId: this.loopId,
147
231
  });
148
232
  }
@@ -157,9 +241,16 @@ export default class RummyContext {
157
241
  return this.entries.getAttributes(this.runId, path);
158
242
  }
159
243
 
160
- async getStatus(path) {
244
+ async getState(path) {
161
245
  const row = await this.entries.getState(this.runId, path);
162
- return row?.status ?? null;
246
+ if (!row) return null;
247
+ return row.state;
248
+ }
249
+
250
+ async getOutcome(path) {
251
+ const row = await this.entries.getState(this.runId, path);
252
+ if (!row) return null;
253
+ return row.outcome;
163
254
  }
164
255
 
165
256
  async getEntry(path) {
@@ -168,11 +259,16 @@ export default class RummyContext {
168
259
  path,
169
260
  null,
170
261
  );
171
- return results[0] || null;
262
+ if (results.length === 0) return null;
263
+ return results[0];
172
264
  }
173
265
 
174
266
  async setAttributes(path, attrs) {
175
- return this.entries.setAttributes(this.runId, path, attrs);
267
+ return this.entries.set({
268
+ runId: this.runId,
269
+ path: path,
270
+ attributes: attrs,
271
+ });
176
272
  }
177
273
 
178
274
  async getEntries(pattern, bodyFilter) {
@@ -181,7 +277,13 @@ export default class RummyContext {
181
277
 
182
278
  async log(message) {
183
279
  const path = `content://${Date.now()}`;
184
- await this.entries.upsert(this.runId, this.sequence, path, message, 200);
280
+ await this.entries.set({
281
+ runId: this.runId,
282
+ turn: this.sequence,
283
+ path,
284
+ body: message,
285
+ state: "resolved",
286
+ });
185
287
  }
186
288
 
187
289
  // --- Node tree methods ---
@@ -191,7 +293,8 @@ export default class RummyContext {
191
293
  const childArray = Array.isArray(children) ? children : [children];
192
294
  for (const child of childArray) {
193
295
  if (typeof child === "string") {
194
- node.content = (node.content || "") + child;
296
+ if (node.content === null) node.content = "";
297
+ node.content += child;
195
298
  } else if (child && typeof child === "object") {
196
299
  node.children.push(child);
197
300
  }
@@ -1,5 +1,6 @@
1
1
  // Tool display order: gather → reason → act → communicate.
2
2
  // Position in the list implies priority to the model.
3
+ // `update` is pinned last — it's the turn-closer, not an action.
3
4
  const TOOL_ORDER = [
4
5
  "think",
5
6
  "unknown",
@@ -12,18 +13,18 @@ const TOOL_ORDER = [
12
13
  "cp",
13
14
  "mv",
14
15
  "ask_user",
15
- "update",
16
- "summarize",
17
16
  "search",
18
17
  ];
19
18
 
20
19
  function sortByPriority(names) {
21
20
  return names.toSorted((a, b) => {
21
+ if (a === "update") return 1;
22
+ if (b === "update") return -1;
22
23
  const ia = TOOL_ORDER.indexOf(a);
23
24
  const ib = TOOL_ORDER.indexOf(b);
24
25
  if (ia === -1 && ib === -1) return a.localeCompare(b);
25
26
  if (ia === -1) return 1;
26
- if (ib === -1) return 1;
27
+ if (ib === -1) return -1;
27
28
  return ia - ib;
28
29
  });
29
30
  }
@@ -32,12 +33,20 @@ export default class ToolRegistry {
32
33
  #tools = new Map();
33
34
  #handlers = new Map();
34
35
  #views = new Map();
36
+ #hidden = new Set();
35
37
 
36
38
  ensureTool(scheme) {
37
39
  if (this.#tools.has(scheme)) return;
38
40
  this.#tools.set(scheme, Object.freeze({}));
39
41
  }
40
42
 
43
+ // Hidden tools dispatch on direct emission but don't appear in any
44
+ // model-facing tool list. Internal schemes (e.g. <known>, <unknown>)
45
+ // the model writes via <set path="scheme://..."> instead.
46
+ markHidden(scheme) {
47
+ this.#hidden.add(scheme);
48
+ }
49
+
41
50
  get(name) {
42
51
  return this.#tools.get(name);
43
52
  }
@@ -53,51 +62,35 @@ export default class ToolRegistry {
53
62
  list.sort((a, b) => a.priority - b.priority);
54
63
  }
55
64
 
56
- onView(scheme, fn, fidelity = "full") {
65
+ onView(scheme, fn, visibility = "visible") {
57
66
  if (!this.#views.has(scheme)) this.#views.set(scheme, new Map());
58
- this.#views.get(scheme).set(fidelity, fn);
67
+ this.#views.get(scheme).set(visibility, fn);
59
68
  }
60
69
 
61
70
  async view(scheme, entry) {
62
- const fidelityMap = this.#views.get(scheme);
63
- if (!fidelityMap) {
71
+ const visibilityMap = this.#views.get(scheme);
72
+ if (!visibilityMap) {
64
73
  throw new Error(
65
74
  `No view registered for scheme '${scheme}'. ` +
66
75
  `Every tool must define how its entries appear in the model view.`,
67
76
  );
68
77
  }
69
78
 
70
- const attrs =
71
- typeof entry.attributes === "string"
72
- ? JSON.parse(entry.attributes)
73
- : entry.attributes;
74
- const summary = typeof attrs?.summary === "string" ? attrs.summary : null;
75
-
76
- const fidelity = entry.fidelity || "full";
77
- const fn = fidelityMap.get(fidelity);
78
- if (!fn) {
79
- // No view for this fidelity — fall back on model-authored summary
80
- return summary || "";
81
- }
79
+ const visibility =
80
+ entry.visibility === undefined ? "visible" : entry.visibility;
81
+ const fn = visibilityMap.get(visibility);
82
+ if (!fn) return "";
82
83
 
83
84
  const body = await fn(entry);
84
-
85
- // Prepend summary keywords above plugin output at summary fidelity
86
- if (fidelity === "summary" && summary && body) {
87
- return `${summary}\n${body}`;
88
- }
89
-
90
- // Fall back to summary attribute when plugin returns empty
91
- if (fidelity === "summary" && summary && !body) {
92
- return summary;
93
- }
94
-
95
- return body;
85
+ // View handlers MAY return undefined or null to mean "no projected
86
+ // body at this visibility" normalize at this boundary so callers
87
+ // get a predictable string.
88
+ return body == null ? "" : body;
96
89
  }
97
90
 
98
91
  hasView(scheme) {
99
- const fidelityMap = this.#views.get(scheme);
100
- return fidelityMap?.size > 0;
92
+ const visibilityMap = this.#views.get(scheme);
93
+ return visibilityMap?.size > 0;
101
94
  }
102
95
 
103
96
  async dispatch(scheme, entry, rummy) {
@@ -113,15 +106,23 @@ export default class ToolRegistry {
113
106
  return sortByPriority([...this.#tools.keys()]);
114
107
  }
115
108
 
109
+ // Names advertised to the model — registered tools minus hidden ones.
110
+ // Use this anywhere a tool list is shown to the model.
111
+ get advertisedNames() {
112
+ return sortByPriority(
113
+ [...this.#tools.keys()].filter((n) => !this.#hidden.has(n)),
114
+ );
115
+ }
116
+
116
117
  /**
117
118
  * Compute the active tool set for a loop.
118
- * All exclusions — mode, flags — handled here. One mechanism.
119
+ * All exclusions — mode, flags, hidden — handled here. One mechanism.
119
120
  */
120
121
  resolveForLoop(
121
122
  mode,
122
123
  { noInteraction = false, noWeb = false, noProposals = false } = {},
123
124
  ) {
124
- const excluded = new Set();
125
+ const excluded = new Set(this.#hidden);
125
126
  if (mode === "ask") excluded.add("sh");
126
127
  if (noInteraction) excluded.add("ask_user");
127
128
  if (noWeb) excluded.add("search");