@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
@@ -0,0 +1,121 @@
1
+ import msg from "../../agent/messages.js";
2
+
3
+ const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
+ if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
5
+
6
+ const PROVIDER = "openrouter";
7
+
8
+ /**
9
+ * OpenRouter LLM provider plugin. Handles model aliases of the form
10
+ * `openrouter/{publisher}/{modelName}`. Strips only the provider
11
+ * segment — OpenRouter's own API expects the `publisher/model` form,
12
+ * so that's exactly what's passed through to it (e.g.
13
+ * `openrouter/anthropic/claude-3-opus` → API receives
14
+ * `anthropic/claude-3-opus`).
15
+ *
16
+ * Inert if OPENROUTER_API_KEY / OPENROUTER_BASE_URL aren't set.
17
+ */
18
+ export default class OpenRouter {
19
+ #apiKey;
20
+ #baseUrl;
21
+ #contextCache = new Map();
22
+
23
+ constructor(core) {
24
+ const apiKey = process.env.OPENROUTER_API_KEY;
25
+ const baseUrl = process.env.OPENROUTER_BASE_URL;
26
+ if (!apiKey || !baseUrl) return;
27
+ this.#apiKey = apiKey;
28
+ this.#baseUrl = baseUrl;
29
+
30
+ const wireModel = (alias) => alias.split("/").slice(1).join("/");
31
+
32
+ core.hooks.llm.providers.push({
33
+ name: PROVIDER,
34
+ matches: (model) => model.split("/")[0] === PROVIDER,
35
+ completion: (messages, model, options) =>
36
+ this.#completion(messages, wireModel(model), options),
37
+ getContextSize: (model) => this.#getContextSize(wireModel(model)),
38
+ });
39
+ }
40
+
41
+ async #completion(messages, model, options = {}) {
42
+ const body = { model, messages, include_reasoning: true };
43
+ if (options.temperature !== undefined)
44
+ body.temperature = options.temperature;
45
+
46
+ const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT);
47
+ const signal = options.signal
48
+ ? AbortSignal.any([options.signal, timeoutSignal])
49
+ : timeoutSignal;
50
+
51
+ const response = await fetch(`${this.#baseUrl}/chat/completions`, {
52
+ method: "POST",
53
+ headers: {
54
+ Authorization: `Bearer ${this.#apiKey}`,
55
+ "Content-Type": "application/json",
56
+ "HTTP-Referer": process.env.RUMMY_HTTP_REFERER,
57
+ "X-Title": process.env.RUMMY_X_TITLE,
58
+ },
59
+ body: JSON.stringify(body),
60
+ signal,
61
+ });
62
+
63
+ if (!response.ok) {
64
+ const error = await response.text();
65
+ if (response.status === 401 || response.status === 403) {
66
+ throw new Error(
67
+ msg("error.openrouter_auth", {
68
+ status: `${response.status} - ${error}`,
69
+ }),
70
+ );
71
+ }
72
+ throw new Error(
73
+ msg("error.openrouter_api", {
74
+ status: `${response.status} - ${error}`,
75
+ }),
76
+ );
77
+ }
78
+ const data = await response.json();
79
+
80
+ for (const choice of data.choices) {
81
+ const cm = choice.message;
82
+ if (!cm) continue;
83
+ const details = cm.reasoning_details
84
+ ? cm.reasoning_details.map((d) => d.text)
85
+ : [];
86
+ const parts = [
87
+ cm.reasoning_content,
88
+ cm.reasoning,
89
+ cm.thinking,
90
+ ...details,
91
+ ].filter(Boolean);
92
+ cm.reasoning_content =
93
+ parts.length > 0 ? [...new Set(parts)].join("\n") : null;
94
+ }
95
+
96
+ return data;
97
+ }
98
+
99
+ async #getContextSize(model) {
100
+ if (this.#contextCache.has(model)) return this.#contextCache.get(model);
101
+
102
+ const res = await fetch(`${this.#baseUrl}/models`, {
103
+ headers: { Authorization: `Bearer ${this.#apiKey}` },
104
+ signal: AbortSignal.timeout(5000),
105
+ });
106
+ if (!res.ok) {
107
+ throw new Error(
108
+ `OpenRouter /models returned ${res.status}; cannot resolve context size for "${model}".`,
109
+ );
110
+ }
111
+ const data = await res.json();
112
+ const entry = data.data?.find((m) => m.id === model);
113
+ if (!entry?.context_length) {
114
+ throw new Error(
115
+ `OpenRouter /models has no context_length for "${model}".`,
116
+ );
117
+ }
118
+ this.#contextCache.set(model, entry.context_length);
119
+ return entry.context_length;
120
+ }
121
+ }
@@ -0,0 +1,20 @@
1
+ # persona {#persona_plugin}
2
+
3
+ Runtime persona management. A persona is free-form text that gets
4
+ prepended to the model's system prompt for a run.
5
+
6
+ ## Files
7
+
8
+ - **persona.js** — RPC registration and persona file loading.
9
+
10
+ ## RPC Methods
11
+
12
+ | Method | Params | Notes |
13
+ |--------|--------|-------|
14
+ | `persona/set` | `{ run, name?, text? }` | Set persona by filename (`${RUMMY_HOME}/personas/<name>.md`) or raw text. Pass neither to clear. |
15
+ | `listPersonas` | — | Return `[{name, path}]` for available persona files. |
16
+
17
+ ## Behavior
18
+
19
+ Persona is stored on the run row (`runs.persona`). The instructions
20
+ plugin reads it during system-prompt assembly.
@@ -20,10 +20,13 @@ export default class Persona {
20
20
  text = await loadFile(params.name);
21
21
  }
22
22
 
23
+ // "Pass neither to clear" — empty string counts as clear too.
24
+ let persona = null;
25
+ if (text) persona = text;
23
26
  await ctx.db.update_run_config.run({
24
27
  id: runRow.id,
25
28
  temperature: null,
26
- persona: text || null,
29
+ persona,
27
30
  context_limit: null,
28
31
  model: null,
29
32
  });
@@ -44,14 +47,10 @@ export default class Persona {
44
47
  handler: async () => {
45
48
  const dir = configDir();
46
49
  if (!dir) return [];
47
- try {
48
- const files = await fs.readdir(dir);
49
- return files
50
- .filter((f) => f.endsWith(".md"))
51
- .map((f) => ({ name: f.replace(".md", ""), path: join(dir, f) }));
52
- } catch {
53
- return [];
54
- }
50
+ const files = await fs.readdir(dir);
51
+ return files
52
+ .filter((f) => f.endsWith(".md"))
53
+ .map((f) => ({ name: f.replace(".md", ""), path: join(dir, f) }));
55
54
  },
56
55
  description: "List available persona files. Returns [{ name, path }].",
57
56
  requiresInit: true,
@@ -68,11 +67,5 @@ function configDir() {
68
67
  async function loadFile(name) {
69
68
  const dir = configDir();
70
69
  if (!dir) throw new Error("RUMMY_HOME not configured");
71
- const path = join(dir, `${name}.md`);
72
- try {
73
- return await fs.readFile(path, "utf8");
74
- } catch (err) {
75
- if (err.code === "ENOENT") throw new Error(`Not found: ${path}`);
76
- throw err;
77
- }
70
+ return fs.readFile(join(dir, `${name}.md`), "utf8");
78
71
  }
@@ -0,0 +1,21 @@
1
+ # policy {#policy_plugin}
2
+
3
+ Per-invocation enforcement of ask-mode restrictions. Rejects
4
+ model-emitted commands that would mutate the filesystem when the run
5
+ was started in `ask` mode.
6
+
7
+ ## Registration
8
+
9
+ - **Filter**: `entry.recording` (priority 1) — runs before a command
10
+ becomes an entry.
11
+
12
+ ## Rejections (ask mode only)
13
+
14
+ - `<sh>` — any shell command.
15
+ - `<set path="file.txt">` — file-scheme writes (bare path, non-scheme).
16
+ - `<rm path="file.txt">` — file-scheme deletes.
17
+ - `<mv>` / `<cp>` into a file-scheme destination.
18
+
19
+ Each rejection logs via `error.log` and returns an entry with
20
+ `state: "failed"`, `outcome: "permission"` so it still appears in the
21
+ turn's audit trail.
@@ -1,44 +1,59 @@
1
- import KnownStore from "../../agent/KnownStore.js";
1
+ import Entries from "../../agent/Entries.js";
2
2
 
3
3
  export default class Policy {
4
+ #core;
5
+
4
6
  constructor(core) {
7
+ this.#core = core;
5
8
  core.filter("entry.recording", this.#enforceAskMode.bind(this), 1);
6
9
  }
7
10
 
11
+ async #reject(ctx, message) {
12
+ await this.#core.hooks.error.log.emit({
13
+ store: ctx.store,
14
+ runId: ctx.runId,
15
+ turn: ctx.turn,
16
+ loopId: ctx.loopId,
17
+ message,
18
+ });
19
+ }
20
+
8
21
  async #enforceAskMode(entry, ctx) {
9
22
  if (ctx.mode !== "ask") return entry;
10
23
 
11
24
  if (entry.scheme === "sh") {
12
- console.warn("[RUMMY] Rejected <sh> in ask mode");
13
- return { ...entry, status: 403 };
25
+ await this.#reject(ctx, "Rejected <sh> in ask mode");
26
+ return { ...entry, state: "failed", outcome: "permission" };
14
27
  }
15
28
 
16
29
  if (entry.scheme === "set" && entry.attributes?.path) {
17
- const scheme = KnownStore.scheme(entry.attributes.path);
30
+ const scheme = Entries.scheme(entry.attributes.path);
18
31
  if (scheme === null && entry.body) {
19
- console.warn(
20
- `[RUMMY] Rejected file edit to ${entry.attributes.path} in ask mode`,
32
+ await this.#reject(
33
+ ctx,
34
+ `Rejected file edit to ${entry.attributes.path} in ask mode`,
21
35
  );
22
- return { ...entry, status: 403 };
36
+ return { ...entry, state: "failed", outcome: "permission" };
23
37
  }
24
38
  }
25
39
 
26
40
  if (entry.scheme === "rm") {
27
41
  const pathAttr = entry.attributes?.path || entry.path;
28
- const scheme = KnownStore.scheme(pathAttr);
42
+ const scheme = Entries.scheme(pathAttr);
29
43
  if (scheme === null) {
30
- console.warn(`[RUMMY] Rejected file rm of ${pathAttr} in ask mode`);
31
- return { ...entry, status: 403 };
44
+ await this.#reject(ctx, `Rejected file rm of ${pathAttr} in ask mode`);
45
+ return { ...entry, state: "failed", outcome: "permission" };
32
46
  }
33
47
  }
34
48
 
35
49
  if (entry.scheme === "mv" || entry.scheme === "cp") {
36
- const destScheme = KnownStore.scheme(entry.attributes?.to);
50
+ const destScheme = Entries.scheme(entry.attributes?.to);
37
51
  if (destScheme === null) {
38
- console.warn(
39
- `[RUMMY] Rejected ${entry.scheme} to file ${entry.attributes?.to} in ask mode`,
52
+ await this.#reject(
53
+ ctx,
54
+ `Rejected ${entry.scheme} to file ${entry.attributes?.to} in ask mode`,
40
55
  );
41
- return { ...entry, status: 403 };
56
+ return { ...entry, state: "failed", outcome: "permission" };
42
57
  }
43
58
  }
44
59
 
@@ -1,4 +1,4 @@
1
- # prompt
1
+ # prompt {#prompt_plugin}
2
2
 
3
3
  Renders the `<prompt mode="ask|act">` tag at the end of the user message.
4
4
  Always present on every turn — the model always sees its task.
@@ -3,16 +3,17 @@ export default class Prompt {
3
3
 
4
4
  constructor(core) {
5
5
  this.#core = core;
6
- core.hooks.tools.onView("prompt", (entry) => {
7
- if (entry.fidelity === "summary") {
6
+ core.hooks.tools.onView("prompt", (entry) => entry.body, "visible");
7
+ core.hooks.tools.onView(
8
+ "prompt",
9
+ (entry) => {
8
10
  const limit = 500;
9
- const text = entry.body?.slice(0, limit) || "";
10
- return text.length < (entry.body?.length || 0)
11
- ? `${text}\n[truncated — promote to full to see the complete prompt]`
12
- : text;
13
- }
14
- return entry.body;
15
- });
11
+ const full = entry.body;
12
+ if (full.length <= limit) return full;
13
+ return `${full.slice(0, limit)}\n[truncated — promote to see the complete prompt]`;
14
+ },
15
+ "summarized",
16
+ );
16
17
  core.on("turn.started", this.onTurnStarted.bind(this));
17
18
  core.filter("assembly.user", this.assemblePrompt.bind(this), 300);
18
19
  }
@@ -21,15 +22,23 @@ export default class Prompt {
21
22
  const { entries: store, sequence: turn, runId, loopId } = rummy;
22
23
 
23
24
  if (!isContinuation && prompt) {
24
- await store.upsert(runId, turn, `prompt://${turn}`, prompt, 200, {
25
+ // prompt:// writable_by: ["plugin"] explicit for clarity.
26
+ await store.set({
27
+ runId,
28
+ turn,
29
+ path: `prompt://${turn}`,
30
+ body: prompt,
31
+ state: "resolved",
25
32
  attributes: { mode },
26
33
  loopId,
34
+ writer: "plugin",
27
35
  });
28
36
  }
29
37
  }
30
38
 
31
39
  async assemblePrompt(content, ctx) {
32
- const promptEntry = ctx.rows.findLast(
40
+ const { rows, contextSize, toolSet } = ctx;
41
+ const promptEntry = rows.findLast(
33
42
  (r) => r.category === "prompt" && r.scheme === "prompt",
34
43
  );
35
44
 
@@ -37,15 +46,51 @@ export default class Prompt {
37
46
  typeof promptEntry?.attributes === "string"
38
47
  ? JSON.parse(promptEntry.attributes)
39
48
  : promptEntry?.attributes;
40
- const mode = attrs?.mode || ctx.type;
41
- const body = promptEntry?.body || "";
42
- const toolNames = ctx.toolSet
43
- ? [...ctx.toolSet]
44
- : [...this.#core.hooks.tools.resolveForLoop(mode)];
45
- const tools = toolNames.join(",");
49
+ const mode = attrs?.mode ? attrs.mode : ctx.type;
50
+ const body = promptEntry ? promptEntry.body : "";
51
+ const activeTools = toolSet
52
+ ? new Set(toolSet)
53
+ : new Set(this.#core.hooks.tools.names);
54
+ const commands = this.#core.hooks.tools.advertisedNames
55
+ .filter((n) => activeTools.has(n))
56
+ .join(",");
46
57
  let warn = "";
47
58
  if (mode === "ask") warn = ' warn="File editing disallowed."';
48
59
 
49
- return `${content}<prompt mode="${mode}" tools="${tools}"${warn}>${body}</prompt>`;
60
+ // Surface the most recent prior-turn budget demotion as a
61
+ // `reverted="N"` attribute on <prompt>. Historical error
62
+ // entries sit in <log> but read as ambient noise; this signal
63
+ // is dynamic and always fresh — the model sees that its
64
+ // promotions last turn were reverted, in the same spot where
65
+ // it reads budget numbers.
66
+ let reverted = "";
67
+ const priorTurn = ctx.turn - 1;
68
+ if (priorTurn >= 1) {
69
+ const priorDemotion = rows.find((r) => {
70
+ if (!r.path.startsWith(`log://turn_${priorTurn}/error/`)) return false;
71
+ const attrs =
72
+ typeof r.attributes === "string"
73
+ ? JSON.parse(r.attributes)
74
+ : r.attributes;
75
+ return attrs?.status === 413 && attrs?.demotedCount > 0;
76
+ });
77
+ if (priorDemotion) {
78
+ const attrs =
79
+ typeof priorDemotion.attributes === "string"
80
+ ? JSON.parse(priorDemotion.attributes)
81
+ : priorDemotion.attributes;
82
+ reverted = ` reverted="${attrs.demotedCount}"`;
83
+ }
84
+ }
85
+
86
+ const path = promptEntry ? ` path="${promptEntry.path}"` : "";
87
+ const visibility = promptEntry?.visibility
88
+ ? ` visibility="${promptEntry.visibility}"`
89
+ : "";
90
+ const tokens =
91
+ promptEntry?.aTokens != null
92
+ ? ` tokens="${promptEntry.aTokens}"`
93
+ : "";
94
+ return `${content}<prompt mode="${mode}"${path} commands="${commands}"${warn}${reverted}${visibility}${tokens}>${body}</prompt>`;
50
95
  }
51
96
  }
@@ -1,4 +1,4 @@
1
- # rm
1
+ # rm {#rm_plugin}
2
2
 
3
3
  Removes entries by path or glob pattern.
4
4
 
@@ -1,6 +1,8 @@
1
- import KnownStore from "../../agent/KnownStore.js";
1
+ import Entries from "../../agent/Entries.js";
2
2
  import docs from "./rmDoc.js";
3
3
 
4
+ const LOG_ACTION_RE = /^log:\/\/turn_\d+\/(\w+)\//;
5
+
4
6
  export default class Rm {
5
7
  #core;
6
8
 
@@ -8,25 +10,50 @@ export default class Rm {
8
10
  this.#core = core;
9
11
  core.registerScheme();
10
12
  core.on("handler", this.handler.bind(this));
11
- core.on("full", this.full.bind(this));
12
- core.on("summary", this.summary.bind(this));
13
+ core.on("visible", this.full.bind(this));
14
+ core.on("summarized", this.summary.bind(this));
13
15
  core.filter("instructions.toolDocs", async (docsMap) => {
14
16
  docsMap.rm = docs;
15
17
  return docsMap;
16
18
  });
19
+ core.on("proposal.accepted", this.#onAccepted.bind(this));
20
+ }
21
+
22
+ async #onAccepted(ctx) {
23
+ const m = LOG_ACTION_RE.exec(ctx.path);
24
+ if (m?.[1] !== "rm") return;
25
+ const target = ctx.attrs?.path;
26
+ if (!target) return;
27
+ await ctx.entries.rm({ runId: ctx.runId, path: target });
28
+ if (ctx.projectRoot) {
29
+ const { unlink } = await import("node:fs/promises");
30
+ const { join } = await import("node:path");
31
+ try {
32
+ await unlink(join(ctx.projectRoot, target));
33
+ } catch (err) {
34
+ // File may already be absent — entry rm'd regardless.
35
+ if (err.code !== "ENOENT") throw err;
36
+ }
37
+ }
17
38
  }
18
39
 
19
40
  async handler(entry, rummy) {
20
41
  const { entries: store, sequence: turn, runId, loopId } = rummy;
21
42
  const target = entry.attributes.path;
22
43
  if (!target) {
23
- await store.upsert(runId, turn, entry.resultPath, "", 400, {
44
+ await store.set({
45
+ runId,
46
+ turn,
47
+ path: entry.resultPath,
48
+ body: "",
49
+ state: "failed",
50
+ outcome: "validation",
24
51
  attributes: { error: "path is required" },
25
52
  loopId,
26
53
  });
27
54
  return;
28
55
  }
29
- const normalized = KnownStore.normalizePath(target);
56
+ const normalized = Entries.normalizePath(target);
30
57
  const matches = await store.getEntriesByPattern(
31
58
  runId,
32
59
  normalized,
@@ -34,7 +61,13 @@ export default class Rm {
34
61
  );
35
62
 
36
63
  if (matches.length === 0) {
37
- await store.upsert(runId, turn, entry.resultPath, "", 404, {
64
+ await store.set({
65
+ runId,
66
+ turn,
67
+ path: entry.resultPath,
68
+ body: "",
69
+ state: "failed",
70
+ outcome: "not_found",
38
71
  attributes: { path: target, error: `${target} not found` },
39
72
  loopId,
40
73
  });
@@ -45,24 +78,35 @@ export default class Rm {
45
78
  const schemeMatches = matches.filter((m) => m.scheme !== null);
46
79
 
47
80
  // Scheme entries: remove all, write one aggregate result entry
48
- for (const match of schemeMatches) await store.remove(runId, match.path);
81
+ for (const match of schemeMatches)
82
+ await store.rm({ runId: runId, path: match.path });
49
83
  if (schemeMatches.length > 0) {
50
84
  const paths = schemeMatches.map((m) => m.path).join("\n");
51
- await store.upsert(runId, turn, entry.resultPath, paths, 200, {
85
+ await store.set({
86
+ runId,
87
+ turn,
88
+ path: entry.resultPath,
89
+ body: paths,
90
+ state: "resolved",
52
91
  attributes: { path: target },
53
92
  loopId,
54
93
  });
55
94
  }
56
95
 
57
- // File entries: individual 202 proposals (require user resolution)
96
+ // File entries: individual proposals (require user resolution)
58
97
  if (fileMatches.length > 0 && schemeMatches.length > 0)
59
- await store.remove(runId, entry.resultPath);
98
+ await store.rm({ runId: runId, path: entry.resultPath });
60
99
  for (const match of fileMatches) {
61
100
  const resultPath =
62
101
  schemeMatches.length === 0 && fileMatches.length === 1
63
102
  ? entry.resultPath
64
- : await store.dedup(runId, "rm", match.path, turn);
65
- await store.upsert(runId, turn, resultPath, match.path, 202, {
103
+ : await store.logPath(runId, turn, "rm", match.path);
104
+ await store.set({
105
+ runId,
106
+ turn,
107
+ path: resultPath,
108
+ body: match.path,
109
+ state: "proposed",
66
110
  attributes: { path: match.path },
67
111
  loopId,
68
112
  });
@@ -74,7 +118,7 @@ export default class Rm {
74
118
  return entry.body ? `${header}\n${entry.body}` : header;
75
119
  }
76
120
 
77
- summary(entry) {
78
- return this.full(entry);
121
+ summary() {
122
+ return "";
79
123
  }
80
124
  }
@@ -1,25 +1,3 @@
1
- // Tool doc for <rm>. 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
- ['## <rm path="[path]"/> - Remove a file or entry'],
6
- ['Example: <rm path="src/config.js"/>', "File removal. Simplest form."],
7
- [
8
- 'Example: <rm path="known://config/deprecated_service"/>',
9
- "Shows topic-hierarchy path convention.",
10
- ],
11
- [
12
- 'Example: <rm path="known://temp_*" preview/>',
13
- "Preview before deleting. Safety pattern for bulk operations.",
14
- ],
15
- [
16
- '* Permanent. Prefer <set fidelity="archive"/> to preserve for later retrieval',
17
- "Nudges toward archive over rm.",
18
- ],
19
- [
20
- "* Use `preview` to check matches before pattern-based bulk deletion",
21
- "Reinforces preview safety pattern.",
22
- ],
23
- ];
1
+ import { loadDoc } from "../helpers.js";
24
2
 
25
- export default LINES.map(([text]) => text).join("\n");
3
+ export default loadDoc(import.meta.url, "rmDoc.md");
@@ -0,0 +1,13 @@
1
+ ## <rm path="[path]"/> - Remove a file or entry
2
+
3
+ Example: <rm path="src/config.js"/>
4
+ <!-- File removal. Simplest form. -->
5
+
6
+ Example: <rm path="known://temp_*" preview/>
7
+ <!-- Preview before deleting. Safety pattern for bulk operations. -->
8
+
9
+ * Permanent. Prefer <set path="..." visibility="archived"/> to preserve for later retrieval
10
+ <!-- Nudges toward archive over rm. Path attr included so the model sees a complete invocation shape, not a fragment. -->
11
+
12
+ * `preview` shows what paths would be affected without performing the operation.
13
+ <!-- Canonical preview teaching lives here — rm is the most intuitive 'check before committing' case. Model generalizes to cp/mv/get by analogy. Advanced uses (e.g. archive rediscovery via <get preview>) belong in persona/skill docs, not here. -->
@@ -1,4 +1,4 @@
1
- # rpc
1
+ # rpc {#rpc_plugin}
2
2
 
3
3
  Registers core RPC methods and provides automatic tool dispatch for
4
4
  all registered tools.
@@ -29,4 +29,4 @@ all registered tools.
29
29
  - `getRuns`, `getRun`
30
30
 
31
31
  ### Notifications
32
- - `run/state`, `run/progress`, `ui/render`, `ui/notify`
32
+ - `run/state`, `run/progress`, `run/proposal`, `ui/render`, `ui/notify`, `stream/cancelled`