@possumtech/rummy 0.5.0 → 2.0.1

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 (157) hide show
  1. package/.env.example +42 -5
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +934 -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 +13 -11
  11. package/scriptify/ask_run.js +77 -0
  12. package/service.js +50 -9
  13. package/src/agent/AgentLoop.js +476 -335
  14. package/src/agent/ContextAssembler.js +4 -4
  15. package/src/agent/Entries.js +676 -0
  16. package/src/agent/ProjectAgent.js +30 -18
  17. package/src/agent/TurnExecutor.js +232 -421
  18. package/src/agent/XmlParser.js +99 -33
  19. package/src/agent/budget.js +56 -0
  20. package/src/agent/errors.js +22 -0
  21. package/src/agent/httpStatus.js +39 -0
  22. package/src/agent/known_checks.sql +8 -4
  23. package/src/agent/known_queries.sql +9 -13
  24. package/src/agent/known_store.sql +280 -125
  25. package/src/agent/materializeContext.js +104 -0
  26. package/src/agent/runs.sql +29 -7
  27. package/src/agent/schemes.sql +14 -3
  28. package/src/agent/tokens.js +6 -0
  29. package/src/agent/turns.sql +9 -9
  30. package/src/hooks/HookRegistry.js +6 -5
  31. package/src/hooks/Hooks.js +44 -3
  32. package/src/hooks/PluginContext.js +29 -21
  33. package/src/{server → hooks}/RpcRegistry.js +2 -1
  34. package/src/hooks/RummyContext.js +139 -35
  35. package/src/hooks/ToolRegistry.js +21 -16
  36. package/src/llm/LlmProvider.js +66 -89
  37. package/src/llm/errors.js +21 -0
  38. package/src/llm/retry.js +63 -0
  39. package/src/plugins/ask_user/README.md +1 -1
  40. package/src/plugins/ask_user/ask_user.js +37 -12
  41. package/src/plugins/ask_user/ask_userDoc.js +2 -25
  42. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  43. package/src/plugins/budget/README.md +27 -25
  44. package/src/plugins/budget/budget.js +306 -88
  45. package/src/plugins/cp/README.md +2 -2
  46. package/src/plugins/cp/cp.js +29 -11
  47. package/src/plugins/cp/cpDoc.js +2 -15
  48. package/src/plugins/cp/cpDoc.md +7 -0
  49. package/src/plugins/engine/README.md +2 -2
  50. package/src/plugins/engine/engine.sql +4 -4
  51. package/src/plugins/engine/turn_context.sql +10 -10
  52. package/src/plugins/env/README.md +20 -5
  53. package/src/plugins/env/env.js +45 -6
  54. package/src/plugins/env/envDoc.js +2 -23
  55. package/src/plugins/env/envDoc.md +13 -0
  56. package/src/plugins/error/README.md +16 -0
  57. package/src/plugins/error/error.js +151 -0
  58. package/src/plugins/file/README.md +6 -6
  59. package/src/plugins/file/file.js +15 -2
  60. package/src/plugins/get/README.md +1 -1
  61. package/src/plugins/get/get.js +103 -48
  62. package/src/plugins/get/getDoc.js +2 -32
  63. package/src/plugins/get/getDoc.md +36 -0
  64. package/src/plugins/hedberg/README.md +1 -2
  65. package/src/plugins/hedberg/hedberg.js +8 -4
  66. package/src/plugins/hedberg/matcher.js +16 -17
  67. package/src/plugins/hedberg/normalize.js +0 -48
  68. package/src/plugins/helpers.js +42 -2
  69. package/src/plugins/index.js +146 -123
  70. package/src/plugins/instructions/README.md +35 -9
  71. package/src/plugins/instructions/instructions.js +244 -9
  72. package/src/plugins/instructions/instructions.md +33 -0
  73. package/src/plugins/instructions/instructions_104.md +7 -0
  74. package/src/plugins/instructions/instructions_105.md +38 -0
  75. package/src/plugins/instructions/instructions_106.md +21 -0
  76. package/src/plugins/instructions/instructions_107.md +10 -0
  77. package/src/plugins/instructions/instructions_108.md +0 -0
  78. package/src/plugins/instructions/protocol.js +12 -0
  79. package/src/plugins/known/README.md +2 -2
  80. package/src/plugins/known/known.js +68 -36
  81. package/src/plugins/known/knownDoc.js +2 -17
  82. package/src/plugins/known/knownDoc.md +8 -0
  83. package/src/plugins/log/README.md +48 -0
  84. package/src/plugins/log/log.js +129 -0
  85. package/src/plugins/mv/README.md +2 -2
  86. package/src/plugins/mv/mv.js +55 -22
  87. package/src/plugins/mv/mvDoc.js +2 -18
  88. package/src/plugins/mv/mvDoc.md +10 -0
  89. package/src/plugins/ollama/README.md +15 -0
  90. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  91. package/src/plugins/openai/README.md +17 -0
  92. package/src/plugins/openai/openai.js +120 -0
  93. package/src/plugins/openrouter/README.md +27 -0
  94. package/src/plugins/openrouter/openrouter.js +121 -0
  95. package/src/plugins/persona/README.md +20 -0
  96. package/src/plugins/persona/persona.js +9 -16
  97. package/src/plugins/policy/README.md +21 -0
  98. package/src/plugins/policy/policy.js +29 -14
  99. package/src/plugins/prompt/README.md +1 -1
  100. package/src/plugins/prompt/prompt.js +64 -16
  101. package/src/plugins/rm/README.md +1 -1
  102. package/src/plugins/rm/rm.js +56 -12
  103. package/src/plugins/rm/rmDoc.js +2 -20
  104. package/src/plugins/rm/rmDoc.md +13 -0
  105. package/src/plugins/rpc/README.md +2 -2
  106. package/src/plugins/rpc/rpc.js +525 -296
  107. package/src/plugins/set/README.md +1 -1
  108. package/src/plugins/set/set.js +318 -75
  109. package/src/plugins/set/setDoc.js +2 -35
  110. package/src/plugins/set/setDoc.md +22 -0
  111. package/src/plugins/sh/README.md +28 -5
  112. package/src/plugins/sh/sh.js +50 -6
  113. package/src/plugins/sh/shDoc.js +2 -23
  114. package/src/plugins/sh/shDoc.md +13 -0
  115. package/src/plugins/skill/README.md +23 -0
  116. package/src/plugins/skill/skill.js +14 -18
  117. package/src/plugins/stream/README.md +101 -0
  118. package/src/plugins/stream/stream.js +290 -0
  119. package/src/plugins/telemetry/README.md +1 -1
  120. package/src/plugins/telemetry/telemetry.js +129 -80
  121. package/src/plugins/think/README.md +1 -1
  122. package/src/plugins/think/think.js +12 -0
  123. package/src/plugins/think/thinkDoc.js +2 -15
  124. package/src/plugins/think/thinkDoc.md +7 -0
  125. package/src/plugins/unknown/README.md +3 -3
  126. package/src/plugins/unknown/unknown.js +47 -19
  127. package/src/plugins/unknown/unknownDoc.js +2 -21
  128. package/src/plugins/unknown/unknownDoc.md +11 -0
  129. package/src/plugins/update/README.md +1 -1
  130. package/src/plugins/update/update.js +83 -5
  131. package/src/plugins/update/updateDoc.js +2 -30
  132. package/src/plugins/update/updateDoc.md +8 -0
  133. package/src/plugins/xai/README.md +23 -0
  134. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  135. package/src/plugins/yolo/yolo.js +192 -0
  136. package/src/server/ClientConnection.js +64 -37
  137. package/src/server/SocketServer.js +23 -10
  138. package/src/server/protocol.js +11 -0
  139. package/src/sql/v_model_context.sql +27 -31
  140. package/src/sql/v_run_log.sql +9 -14
  141. package/EXCEPTIONS.md +0 -46
  142. package/FIDELITY_CONTRACT.md +0 -172
  143. package/src/agent/KnownStore.js +0 -337
  144. package/src/agent/ResponseHealer.js +0 -241
  145. package/src/llm/OpenAiClient.js +0 -100
  146. package/src/llm/OpenRouterClient.js +0 -100
  147. package/src/plugins/budget/recovery.js +0 -47
  148. package/src/plugins/instructions/preamble.md +0 -45
  149. package/src/plugins/performed/README.md +0 -15
  150. package/src/plugins/performed/performed.js +0 -45
  151. package/src/plugins/previous/README.md +0 -16
  152. package/src/plugins/previous/previous.js +0 -56
  153. package/src/plugins/progress/README.md +0 -16
  154. package/src/plugins/progress/progress.js +0 -43
  155. package/src/plugins/summarize/README.md +0 -19
  156. package/src/plugins/summarize/summarize.js +0 -32
  157. package/src/plugins/summarize/summarizeDoc.js +0 -27
@@ -39,12 +39,12 @@ WHERE run_id = :run_id AND context_tokens > 0
39
39
  ORDER BY sequence DESC
40
40
  LIMIT 1;
41
41
 
42
- -- PREP: get_run_log
43
- SELECT ke.path, ke.status, ke.body, ke.attributes
44
- FROM known_entries AS ke
45
- JOIN schemes AS s ON s.name = COALESCE(ke.scheme, 'file')
46
- WHERE
47
- ke.run_id = :run_id
48
- AND ke.scheme IS NOT NULL
49
- AND s.category NOT IN ('knowledge')
50
- ORDER BY ke.id;
42
+ -- PREP: get_turns_by_run
43
+ SELECT
44
+ id, run_id, loop_id, sequence, context_tokens, prompt_tokens,
45
+ cached_tokens, completion_tokens, reasoning_tokens, total_tokens, cost
46
+ FROM turns
47
+ WHERE run_id = :run_id
48
+ ORDER BY sequence;
49
+
50
+ -- get_run_log retired — use get_results (v_run_log) instead.
@@ -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,38 @@ 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
+
109
+ get yolo() {
110
+ return this.#context.yolo === true;
111
+ }
112
+
70
113
  get toolSet() {
71
- return this.#context.toolSet || null;
114
+ return this.#context.toolSet;
72
115
  }
73
116
 
74
117
  get contextSize() {
75
- return this.#context.contextSize || null;
118
+ return this.#context.contextSize;
76
119
  }
77
120
 
78
121
  get systemPrompt() {
79
- return this.#context.systemPrompt || "";
122
+ return this.#context.systemPrompt;
80
123
  }
81
124
 
82
125
  get loopPrompt() {
83
- return this.#context.loopPrompt || "";
126
+ return this.#context.loopPrompt;
127
+ }
128
+
129
+ /**
130
+ * Writer identity for Entries permission checks. Defaults to
131
+ * 'model' — handlers write on behalf of the model's emitted command.
132
+ * Non-handler plugin code (streaming callbacks, background emissions)
133
+ * passes `writer: 'plugin'` or `'system'` explicitly.
134
+ */
135
+ get writer() {
136
+ return this.#context.writer;
84
137
  }
85
138
 
86
139
  get system() {
@@ -101,51 +154,83 @@ export default class RummyContext {
101
154
 
102
155
  // --- Tool methods (same operations the model uses) ---
103
156
 
104
- async set({ path, body, status = 200, fidelity, attributes } = {}) {
157
+ async set({
158
+ path,
159
+ body = "",
160
+ state = "resolved",
161
+ outcome = null,
162
+ visibility,
163
+ attributes,
164
+ } = {}) {
105
165
  if (!path) {
106
166
  path = await this.entries.slugPath(
107
167
  this.runId,
108
168
  "known",
109
- body || "",
169
+ body,
110
170
  attributes?.summary,
111
171
  );
112
172
  }
113
- await this.entries.upsert(
114
- this.runId,
115
- this.sequence,
173
+ await this.entries.set({
174
+ runId: this.runId,
175
+ turn: this.sequence,
116
176
  path,
117
- body || "",
118
- status,
119
- { fidelity, attributes, loopId: this.loopId },
120
- );
177
+ body,
178
+ state,
179
+ outcome,
180
+ visibility,
181
+ attributes,
182
+ loopId: this.loopId,
183
+ });
121
184
  return path;
122
185
  }
123
186
 
124
187
  async get(path) {
125
- await this.entries.promoteByPattern(this.runId, path, null, this.sequence);
188
+ await this.entries.get({
189
+ runId: this.runId,
190
+ turn: this.sequence,
191
+ path: path,
192
+ bodyFilter: null,
193
+ });
126
194
  }
127
195
 
128
- async store(path) {
129
- await this.entries.demoteByPattern(this.runId, path, null);
196
+ async rm(path) {
197
+ await this.entries.rm({ runId: this.runId, path: path });
130
198
  }
131
199
 
132
- async rm(path) {
133
- await this.entries.remove(this.runId, path);
200
+ async update(body, { status = 102, attributes = {} } = {}) {
201
+ return this.entries.update({
202
+ runId: this.runId,
203
+ turn: this.sequence,
204
+ body,
205
+ status,
206
+ attributes,
207
+ loopId: this.loopId,
208
+ });
134
209
  }
135
210
 
136
211
  async mv(from, to) {
137
212
  const body = await this.entries.getBody(this.runId, from);
138
213
  if (body === null) return;
139
- await this.entries.upsert(this.runId, this.sequence, to, body, 200, {
214
+ await this.entries.set({
215
+ runId: this.runId,
216
+ turn: this.sequence,
217
+ path: to,
218
+ body,
219
+ state: "resolved",
140
220
  loopId: this.loopId,
141
221
  });
142
- await this.entries.remove(this.runId, from);
222
+ await this.entries.rm({ runId: this.runId, path: from });
143
223
  }
144
224
 
145
225
  async cp(from, to) {
146
226
  const body = await this.entries.getBody(this.runId, from);
147
227
  if (body === null) return;
148
- await this.entries.upsert(this.runId, this.sequence, to, body, 200, {
228
+ await this.entries.set({
229
+ runId: this.runId,
230
+ turn: this.sequence,
231
+ path: to,
232
+ body,
233
+ state: "resolved",
149
234
  loopId: this.loopId,
150
235
  });
151
236
  }
@@ -160,9 +245,16 @@ export default class RummyContext {
160
245
  return this.entries.getAttributes(this.runId, path);
161
246
  }
162
247
 
163
- async getStatus(path) {
248
+ async getState(path) {
164
249
  const row = await this.entries.getState(this.runId, path);
165
- return row?.status ?? null;
250
+ if (!row) return null;
251
+ return row.state;
252
+ }
253
+
254
+ async getOutcome(path) {
255
+ const row = await this.entries.getState(this.runId, path);
256
+ if (!row) return null;
257
+ return row.outcome;
166
258
  }
167
259
 
168
260
  async getEntry(path) {
@@ -171,11 +263,16 @@ export default class RummyContext {
171
263
  path,
172
264
  null,
173
265
  );
174
- return results[0] || null;
266
+ if (results.length === 0) return null;
267
+ return results[0];
175
268
  }
176
269
 
177
270
  async setAttributes(path, attrs) {
178
- return this.entries.setAttributes(this.runId, path, attrs);
271
+ return this.entries.set({
272
+ runId: this.runId,
273
+ path: path,
274
+ attributes: attrs,
275
+ });
179
276
  }
180
277
 
181
278
  async getEntries(pattern, bodyFilter) {
@@ -184,7 +281,13 @@ export default class RummyContext {
184
281
 
185
282
  async log(message) {
186
283
  const path = `content://${Date.now()}`;
187
- await this.entries.upsert(this.runId, this.sequence, path, message, 200);
284
+ await this.entries.set({
285
+ runId: this.runId,
286
+ turn: this.sequence,
287
+ path,
288
+ body: message,
289
+ state: "resolved",
290
+ });
188
291
  }
189
292
 
190
293
  // --- Node tree methods ---
@@ -194,7 +297,8 @@ export default class RummyContext {
194
297
  const childArray = Array.isArray(children) ? children : [children];
195
298
  for (const child of childArray) {
196
299
  if (typeof child === "string") {
197
- node.content = (node.content || "") + child;
300
+ if (node.content === null) node.content = "";
301
+ node.content += child;
198
302
  } else if (child && typeof child === "object") {
199
303
  node.children.push(child);
200
304
  }
@@ -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) {