@possumtech/rummy 2.0.0 → 2.1.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 (117) hide show
  1. package/.env.example +31 -5
  2. package/BENCH_ENVIRONMENT.md +230 -0
  3. package/CLIENT_INTERFACE.md +396 -0
  4. package/PLUGINS.md +93 -1
  5. package/SPEC.md +389 -28
  6. package/bin/postinstall.js +2 -2
  7. package/bin/rummy.js +2 -2
  8. package/last_run.txt +5617 -0
  9. package/migrations/001_initial_schema.sql +2 -1
  10. package/package.json +13 -9
  11. package/scriptify/ask_run.js +77 -0
  12. package/scriptify/cache_probe.js +66 -0
  13. package/scriptify/cache_probe_grok.js +74 -0
  14. package/service.js +22 -11
  15. package/src/agent/AgentLoop.js +62 -157
  16. package/src/agent/ContextAssembler.js +2 -9
  17. package/src/agent/Entries.js +54 -98
  18. package/src/agent/ProjectAgent.js +4 -11
  19. package/src/agent/TurnExecutor.js +48 -83
  20. package/src/agent/XmlParser.js +247 -273
  21. package/src/agent/budget.js +5 -28
  22. package/src/agent/config.js +38 -0
  23. package/src/agent/errors.js +7 -13
  24. package/src/agent/httpStatus.js +1 -19
  25. package/src/agent/known_queries.sql +1 -1
  26. package/src/agent/known_store.sql +12 -2
  27. package/src/agent/materializeContext.js +15 -18
  28. package/src/agent/pathEncode.js +5 -0
  29. package/src/agent/rummyHome.js +9 -0
  30. package/src/agent/runs.sql +37 -0
  31. package/src/agent/tokens.js +7 -7
  32. package/src/hooks/HookRegistry.js +1 -16
  33. package/src/hooks/Hooks.js +8 -33
  34. package/src/hooks/PluginContext.js +3 -21
  35. package/src/hooks/RpcRegistry.js +1 -4
  36. package/src/hooks/RummyContext.js +6 -16
  37. package/src/hooks/ToolRegistry.js +5 -15
  38. package/src/llm/LlmProvider.js +41 -33
  39. package/src/llm/errors.js +41 -4
  40. package/src/llm/openaiStream.js +125 -0
  41. package/src/llm/retry.js +109 -0
  42. package/src/plugins/budget/budget.js +55 -76
  43. package/src/plugins/cli/README.md +87 -0
  44. package/src/plugins/cli/bin.js +61 -0
  45. package/src/plugins/cli/cli.js +120 -0
  46. package/src/plugins/env/README.md +2 -1
  47. package/src/plugins/env/env.js +4 -6
  48. package/src/plugins/env/envDoc.md +2 -2
  49. package/src/plugins/error/error.js +23 -23
  50. package/src/plugins/file/file.js +2 -22
  51. package/src/plugins/get/get.js +12 -34
  52. package/src/plugins/get/getDoc.md +8 -6
  53. package/src/plugins/hedberg/edits.js +1 -11
  54. package/src/plugins/hedberg/hedberg.js +3 -26
  55. package/src/plugins/hedberg/normalize.js +1 -5
  56. package/src/plugins/hedberg/patterns.js +4 -15
  57. package/src/plugins/hedberg/sed.js +1 -7
  58. package/src/plugins/helpers.js +28 -20
  59. package/src/plugins/index.js +25 -41
  60. package/src/plugins/instructions/README.md +18 -0
  61. package/src/plugins/instructions/instructions.js +97 -38
  62. package/src/plugins/instructions/instructions.md +24 -15
  63. package/src/plugins/instructions/instructions_104.md +5 -4
  64. package/src/plugins/instructions/instructions_105.md +29 -36
  65. package/src/plugins/instructions/instructions_106.md +22 -0
  66. package/src/plugins/instructions/instructions_107.md +17 -0
  67. package/src/plugins/instructions/instructions_108.md +0 -8
  68. package/src/plugins/known/README.md +26 -6
  69. package/src/plugins/known/known.js +37 -34
  70. package/src/plugins/log/README.md +2 -2
  71. package/src/plugins/log/log.js +27 -34
  72. package/src/plugins/ollama/ollama.js +50 -66
  73. package/src/plugins/openai/openai.js +26 -44
  74. package/src/plugins/openrouter/openrouter.js +28 -52
  75. package/src/plugins/policy/README.md +8 -2
  76. package/src/plugins/policy/policy.js +8 -21
  77. package/src/plugins/prompt/README.md +22 -0
  78. package/src/plugins/prompt/prompt.js +14 -16
  79. package/src/plugins/rm/rm.js +5 -2
  80. package/src/plugins/rm/rmDoc.md +4 -4
  81. package/src/plugins/rpc/README.md +2 -1
  82. package/src/plugins/rpc/rpc.js +62 -48
  83. package/src/plugins/set/README.md +5 -1
  84. package/src/plugins/set/set.js +23 -33
  85. package/src/plugins/set/setDoc.md +1 -1
  86. package/src/plugins/sh/README.md +2 -1
  87. package/src/plugins/sh/sh.js +5 -11
  88. package/src/plugins/sh/shDoc.md +2 -2
  89. package/src/plugins/stream/README.md +6 -5
  90. package/src/plugins/stream/stream.js +6 -35
  91. package/src/plugins/telemetry/telemetry.js +26 -19
  92. package/src/plugins/think/think.js +4 -7
  93. package/src/plugins/unknown/unknown.js +8 -13
  94. package/src/plugins/update/update.js +42 -25
  95. package/src/plugins/update/updateDoc.md +3 -3
  96. package/src/plugins/xai/xai.js +30 -20
  97. package/src/plugins/yolo/yolo.js +159 -0
  98. package/src/server/ClientConnection.js +17 -47
  99. package/src/server/SocketServer.js +14 -14
  100. package/src/server/protocol.js +1 -10
  101. package/src/sql/functions/slugify.js +5 -7
  102. package/src/sql/v_model_context.sql +4 -11
  103. package/turns/cli_1777462658211/turn_001.txt +772 -0
  104. package/turns/cli_1777462658211/turn_002.txt +606 -0
  105. package/turns/cli_1777462658211/turn_003.txt +667 -0
  106. package/turns/cli_1777462658211/turn_004.txt +297 -0
  107. package/turns/cli_1777462658211/turn_005.txt +301 -0
  108. package/turns/cli_1777462658211/turn_006.txt +262 -0
  109. package/turns/cli_1777465095132/turn_001.txt +715 -0
  110. package/turns/cli_1777465095132/turn_002.txt +236 -0
  111. package/turns/cli_1777465095132/turn_003.txt +287 -0
  112. package/turns/cli_1777465095132/turn_004.txt +694 -0
  113. package/turns/cli_1777465095132/turn_005.txt +422 -0
  114. package/turns/cli_1777465095132/turn_006.txt +365 -0
  115. package/turns/cli_1777465095132/turn_007.txt +885 -0
  116. package/turns/cli_1777465095132/turn_008.txt +1277 -0
  117. package/turns/cli_1777465095132/turn_009.txt +736 -0
@@ -1,16 +1,12 @@
1
+ import config from "../../agent/config.js";
1
2
  import msg from "../../agent/messages.js";
3
+ import { chatCompletionStream } from "../../llm/openaiStream.js";
2
4
 
3
- const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
- if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
5
+ const { FETCH_TIMEOUT } = config;
5
6
 
6
7
  const PROVIDER = "openai";
7
8
 
8
- /**
9
- * OpenAI-compatible LLM provider plugin. Registers with hooks.llm.providers
10
- * if OPENAI_BASE_URL is set in env; silently inert otherwise. Handles
11
- * model aliases of the form `openai/{modelName}` — the first path
12
- * segment picks the provider, the rest is whatever the API expects.
13
- */
9
+ // Inert unless OPENAI_BASE_URL is set; openai/{model} aliases.
14
10
  export default class OpenAi {
15
11
  #baseUrl;
16
12
  #apiKey;
@@ -42,47 +38,36 @@ export default class OpenAi {
42
38
  ? AbortSignal.any([options.signal, timeoutSignal])
43
39
  : timeoutSignal;
44
40
 
45
- const headers = { "Content-Type": "application/json" };
41
+ const headers = {};
46
42
  if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
47
43
 
48
- const response = await fetch(`${this.#baseUrl}/v1/chat/completions`, {
49
- method: "POST",
50
- headers,
51
- body: JSON.stringify(body),
52
- signal,
53
- });
54
-
55
- if (!response.ok) {
56
- const error = await response.text();
57
- throw new Error(
58
- msg("error.openai_api", { status: `${response.status} - ${error}` }),
59
- );
60
- }
61
-
62
- const data = await response.json();
63
-
64
- for (const choice of data.choices) {
65
- const m = choice.message;
66
- if (!m) continue;
67
- const parts = [m.reasoning_content, m.reasoning, m.thinking].filter(
68
- Boolean,
69
- );
70
- m.reasoning_content =
71
- parts.length > 0 ? [...new Set(parts)].join("\n") : null;
72
-
73
- // Full reasoning dump is centralized in telemetry.js on every
74
- // provider — keeping it out of provider plugins avoids double
75
- // printing and per-provider drift.
44
+ try {
45
+ return await chatCompletionStream({
46
+ url: `${this.#baseUrl}/v1/chat/completions`,
47
+ headers,
48
+ body,
49
+ signal,
50
+ });
51
+ } catch (err) {
52
+ if (err.status) {
53
+ const wrapped = new Error(
54
+ msg("error.openai_api", { status: `${err.status} - ${err.body}` }),
55
+ { cause: err },
56
+ );
57
+ wrapped.status = err.status;
58
+ wrapped.body = err.body;
59
+ wrapped.retryAfter = err.retryAfter;
60
+ throw wrapped;
61
+ }
62
+ throw err;
76
63
  }
77
-
78
- return data;
79
64
  }
80
65
 
81
66
  async #getContextSize(_model) {
82
67
  const headers = { "Content-Type": "application/json" };
83
68
  if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
84
69
 
85
- // Try /props first llama.cpp exposes runtime n_ctx here.
70
+ // llama.cpp /props returns runtime n_ctx; absent on vanilla OpenAI.
86
71
  try {
87
72
  const propsResponse = await fetch(`${this.#baseUrl}/props`, {
88
73
  headers,
@@ -93,10 +78,7 @@ export default class OpenAi {
93
78
  const runtimeCtx = props?.default_generation_settings?.n_ctx;
94
79
  if (runtimeCtx) return runtimeCtx;
95
80
  }
96
- } catch (_err) {
97
- // /props is a llama.cpp extension; absent on vanilla OpenAI.
98
- // Fall through to /v1/models for the training-context-size hint.
99
- }
81
+ } catch (_err) {}
100
82
 
101
83
  // Fall back to /v1/models for training context.
102
84
  const response = await fetch(`${this.#baseUrl}/v1/models`, {
@@ -1,20 +1,12 @@
1
+ import config from "../../agent/config.js";
1
2
  import msg from "../../agent/messages.js";
3
+ import { chatCompletionStream } from "../../llm/openaiStream.js";
2
4
 
3
- const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
- if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
5
+ const { FETCH_TIMEOUT } = config;
5
6
 
6
7
  const PROVIDER = "openrouter";
7
8
 
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
- */
9
+ // Inert unless OPENROUTER_API_KEY+OPENROUTER_BASE_URL set; openrouter/{publisher}/{model} aliases.
18
10
  export default class OpenRouter {
19
11
  #apiKey;
20
12
  #baseUrl;
@@ -48,52 +40,36 @@ export default class OpenRouter {
48
40
  ? AbortSignal.any([options.signal, timeoutSignal])
49
41
  : timeoutSignal;
50
42
 
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
- });
43
+ const headers = {
44
+ Authorization: `Bearer ${this.#apiKey}`,
45
+ "HTTP-Referer": process.env.RUMMY_HTTP_REFERER,
46
+ "X-Title": process.env.RUMMY_X_TITLE,
47
+ };
62
48
 
63
- if (!response.ok) {
64
- const error = await response.text();
65
- if (response.status === 401 || response.status === 403) {
49
+ try {
50
+ return await chatCompletionStream({
51
+ url: `${this.#baseUrl}/chat/completions`,
52
+ headers,
53
+ body,
54
+ signal,
55
+ });
56
+ } catch (err) {
57
+ if (err.status === 401 || err.status === 403) {
66
58
  throw new Error(
67
59
  msg("error.openrouter_auth", {
68
- status: `${response.status} - ${error}`,
60
+ status: `${err.status} - ${err.body}`,
69
61
  }),
70
62
  );
71
63
  }
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;
64
+ if (err.status) {
65
+ throw new Error(
66
+ msg("error.openrouter_api", {
67
+ status: `${err.status} - ${err.body}`,
68
+ }),
69
+ );
70
+ }
71
+ throw err;
94
72
  }
95
-
96
- return data;
97
73
  }
98
74
 
99
75
  async #getContextSize(model) {
@@ -101,7 +77,7 @@ export default class OpenRouter {
101
77
 
102
78
  const res = await fetch(`${this.#baseUrl}/models`, {
103
79
  headers: { Authorization: `Bearer ${this.#apiKey}` },
104
- signal: AbortSignal.timeout(5000),
80
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
105
81
  });
106
82
  if (!res.ok) {
107
83
  throw new Error(
@@ -6,8 +6,14 @@ was started in `ask` mode.
6
6
 
7
7
  ## Registration
8
8
 
9
- - **Filter**: `entry.recording` (priority 1) — runs before a command
10
- becomes an entry.
9
+ - **Filter**: `entry.recording` (priority 1) — the validation /
10
+ transform hook in TurnExecutor's RECORD phase. Runs after the
11
+ command is parsed but before the audit row is committed. Returning
12
+ an object with `state: "failed"` (or `"cancelled"`) short-circuits
13
+ recording and skips DISPATCH for that command. Plugins may also
14
+ return a transformed entry (modified body, attributes, path) for
15
+ the recorder to commit. Filter signature:
16
+ `(entry, { store, runId, turn, loopId, mode })`.
11
17
 
12
18
  ## Rejections (ask mode only)
13
19
 
@@ -1,39 +1,28 @@
1
1
  import Entries from "../../agent/Entries.js";
2
2
 
3
3
  export default class Policy {
4
- #core;
5
-
6
4
  constructor(core) {
7
- this.#core = core;
8
5
  core.filter("entry.recording", this.#enforceAskMode.bind(this), 1);
9
6
  }
10
7
 
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
- });
8
+ #fail(entry, body) {
9
+ return { ...entry, body, state: "failed", outcome: "permission" };
19
10
  }
20
11
 
21
12
  async #enforceAskMode(entry, ctx) {
22
13
  if (ctx.mode !== "ask") return entry;
23
14
 
24
15
  if (entry.scheme === "sh") {
25
- await this.#reject(ctx, "Rejected <sh> in ask mode");
26
- return { ...entry, state: "failed", outcome: "permission" };
16
+ return this.#fail(entry, "Rejected <sh> in ask mode");
27
17
  }
28
18
 
29
19
  if (entry.scheme === "set" && entry.attributes?.path) {
30
20
  const scheme = Entries.scheme(entry.attributes.path);
31
21
  if (scheme === null && entry.body) {
32
- await this.#reject(
33
- ctx,
22
+ return this.#fail(
23
+ entry,
34
24
  `Rejected file edit to ${entry.attributes.path} in ask mode`,
35
25
  );
36
- return { ...entry, state: "failed", outcome: "permission" };
37
26
  }
38
27
  }
39
28
 
@@ -41,19 +30,17 @@ export default class Policy {
41
30
  const pathAttr = entry.attributes?.path || entry.path;
42
31
  const scheme = Entries.scheme(pathAttr);
43
32
  if (scheme === null) {
44
- await this.#reject(ctx, `Rejected file rm of ${pathAttr} in ask mode`);
45
- return { ...entry, state: "failed", outcome: "permission" };
33
+ return this.#fail(entry, `Rejected file rm of ${pathAttr} in ask mode`);
46
34
  }
47
35
  }
48
36
 
49
37
  if (entry.scheme === "mv" || entry.scheme === "cp") {
50
38
  const destScheme = Entries.scheme(entry.attributes?.to);
51
39
  if (destScheme === null) {
52
- await this.#reject(
53
- ctx,
40
+ return this.#fail(
41
+ entry,
54
42
  `Rejected ${entry.scheme} to file ${entry.attributes?.to} in ask mode`,
55
43
  );
56
- return { ...entry, state: "failed", outcome: "permission" };
57
44
  }
58
45
  }
59
46
 
@@ -14,3 +14,25 @@ Finds the latest `prompt://` entry in the turn_context rows. The mode
14
14
  attribute (available tool list) and optional `warn` attribute in ask
15
15
  mode. Falls back to the mode passed by the core if no prompt entry
16
16
  exists.
17
+
18
+ ## Archived prompts: the singular exception to invisibility
19
+
20
+ `v_model_context.sql` filters archived entries out of the model's
21
+ context — every scheme **except `prompt`**. Archived `prompt://`
22
+ entries flow through with `effective_visibility = 'archived'` and
23
+ their body suppressed (per `projected.body`'s visibility CASE). The
24
+ plugin then renders the tag with full attributes (`path`,
25
+ `visibility="archived"`, etc.) but empty body.
26
+
27
+ The exception exists because the prompt is run identity: every other
28
+ archived entry is recoverable by pattern search if the model ever
29
+ needs it back, but the prompt is the question the run is answering.
30
+ A model that loses sight of its prompt cannot honestly act. Keeping
31
+ the archived prompt's path visible lets the model emit
32
+ `<get path="prompt://N"/>` to promote it back if it archived
33
+ prematurely (or step back to an earlier stage via
34
+ `<update status="174">`).
35
+
36
+ This is the only entry-type exception to the "archived = invisible"
37
+ contract. New schemes that warrant similar treatment should be added
38
+ explicitly here, not by accident.
@@ -1,3 +1,5 @@
1
+ const SUMMARIZED_PROMPT_CHAR_CAP = 500;
2
+
1
3
  export default class Prompt {
2
4
  #core;
3
5
 
@@ -7,22 +9,23 @@ export default class Prompt {
7
9
  core.hooks.tools.onView(
8
10
  "prompt",
9
11
  (entry) => {
10
- const limit = 500;
11
12
  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]`;
13
+ if (full.length <= SUMMARIZED_PROMPT_CHAR_CAP) return full;
14
+ return `${full.slice(0, SUMMARIZED_PROMPT_CHAR_CAP)}\n[truncated — promote to see the complete prompt]`;
14
15
  },
15
16
  "summarized",
16
17
  );
17
18
  core.on("turn.started", this.onTurnStarted.bind(this));
18
- core.filter("assembly.user", this.assemblePrompt.bind(this), 300);
19
+ core.filter("assembly.user", this.assemblePrompt.bind(this), 225);
19
20
  }
20
21
 
21
22
  async onTurnStarted({ rummy, mode, prompt, isContinuation }) {
22
23
  const { entries: store, sequence: turn, runId, loopId } = rummy;
23
24
 
24
25
  if (!isContinuation && prompt) {
25
- // prompt:// writable_by: ["plugin"] explicit for clarity.
26
+ // New prompt = new cycle; archive prior cycle's prompts/logs (knowns/unknowns persist).
27
+ await store.archivePriorPromptArtifacts(runId, turn);
28
+
26
29
  await store.set({
27
30
  runId,
28
31
  turn,
@@ -37,7 +40,7 @@ export default class Prompt {
37
40
  }
38
41
 
39
42
  async assemblePrompt(content, ctx) {
40
- const { rows, contextSize, toolSet } = ctx;
43
+ const { rows, toolSet } = ctx;
41
44
  const promptEntry = rows.findLast(
42
45
  (r) => r.category === "prompt" && r.scheme === "prompt",
43
46
  );
@@ -57,12 +60,7 @@ export default class Prompt {
57
60
  let warn = "";
58
61
  if (mode === "ask") warn = ' warn="File editing disallowed."';
59
62
 
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.
63
+ // reverted="N" surfaces last turn's 413 demotion count next to budget numbers.
66
64
  let reverted = "";
67
65
  const priorTurn = ctx.turn - 1;
68
66
  if (priorTurn >= 1) {
@@ -88,9 +86,9 @@ export default class Prompt {
88
86
  ? ` visibility="${promptEntry.visibility}"`
89
87
  : "";
90
88
  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>`;
89
+ promptEntry?.aTokens != null ? ` tokens="${promptEntry.aTokens}"` : "";
90
+ const lines =
91
+ promptEntry?.vLines != null ? ` lines="${promptEntry.vLines}"` : "";
92
+ return `${content}<prompt mode="${mode}"${path} commands="${commands}"${warn}${reverted}${visibility}${tokens}${lines}>${body}</prompt>`;
95
93
  }
96
94
  }
@@ -27,9 +27,12 @@ export default class Rm {
27
27
  await ctx.entries.rm({ runId: ctx.runId, path: target });
28
28
  if (ctx.projectRoot) {
29
29
  const { unlink } = await import("node:fs/promises");
30
- const { join } = await import("node:path");
30
+ const { isAbsolute, join } = await import("node:path");
31
+ const targetPath = isAbsolute(target)
32
+ ? target
33
+ : join(ctx.projectRoot, target);
31
34
  try {
32
- await unlink(join(ctx.projectRoot, target));
35
+ await unlink(targetPath);
33
36
  } catch (err) {
34
37
  // File may already be absent — entry rm'd regardless.
35
38
  if (err.code !== "ENOENT") throw err;
@@ -3,11 +3,11 @@
3
3
  Example: <rm path="src/config.js"/>
4
4
  <!-- File removal. Simplest form. -->
5
5
 
6
- Example: <rm path="known://temp_*" preview/>
7
- <!-- Preview before deleting. Safety pattern for bulk operations. -->
6
+ Example: <rm path="known://temp_*" manifest/>
7
+ <!-- Optional: Manifest before deleting. Safety pattern for bulk operations. -->
8
8
 
9
9
  * Permanent. Prefer <set path="..." visibility="archived"/> to preserve for later retrieval
10
10
  <!-- Nudges toward archive over rm. Path attr included so the model sees a complete invocation shape, not a fragment. -->
11
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. -->
12
+ * `manifest` lists what paths would be affected without performing the operation.
13
+ <!-- Canonical manifest 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 manifest>) belong in persona/skill docs, not here. -->
@@ -29,4 +29,5 @@ all registered tools.
29
29
  - `getRuns`, `getRun`
30
30
 
31
31
  ### Notifications
32
- - `run/state`, `run/progress`, `run/proposal`, `ui/render`, `ui/notify`, `stream/cancelled`
32
+ - `run/changed` — pulse: an entry under this run changed; client reconciles via `getEntries(run, { since })`.
33
+ - `ui/render`, `ui/notify`, `stream/cancelled`
@@ -24,10 +24,7 @@ export default class Rpc {
24
24
  description: "Returns { methods, notifications } catalog.",
25
25
  });
26
26
 
27
- // --- Primitives (SPEC primitives) ---
28
- // The client surface is a thin projection of the plugin API.
29
- // Six verbs, each takes an object of entry-grammar params.
30
- // Writer is fixed to "client"; permissions enforced per scheme.
27
+ // Primitives (SPEC #primitives); writer fixed to "client".
31
28
 
32
29
  r.register("set", {
33
30
  handler: async (params, ctx) => {
@@ -139,8 +136,9 @@ export default class Rpc {
139
136
  return { ok: true, path };
140
137
  },
141
138
  description:
142
- "Write an update:// entry carrying a turn's continuation/terminal " +
143
- "signal. Not general — this is the lifecycle verb.",
139
+ "Write a status update at log://turn_N/update/<slug> carrying a " +
140
+ "turn's continuation/terminal signal. Not general — this is the " +
141
+ "lifecycle verb.",
144
142
  params: {
145
143
  run: "string — run alias",
146
144
  body: "string — update text",
@@ -151,9 +149,7 @@ export default class Rpc {
151
149
  requiresInit: true,
152
150
  });
153
151
 
154
- // Connection handshake. First call a client makes. Establishes
155
- // the project identity for this connection and announces the
156
- // server's protocol version.
152
+ // Connection handshake; project identity + protocol version.
157
153
  r.register("rummy/hello", {
158
154
  handler: async (params, ctx) => {
159
155
  const { RUMMY_PROTOCOL_VERSION } = await import(
@@ -311,11 +307,18 @@ export default class Rpc {
311
307
  r.register("getEntries", {
312
308
  handler: async (params, ctx) => {
313
309
  const runRow = await this.#resolveRun(params.run, ctx);
314
- const { pattern = "*", bodyFilter = null } = params;
310
+ const {
311
+ pattern = "*",
312
+ bodyFilter = null,
313
+ since = null,
314
+ limit = null,
315
+ withBody = false,
316
+ } = params;
315
317
  const rows = await ctx.projectAgent.entries.getEntriesByPattern(
316
318
  runRow.id,
317
319
  pattern,
318
320
  bodyFilter,
321
+ { since, limit },
319
322
  );
320
323
  return rows
321
324
  .filter((e) => !params.scheme || e.scheme === params.scheme)
@@ -323,30 +326,43 @@ export default class Rpc {
323
326
  .filter(
324
327
  (e) => !params.visibility || e.visibility === params.visibility,
325
328
  )
326
- .map((e) => ({
327
- path: e.path,
328
- scheme: e.scheme,
329
- state: e.state,
330
- outcome: e.outcome,
331
- visibility: e.visibility,
332
- turn: e.turn,
333
- tokens: e.tokens,
334
- attributes:
335
- typeof e.attributes === "string"
336
- ? JSON.parse(e.attributes)
337
- : e.attributes,
338
- }));
329
+ .map((e) => {
330
+ const row = {
331
+ id: e.id,
332
+ path: e.path,
333
+ scheme: e.scheme,
334
+ state: e.state,
335
+ outcome: e.outcome,
336
+ visibility: e.visibility,
337
+ turn: e.turn,
338
+ tokens: e.tokens,
339
+ attributes:
340
+ typeof e.attributes === "string"
341
+ ? JSON.parse(e.attributes)
342
+ : e.attributes,
343
+ };
344
+ if (withBody) row.body = e.body;
345
+ return row;
346
+ });
339
347
  },
340
348
  description:
341
349
  "List entries matching a pattern. Read-only — no promotion. " +
342
- "Optional filters: scheme, state, visibility, bodyFilter.",
350
+ "Optional filters: scheme, state, visibility, bodyFilter. " +
351
+ "Pass `withBody: true` to include `body` on each row (omitted by default to keep pulse-reconcile traffic lean). " +
352
+ "For incremental sync after a `run/changed` pulse, pass `since` (last seen entry id); " +
353
+ "use `limit` to chunk catch-up.",
343
354
  params: {
344
355
  run: "string — run alias",
345
356
  pattern: "string? — glob pattern (default '*')",
346
357
  scheme: "string? — filter by scheme (e.g. 'file')",
347
358
  state: "string? — filter by state",
348
359
  visibility: "string? — filter by visibility",
349
- bodyFilter: "string? — narrow pattern matches by body content",
360
+ bodyFilter:
361
+ "string? — filter rows by content of body (substring/glob; NOT for body inclusion — see withBody)",
362
+ withBody:
363
+ "boolean? — include `body` field on each returned row (default false)",
364
+ since: "number? — only entries with id > since (insertion-ordered)",
365
+ limit: "number? — cap result count",
350
366
  },
351
367
  requiresInit: true,
352
368
  });
@@ -436,9 +452,10 @@ export default class Rpc {
436
452
 
437
453
  // --- Notifications ---
438
454
 
439
- r.registerNotification("run/state", "Turn state update.");
440
- r.registerNotification("run/progress", "Turn status.");
441
- r.registerNotification("run/proposal", "Proposal awaiting resolution.");
455
+ r.registerNotification(
456
+ "run/changed",
457
+ "Pulse: an entry under this run changed. Query with `getEntries(run, { pattern, since })` to reconcile.",
458
+ );
442
459
  r.registerNotification(
443
460
  "stream/cancelled",
444
461
  "Server-initiated stream cancellation.",
@@ -446,8 +463,7 @@ export default class Rpc {
446
463
  r.registerNotification("ui/render", "Streaming output.");
447
464
  r.registerNotification("ui/notify", "Toast notification.");
448
465
 
449
- // Auto-dispatch: any registered tool is callable via RPC.
450
- // Checked at request time — no timing dependency on plugin load order.
466
+ // Any registered tool is callable via RPC; resolved at request time.
451
467
  r.setToolFallback(hooks, buildRunContext, dispatchTool);
452
468
  }
453
469
 
@@ -464,18 +480,14 @@ export default class Rpc {
464
480
  async #dispatchSet(params, ctx) {
465
481
  if (!params.path) throw new Error("set: path is required");
466
482
 
467
- // run:// is the lifecycle surface. A set to a brand-new run://
468
- // alias starts a run loop; a state transition cancels or resolves.
483
+ // run:// = lifecycle surface (start run, cancel, resolve).
469
484
  if (params.path.startsWith("run://")) {
470
485
  return await this.#dispatchRunSet(params, ctx);
471
486
  }
472
487
 
473
488
  const runRow = await this.#resolveRun(params.run, ctx);
474
489
 
475
- // State transition on an existing proposed entryroute through
476
- // AgentLoop.resolve, which applies scheme-specific side effects
477
- // (patch application for set://, file removal for rm://, stream
478
- // setup for sh:// / env://, etc.).
490
+ // State transitions on proposed entriesAgentLoop.resolve for scheme-specific effects.
479
491
  if (params.state && !params.append && !params.pattern) {
480
492
  const current = await ctx.projectAgent.entries.getState(
481
493
  runRow.id,
@@ -521,10 +533,7 @@ export default class Rpc {
521
533
  async #dispatchRunSet(params, ctx) {
522
534
  let alias = params.path.slice("run://".length);
523
535
 
524
- // Empty alias on a new-run set synthesize ${model}_${epoch}.
525
- // Matches AgentLoop.#generateAlias so server- and client-initiated
526
- // runs share one naming scheme. Clients that want a specific name
527
- // pass it in the path; anonymous starts get the synthesized one.
536
+ // Empty alias → ${model}_${epoch}; mirrors AgentLoop.#generateAlias.
528
537
  if (!alias) {
529
538
  const { attributes: attrs = {} } = params;
530
539
  if (!attrs.model) {
@@ -576,12 +585,11 @@ export default class Rpc {
576
585
  noInteraction: attrs.noInteraction,
577
586
  noWeb: attrs.noWeb,
578
587
  noProposals: attrs.noProposals,
588
+ yolo: attrs.yolo,
579
589
  fork: attrs.fork,
580
590
  };
581
591
  const { body = "" } = params;
582
- // Fire-and-forget: client watches state via entry notifications.
583
- // ProjectAgent exposes .ask/.act wrappers over AgentLoop#run; route
584
- // by mode rather than calling the private loop directly.
592
+ // Fire-and-forget; client watches state via entry notifications.
585
593
  const kickoff =
586
594
  mode === "act"
587
595
  ? ctx.projectAgent.act(
@@ -604,10 +612,7 @@ export default class Rpc {
604
612
  return { ok: true, alias };
605
613
  }
606
614
 
607
- // Existing run + fork=true: create a child run synchronously so we
608
- // can return the child alias, then kick off the loop against it.
609
- // fork needs a brand-new run row with parent_run_id set; inject()
610
- // would just add another prompt to the parent.
615
+ // fork=true new child run with parent_run_id; inject() would only add a prompt to parent.
611
616
  const attrs = params.attributes ? params.attributes : {};
612
617
  if (attrs.fork === true) {
613
618
  const { mode } = attrs;
@@ -638,6 +643,7 @@ export default class Rpc {
638
643
  noInteraction: attrs.noInteraction,
639
644
  noWeb: attrs.noWeb,
640
645
  noProposals: attrs.noProposals,
646
+ yolo: attrs.yolo,
641
647
  // fork already applied — pass false to reuse the child row.
642
648
  fork: false,
643
649
  };
@@ -673,7 +679,15 @@ export default class Rpc {
673
679
  `set run://: attributes.mode is required on inject and must be "ask" or "act" (got ${JSON.stringify(mode)})`,
674
680
  );
675
681
  }
676
- await ctx.projectAgent.inject(alias, params.body, mode);
682
+ const options = {
683
+ temperature: attrs.temperature,
684
+ noRepo: attrs.noRepo,
685
+ noInteraction: attrs.noInteraction,
686
+ noWeb: attrs.noWeb,
687
+ noProposals: attrs.noProposals,
688
+ yolo: attrs.yolo,
689
+ };
690
+ await ctx.projectAgent.inject(alias, params.body, mode, options);
677
691
  return { ok: true, alias };
678
692
  }
679
693