@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
@@ -29,9 +29,8 @@ export default class HookRegistry {
29
29
  await p.callback(rummy);
30
30
  if (this.#debug) {
31
31
  const duration = (performance.now() - start).toFixed(2);
32
- console.log(
33
- `[PIPELINE] Processor ${p.callback.name || "anonymous"} took ${duration}ms`,
34
- );
32
+ const name = p.callback.name ? p.callback.name : "anonymous";
33
+ console.log(`[PIPELINE] Processor ${name} took ${duration}ms`);
35
34
  }
36
35
  }
37
36
  }
@@ -46,7 +45,8 @@ export default class HookRegistry {
46
45
  }
47
46
 
48
47
  async applyFilters(tag, value, ...args) {
49
- const hooks = this.#filters.get(tag) || [];
48
+ const hooks = this.#filters.get(tag);
49
+ if (!hooks) return value;
50
50
  let result = value;
51
51
  for (const h of hooks) {
52
52
  result = await h.callback(result, ...args);
@@ -71,7 +71,8 @@ export default class HookRegistry {
71
71
  }
72
72
 
73
73
  async emitEvent(tag, ...args) {
74
- const hooks = this.#events.get(tag) || [];
74
+ const hooks = this.#events.get(tag);
75
+ if (!hooks) return;
75
76
  for (const h of hooks) {
76
77
  await h.callback(...args);
77
78
  }
@@ -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,6 +67,12 @@ 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
  }
@@ -78,7 +86,7 @@ export default class PluginContext {
78
86
  /**
79
87
  * Register a named callback for this plugin.
80
88
  * "handler" registers the tool handler.
81
- * "promoted"/"demoted" register fidelity projections.
89
+ * "visible"/"summarized" register visibility projections.
82
90
  * "docs" sets tool documentation.
83
91
  * Everything else resolves to a hook event.
84
92
  */
@@ -88,7 +96,7 @@ export default class PluginContext {
88
96
  this.#hooks.tools.onHandle(this.#name, callback, priority);
89
97
  return;
90
98
  }
91
- if (event === "promoted" || event === "demoted") {
99
+ if (event === "visible" || event === "summarized") {
92
100
  this.#hooks.tools.onView(this.#name, callback, event);
93
101
  return;
94
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,51 +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
162
  path = await this.entries.slugPath(
107
163
  this.runId,
108
164
  "known",
109
- body || "",
165
+ body,
110
166
  attributes?.summary,
111
167
  );
112
168
  }
113
- await this.entries.upsert(
114
- this.runId,
115
- this.sequence,
169
+ await this.entries.set({
170
+ runId: this.runId,
171
+ turn: this.sequence,
116
172
  path,
117
- body || "",
118
- status,
119
- { fidelity, attributes, loopId: this.loopId },
120
- );
173
+ body,
174
+ state,
175
+ outcome,
176
+ visibility,
177
+ attributes,
178
+ loopId: this.loopId,
179
+ });
121
180
  return path;
122
181
  }
123
182
 
124
183
  async get(path) {
125
- 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
+ });
126
190
  }
127
191
 
128
- async store(path) {
129
- await this.entries.demoteByPattern(this.runId, path, null);
192
+ async rm(path) {
193
+ await this.entries.rm({ runId: this.runId, path: path });
130
194
  }
131
195
 
132
- async rm(path) {
133
- 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
+ });
134
205
  }
135
206
 
136
207
  async mv(from, to) {
137
208
  const body = await this.entries.getBody(this.runId, from);
138
209
  if (body === null) return;
139
- 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",
140
216
  loopId: this.loopId,
141
217
  });
142
- await this.entries.remove(this.runId, from);
218
+ await this.entries.rm({ runId: this.runId, path: from });
143
219
  }
144
220
 
145
221
  async cp(from, to) {
146
222
  const body = await this.entries.getBody(this.runId, from);
147
223
  if (body === null) return;
148
- 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",
149
230
  loopId: this.loopId,
150
231
  });
151
232
  }
@@ -160,9 +241,16 @@ export default class RummyContext {
160
241
  return this.entries.getAttributes(this.runId, path);
161
242
  }
162
243
 
163
- async getStatus(path) {
244
+ async getState(path) {
164
245
  const row = await this.entries.getState(this.runId, path);
165
- 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;
166
254
  }
167
255
 
168
256
  async getEntry(path) {
@@ -171,11 +259,16 @@ export default class RummyContext {
171
259
  path,
172
260
  null,
173
261
  );
174
- return results[0] || null;
262
+ if (results.length === 0) return null;
263
+ return results[0];
175
264
  }
176
265
 
177
266
  async setAttributes(path, attrs) {
178
- return this.entries.setAttributes(this.runId, path, attrs);
267
+ return this.entries.set({
268
+ runId: this.runId,
269
+ path: path,
270
+ attributes: attrs,
271
+ });
179
272
  }
180
273
 
181
274
  async getEntries(pattern, bodyFilter) {
@@ -184,7 +277,13 @@ export default class RummyContext {
184
277
 
185
278
  async log(message) {
186
279
  const path = `content://${Date.now()}`;
187
- 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
+ });
188
287
  }
189
288
 
190
289
  // --- Node tree methods ---
@@ -194,7 +293,8 @@ export default class RummyContext {
194
293
  const childArray = Array.isArray(children) ? children : [children];
195
294
  for (const child of childArray) {
196
295
  if (typeof child === "string") {
197
- node.content = (node.content || "") + child;
296
+ if (node.content === null) node.content = "";
297
+ node.content += child;
198
298
  } else if (child && typeof child === "object") {
199
299
  node.children.push(child);
200
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
  }
@@ -39,10 +40,9 @@ export default class ToolRegistry {
39
40
  this.#tools.set(scheme, Object.freeze({}));
40
41
  }
41
42
 
42
- // Mark a tool as hidden handler still dispatches if the model emits the
43
- // tag, but the tool is excluded from all model-facing tool lists. Used for
44
- // legacy/internal schemes (e.g. <known>, <unknown>) we want to retire
45
- // without deleting.
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
46
  markHidden(scheme) {
47
47
  this.#hidden.add(scheme);
48
48
  }
@@ -62,30 +62,35 @@ export default class ToolRegistry {
62
62
  list.sort((a, b) => a.priority - b.priority);
63
63
  }
64
64
 
65
- onView(scheme, fn, fidelity = "promoted") {
65
+ onView(scheme, fn, visibility = "visible") {
66
66
  if (!this.#views.has(scheme)) this.#views.set(scheme, new Map());
67
- this.#views.get(scheme).set(fidelity, fn);
67
+ this.#views.get(scheme).set(visibility, fn);
68
68
  }
69
69
 
70
70
  async view(scheme, entry) {
71
- const fidelityMap = this.#views.get(scheme);
72
- if (!fidelityMap) {
71
+ const visibilityMap = this.#views.get(scheme);
72
+ if (!visibilityMap) {
73
73
  throw new Error(
74
74
  `No view registered for scheme '${scheme}'. ` +
75
75
  `Every tool must define how its entries appear in the model view.`,
76
76
  );
77
77
  }
78
78
 
79
- const fidelity = entry.fidelity || "promoted";
80
- const fn = fidelityMap.get(fidelity);
79
+ const visibility =
80
+ entry.visibility === undefined ? "visible" : entry.visibility;
81
+ const fn = visibilityMap.get(visibility);
81
82
  if (!fn) return "";
82
83
 
83
- return fn(entry);
84
+ const body = await fn(entry);
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;
84
89
  }
85
90
 
86
91
  hasView(scheme) {
87
- const fidelityMap = this.#views.get(scheme);
88
- return fidelityMap?.size > 0;
92
+ const visibilityMap = this.#views.get(scheme);
93
+ return visibilityMap?.size > 0;
89
94
  }
90
95
 
91
96
  async dispatch(scheme, entry, rummy) {