@possumtech/rummy 0.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/.env.example +21 -4
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +850 -373
  5. package/bin/demo.js +166 -0
  6. package/bin/rummy.js +9 -3
  7. package/biome/no-fallbacks.grit +50 -0
  8. package/lang/en.json +2 -2
  9. package/migrations/001_initial_schema.sql +88 -37
  10. package/package.json +6 -4
  11. package/service.js +50 -9
  12. package/src/agent/AgentLoop.js +460 -331
  13. package/src/agent/ContextAssembler.js +4 -2
  14. package/src/agent/Entries.js +655 -0
  15. package/src/agent/ProjectAgent.js +30 -18
  16. package/src/agent/TurnExecutor.js +232 -379
  17. package/src/agent/XmlParser.js +242 -67
  18. package/src/agent/budget.js +56 -0
  19. package/src/agent/errors.js +22 -0
  20. package/src/agent/httpStatus.js +39 -0
  21. package/src/agent/known_checks.sql +8 -4
  22. package/src/agent/known_queries.sql +9 -13
  23. package/src/agent/known_store.sql +275 -118
  24. package/src/agent/materializeContext.js +102 -0
  25. package/src/agent/runs.sql +10 -7
  26. package/src/agent/schemes.sql +14 -3
  27. package/src/agent/turns.sql +9 -9
  28. package/src/hooks/HookRegistry.js +6 -5
  29. package/src/hooks/Hooks.js +44 -3
  30. package/src/hooks/PluginContext.js +35 -21
  31. package/src/{server → hooks}/RpcRegistry.js +2 -1
  32. package/src/hooks/RummyContext.js +140 -37
  33. package/src/hooks/ToolRegistry.js +36 -35
  34. package/src/llm/LlmProvider.js +64 -90
  35. package/src/llm/errors.js +21 -0
  36. package/src/plugins/ask_user/README.md +1 -1
  37. package/src/plugins/ask_user/ask_user.js +37 -12
  38. package/src/plugins/ask_user/ask_userDoc.js +2 -23
  39. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  40. package/src/plugins/budget/README.md +27 -23
  41. package/src/plugins/budget/budget.js +261 -69
  42. package/src/plugins/cp/README.md +2 -2
  43. package/src/plugins/cp/cp.js +31 -13
  44. package/src/plugins/cp/cpDoc.js +2 -23
  45. package/src/plugins/cp/cpDoc.md +7 -0
  46. package/src/plugins/engine/README.md +2 -2
  47. package/src/plugins/engine/engine.sql +4 -4
  48. package/src/plugins/engine/turn_context.sql +10 -10
  49. package/src/plugins/env/README.md +20 -5
  50. package/src/plugins/env/env.js +47 -8
  51. package/src/plugins/env/envDoc.js +2 -23
  52. package/src/plugins/env/envDoc.md +13 -0
  53. package/src/plugins/error/README.md +16 -0
  54. package/src/plugins/error/error.js +151 -0
  55. package/src/plugins/file/README.md +6 -6
  56. package/src/plugins/file/file.js +15 -7
  57. package/src/plugins/get/README.md +1 -1
  58. package/src/plugins/get/get.js +125 -49
  59. package/src/plugins/get/getDoc.js +2 -43
  60. package/src/plugins/get/getDoc.md +36 -0
  61. package/src/plugins/hedberg/README.md +1 -2
  62. package/src/plugins/hedberg/hedberg.js +8 -4
  63. package/src/plugins/hedberg/matcher.js +16 -17
  64. package/src/plugins/hedberg/normalize.js +0 -48
  65. package/src/plugins/helpers.js +43 -3
  66. package/src/plugins/index.js +146 -123
  67. package/src/plugins/instructions/README.md +35 -9
  68. package/src/plugins/instructions/instructions.js +126 -12
  69. package/src/plugins/instructions/instructions.md +25 -0
  70. package/src/plugins/instructions/instructions_104.md +7 -0
  71. package/src/plugins/instructions/instructions_105.md +46 -0
  72. package/src/plugins/instructions/instructions_106.md +0 -0
  73. package/src/plugins/instructions/instructions_107.md +0 -0
  74. package/src/plugins/instructions/instructions_108.md +8 -0
  75. package/src/plugins/instructions/protocol.js +12 -0
  76. package/src/plugins/known/README.md +2 -2
  77. package/src/plugins/known/known.js +77 -45
  78. package/src/plugins/known/knownDoc.js +2 -29
  79. package/src/plugins/known/knownDoc.md +8 -0
  80. package/src/plugins/log/README.md +48 -0
  81. package/src/plugins/log/log.js +109 -0
  82. package/src/plugins/mv/README.md +2 -2
  83. package/src/plugins/mv/mv.js +57 -24
  84. package/src/plugins/mv/mvDoc.js +2 -29
  85. package/src/plugins/mv/mvDoc.md +10 -0
  86. package/src/plugins/ollama/README.md +15 -0
  87. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  88. package/src/plugins/openai/README.md +17 -0
  89. package/src/plugins/openai/openai.js +120 -0
  90. package/src/plugins/openrouter/README.md +27 -0
  91. package/src/plugins/openrouter/openrouter.js +121 -0
  92. package/src/plugins/persona/README.md +20 -0
  93. package/src/plugins/persona/persona.js +9 -16
  94. package/src/plugins/policy/README.md +21 -0
  95. package/src/plugins/policy/policy.js +29 -14
  96. package/src/plugins/prompt/README.md +1 -1
  97. package/src/plugins/prompt/prompt.js +63 -18
  98. package/src/plugins/rm/README.md +1 -1
  99. package/src/plugins/rm/rm.js +58 -14
  100. package/src/plugins/rm/rmDoc.js +2 -24
  101. package/src/plugins/rm/rmDoc.md +13 -0
  102. package/src/plugins/rpc/README.md +2 -2
  103. package/src/plugins/rpc/rpc.js +515 -296
  104. package/src/plugins/set/README.md +1 -1
  105. package/src/plugins/set/set.js +318 -77
  106. package/src/plugins/set/setDoc.js +2 -35
  107. package/src/plugins/set/setDoc.md +22 -0
  108. package/src/plugins/sh/README.md +28 -5
  109. package/src/plugins/sh/sh.js +52 -8
  110. package/src/plugins/sh/shDoc.js +2 -23
  111. package/src/plugins/sh/shDoc.md +13 -0
  112. package/src/plugins/skill/README.md +23 -0
  113. package/src/plugins/skill/skill.js +14 -17
  114. package/src/plugins/stream/README.md +101 -0
  115. package/src/plugins/stream/stream.js +290 -0
  116. package/src/plugins/telemetry/README.md +1 -1
  117. package/src/plugins/telemetry/telemetry.js +148 -74
  118. package/src/plugins/think/README.md +1 -1
  119. package/src/plugins/think/think.js +14 -1
  120. package/src/plugins/think/thinkDoc.js +2 -17
  121. package/src/plugins/think/thinkDoc.md +7 -0
  122. package/src/plugins/unknown/README.md +3 -3
  123. package/src/plugins/unknown/unknown.js +56 -21
  124. package/src/plugins/unknown/unknownDoc.js +2 -25
  125. package/src/plugins/unknown/unknownDoc.md +11 -0
  126. package/src/plugins/update/README.md +1 -1
  127. package/src/plugins/update/update.js +67 -5
  128. package/src/plugins/update/updateDoc.js +2 -27
  129. package/src/plugins/update/updateDoc.md +8 -0
  130. package/src/plugins/xai/README.md +23 -0
  131. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  132. package/src/server/ClientConnection.js +64 -37
  133. package/src/server/SocketServer.js +23 -10
  134. package/src/server/protocol.js +11 -0
  135. package/src/sql/functions/slugify.js +13 -1
  136. package/src/sql/v_model_context.sql +27 -31
  137. package/src/sql/v_run_log.sql +9 -14
  138. package/EXCEPTIONS.md +0 -46
  139. package/src/agent/KnownStore.js +0 -338
  140. package/src/agent/ResponseHealer.js +0 -188
  141. package/src/llm/OpenAiClient.js +0 -100
  142. package/src/llm/OpenRouterClient.js +0 -100
  143. package/src/plugins/budget/recovery.js +0 -47
  144. package/src/plugins/instructions/preamble.md +0 -37
  145. package/src/plugins/performed/README.md +0 -15
  146. package/src/plugins/performed/performed.js +0 -45
  147. package/src/plugins/previous/README.md +0 -16
  148. package/src/plugins/previous/previous.js +0 -60
  149. package/src/plugins/progress/README.md +0 -16
  150. package/src/plugins/progress/progress.js +0 -26
  151. package/src/plugins/summarize/README.md +0 -19
  152. package/src/plugins/summarize/summarize.js +0 -32
  153. package/src/plugins/summarize/summarizeDoc.js +0 -28
@@ -1,4 +1,4 @@
1
- # hedberg
1
+ # hedberg {#hedberg_plugin}
2
2
 
3
3
  The interpretation boundary between stochastic model output and
4
4
  deterministic system operations.
@@ -26,7 +26,6 @@ constructor(core) {
26
26
  | `replace(body, search, replacement, opts?)` | Apply replacement (sed regex → literal → heuristic) |
27
27
  | `parseSed(input)` | Parse sed syntax into `[{ search, replace, flags, sed }]` |
28
28
  | `parseEdits(content)` | Detect edit format (merge conflict, udiff, Claude XML) |
29
- | `normalizeAttrs(attrs)` | Heal model attribute names (value→body, unknown→path) |
30
29
  | `generatePatch(path, old, new)` | Generate unified diff |
31
30
 
32
31
  ### Hedberg.replace(body, search, replacement, options?)
@@ -1,6 +1,6 @@
1
1
  import { parseEditContent } from "./edits.js";
2
2
  import HeuristicMatcher, { generatePatch } from "./matcher.js";
3
- import { normalizeAttrs, parseJsonEdit } from "./normalize.js";
3
+ import { parseJsonEdit } from "./normalize.js";
4
4
  import { hedmatch, hedsearch } from "./patterns.js";
5
5
  import { parseSed } from "./sed.js";
6
6
 
@@ -14,7 +14,6 @@ import { parseSed } from "./sed.js";
14
14
  * core.hedberg.replace(body, search, replacement, options?)
15
15
  * core.hedberg.parseSed(input)
16
16
  * core.hedberg.parseEdits(content)
17
- * core.hedberg.normalizeAttrs(attrs)
18
17
  * core.hedberg.generatePatch(path, old, new)
19
18
  */
20
19
  export default class Hedberg {
@@ -30,7 +29,6 @@ export default class Hedberg {
30
29
  parseSed,
31
30
  parseEdits: parseEditContent,
32
31
  parseJsonEdit,
33
- normalizeAttrs,
34
32
  generatePatch,
35
33
  };
36
34
 
@@ -57,7 +55,13 @@ export default class Hedberg {
57
55
  searchText,
58
56
  flags.includes("g") ? flags : `${flags}g`,
59
57
  );
60
- patch = body.replace(re, replaceText);
58
+ // Unescape regex metacharacter escapes in the replacement string.
59
+ // The model writes `\[x\]` meaning literal `[x]` in both search
60
+ // and replace. RegExp handles this in search; in the replacement
61
+ // string we must strip the backslashes ourselves since
62
+ // String.replace only interprets `$` sequences, not `\`.
63
+ const unescaped = replaceText.replace(/\\([[\](){}.*+?^$|\\])/g, "$1");
64
+ patch = body.replace(re, unescaped);
61
65
  if (patch === body) patch = null;
62
66
  } catch {
63
67
  // Invalid regex — fall through to literal/heuristic interpretation
@@ -1,9 +1,9 @@
1
1
  import { createTwoFilesPatch } from "diff";
2
2
 
3
- export function generatePatch(filePath, oldContent, newContent) {
3
+ export function generatePatch(entryPath, oldContent, newContent) {
4
4
  return createTwoFilesPatch(
5
- `${filePath}\told`,
6
- `${filePath}\tnew`,
5
+ `${entryPath}\told`,
6
+ `${entryPath}\tnew`,
7
7
  oldContent,
8
8
  newContent,
9
9
  "",
@@ -13,37 +13,36 @@ export function generatePatch(filePath, oldContent, newContent) {
13
13
  }
14
14
 
15
15
  export default class HeuristicMatcher {
16
- static matchAndPatch(filePath, fileContent, searchBlock, replaceBlock) {
16
+ static matchAndPatch(entryPath, entryBody, searchBlock, replaceBlock) {
17
17
  // Unescape common regex escapes (models often escape brackets, parens, etc.)
18
18
  const unescaped = searchBlock.replace(/\\([[\](){}.*+?^$|\\])/g, "$1");
19
- if (unescaped !== searchBlock && fileContent.includes(unescaped)) {
19
+ if (unescaped !== searchBlock && entryBody.includes(unescaped)) {
20
20
  searchBlock = unescaped;
21
21
  }
22
22
 
23
23
  const searchLines = searchBlock.split(/\r?\n/);
24
- const fileLines = fileContent.split(/\r?\n/);
24
+ const fileLines = entryBody.split(/\r?\n/);
25
25
 
26
26
  // 1. Exact Match Attempt (line-boundary substring search)
27
- let exactIdx = fileContent.indexOf(searchBlock);
27
+ let exactIdx = entryBody.indexOf(searchBlock);
28
28
  let lastExactIdx = -1;
29
29
  let exactCount = 0;
30
30
  while (exactIdx !== -1) {
31
- const atLineBoundary =
32
- exactIdx === 0 || fileContent[exactIdx - 1] === "\n";
31
+ const atLineBoundary = exactIdx === 0 || entryBody[exactIdx - 1] === "\n";
33
32
  if (atLineBoundary) {
34
33
  exactCount++;
35
34
  lastExactIdx = exactIdx;
36
35
  }
37
- exactIdx = fileContent.indexOf(searchBlock, exactIdx + 1);
36
+ exactIdx = entryBody.indexOf(searchBlock, exactIdx + 1);
38
37
  }
39
38
 
40
39
  if (exactCount > 0) {
41
40
  const useIdx = lastExactIdx;
42
41
  const newContent =
43
- fileContent.slice(0, useIdx) +
42
+ entryBody.slice(0, useIdx) +
44
43
  replaceBlock +
45
- fileContent.slice(useIdx + searchBlock.length);
46
- const patch = generatePatch(filePath, fileContent, newContent);
44
+ entryBody.slice(useIdx + searchBlock.length);
45
+ const patch = generatePatch(entryPath, entryBody, newContent);
47
46
  const warning =
48
47
  exactCount > 1
49
48
  ? `SEARCH block matched ${exactCount} locations. Edit was applied to the last occurrence. Use more surrounding context in future edits to avoid ambiguity.`
@@ -59,9 +58,9 @@ export default class HeuristicMatcher {
59
58
 
60
59
  if (searchTokens.length === 0) {
61
60
  // Empty SEARCH = append REPLACE to end of file
62
- const trailing = fileContent.endsWith("\n") ? "" : "\n";
63
- const newContent = `${fileContent + trailing + replaceBlock}\n`;
64
- const patch = generatePatch(filePath, fileContent, newContent);
61
+ const trailing = entryBody.endsWith("\n") ? "" : "\n";
62
+ const newContent = `${entryBody + trailing + replaceBlock}\n`;
63
+ const patch = generatePatch(entryPath, entryBody, newContent);
65
64
  return { patch, newContent, warning: null, error: null };
66
65
  }
67
66
 
@@ -149,7 +148,7 @@ export default class HeuristicMatcher {
149
148
  ];
150
149
  const newContent = newFileLines.join("\n");
151
150
 
152
- const patch = generatePatch(filePath, fileContent, newContent);
151
+ const patch = generatePatch(entryPath, entryBody, newContent);
153
152
 
154
153
  if (fuzzyAmbiguous) {
155
154
  warning =
@@ -1,51 +1,3 @@
1
- /**
2
- * Attribute normalization. Heals legacy and alternative attribute names
3
- * from model output into canonical form.
4
- *
5
- * - value="" → body=""
6
- * - file="" or key="" → path="" (first unrecognized attr becomes path)
7
- * - preview="" → preview=true
8
- */
9
-
10
- const KNOWN_ATTRS = new Set([
11
- "path",
12
- "body",
13
- "preview",
14
- "question",
15
- "options",
16
- "search",
17
- "replace",
18
- "to",
19
- "results",
20
- "command",
21
- "warn",
22
- "summary",
23
- "fidelity",
24
- ]);
25
-
26
- export function normalizeAttrs(attrs) {
27
- const out = { ...attrs };
28
- if ("value" in out && !("body" in out)) {
29
- out.body = out.value;
30
- delete out.value;
31
- }
32
- if (!out.path) {
33
- for (const [k, v] of Object.entries(out)) {
34
- if (!KNOWN_ATTRS.has(k) && v) {
35
- out.path = v;
36
- delete out[k];
37
- break;
38
- }
39
- }
40
- }
41
- if ("preview" in out) out.preview = true;
42
- // summary="..." is the description text, not a fidelity flag
43
- if ("summary" in out && !out.summary) out.summary = true;
44
- // file:// prefix — strip silently, bare paths are the convention
45
- if (out.path?.startsWith("file://")) out.path = out.path.slice(7);
46
- return out;
47
- }
48
-
49
1
  /**
50
2
  * Parse JSON-style edit from body content.
51
3
  * Accepts: {"search":"old","replace":"new"} and {search="old",replace="new"}
@@ -1,3 +1,35 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * Read a sibling tooldoc markdown file and return its model-facing text.
7
+ * Strips HTML comments (rationale stays in source, never reaches the model)
8
+ * and collapses any blank-line runs left behind. Each plugin's Doc.js is a
9
+ * one-liner that defers to this so authors edit normal markdown instead of
10
+ * a JS array of [text, rationale] pairs.
11
+ */
12
+ export function loadDoc(metaUrl, name) {
13
+ const dir = dirname(fileURLToPath(metaUrl));
14
+ return readFileSync(join(dir, name), "utf8")
15
+ .replace(/<!--[\s\S]*?-->/g, "")
16
+ .replace(/\n{3,}/g, "\n\n")
17
+ .trim();
18
+ }
19
+
20
+ /**
21
+ * Translate a log entry path into its companion data-scheme base path.
22
+ * `log://turn_N/{action}/{rest}` → `{action}://turn_N/{rest}`.
23
+ * Streaming producers (sh, env) create data channel entries under the
24
+ * producer scheme while the audit record lives in the log scheme; this
25
+ * helper bridges the two namespaces. Returns null for non-log paths.
26
+ */
27
+ export function logPathToDataBase(logPath) {
28
+ const m = logPath?.match(/^log:\/\/turn_(\d+)\/([^/]+)\/(.+)$/);
29
+ if (!m) return null;
30
+ return `${m[2]}://turn_${m[1]}/${m[3]}`;
31
+ }
32
+
1
33
  /**
2
34
  * Shared helper for pattern-based tool results.
3
35
  * Used by get, set, store, and rm tools.
@@ -10,13 +42,21 @@ export async function storePatternResult(
10
42
  path,
11
43
  bodyFilter,
12
44
  matches,
13
- { preview = false, loopId = null } = {},
45
+ { preview = false, loopId = null, attributes = null } = {},
14
46
  ) {
15
- const slug = await store.slugPath(runId, scheme, path);
47
+ const logSlug = await store.logPath(runId, turn, scheme, path);
16
48
  const filter = bodyFilter ? ` body="${bodyFilter}"` : "";
17
49
  const total = matches.reduce((s, m) => s + m.tokens, 0);
18
50
  const listing = matches.map((m) => `${m.path} (${m.tokens})`).join("\n");
19
51
  const prefix = preview ? "PREVIEW " : "";
20
52
  const body = `${prefix}${scheme} path="${path}"${filter}: ${matches.length} matched (${total} tokens)\n${listing}`;
21
- await store.upsert(runId, turn, slug, body, 200, { loopId });
53
+ await store.set({
54
+ runId,
55
+ turn,
56
+ path: logSlug,
57
+ body,
58
+ state: "resolved",
59
+ loopId,
60
+ attributes,
61
+ });
22
62
  }
@@ -11,20 +11,70 @@ function getGlobalPrefix() {
11
11
  return globalPrefix;
12
12
  }
13
13
 
14
- const instances = new Map();
15
-
16
14
  /**
17
- * Dynamically loads and registers plugins from provided directories
18
- * and RUMMY_PLUGIN_* env vars.
15
+ * Plugin loader:
16
+ * 1. Walk filesystem + env vars to collect plugin descriptors.
17
+ * 2. Import each and instantiate with a fresh PluginContext.
18
+ *
19
+ * Returns a Map of name → PluginContext for the caller to pass to
20
+ * initPlugins. No module-global state — each caller owns its set.
21
+ *
22
+ * Plugin constructors must be declarative (SPEC surfaces): they
23
+ * register schemes, hooks, filters, RPC methods — but don't dereference
24
+ * infrastructure that might not be ready yet. Because the plugin
25
+ * contract makes constructors side-effect-free on each other, load
26
+ * order doesn't matter and there is no dependency system.
19
27
  */
20
28
  export async function registerPlugins(dirs = [], hooks) {
21
29
  const uniqueDirs = [...new Set(dirs.map((d) => join(d)))];
22
30
 
31
+ const descriptors = [];
23
32
  for (const dir of uniqueDirs) {
24
- await scanDir(dir, hooks, true);
33
+ await collectFromDir(dir, true, descriptors);
34
+ }
35
+ await collectFromEnv(descriptors);
36
+
37
+ const resolved = [];
38
+ for (const d of descriptors) {
39
+ try {
40
+ const module = await withTimeout(
41
+ import(d.url),
42
+ PLUGIN_LOAD_TIMEOUT,
43
+ `Plugin import timed out: ${d.source}`,
44
+ );
45
+ resolved.push({ ...d, Plugin: module.default });
46
+ } catch (err) {
47
+ console.warn(`[RUMMY] Plugin import failed: ${d.name} — ${err.message}`);
48
+ }
25
49
  }
26
50
 
27
- await loadEnvPlugins(hooks);
51
+ const instances = new Map();
52
+ for (const r of resolved) {
53
+ try {
54
+ await instantiatePlugin(r, hooks, instances);
55
+ } catch (err) {
56
+ console.warn(`[RUMMY] Plugin load failed: ${r.name} — ${err.message}`);
57
+ }
58
+ }
59
+ return instances;
60
+ }
61
+
62
+ async function instantiatePlugin({ name, Plugin, source }, hooks, instances) {
63
+ if (typeof Plugin?.register === "function") {
64
+ await withTimeout(
65
+ Plugin.register(hooks),
66
+ PLUGIN_LOAD_TIMEOUT,
67
+ `Plugin register timed out: ${source}`,
68
+ );
69
+ return;
70
+ }
71
+ if (typeof Plugin !== "function") return;
72
+ const ctx = new PluginContext(name, hooks);
73
+ new Plugin(ctx);
74
+ instances.set(name, ctx);
75
+ if (source.startsWith("env:")) {
76
+ console.log(`[RUMMY] Plugin ${name}: ${source.slice(4)}`);
77
+ }
28
78
  }
29
79
 
30
80
  const AUDIT_SCHEMES = [
@@ -32,62 +82,98 @@ const AUDIT_SCHEMES = [
32
82
  "system",
33
83
  "reasoning",
34
84
  "model",
35
- "error",
36
85
  "user",
37
86
  "assistant",
38
87
  "content",
39
88
  ];
40
89
 
41
- const PROMPT_SCHEMES = ["prompt", "progress"];
90
+ const PROMPT_SCHEMES = ["prompt"];
91
+
92
+ // Lifecycle schemes: client-addressable entries that reflect server
93
+ // state. Writable by system (internal bookkeeping), plugin (extensions),
94
+ // and client (RPC in Phase 4).
95
+ const LIFECYCLE_SCHEMES = ["run"];
96
+
97
+ // Unified log namespace for action history entries under
98
+ // log://turn_N/scheme/slug.
99
+ const LOG_SCHEMES = ["log"];
42
100
 
43
101
  /**
44
- * After DB is ready, inject db and store into all PluginContext instances,
45
- * upsert declared schemes, and bootstrap audit schemes.
102
+ * After DB is ready, upsert declared schemes and bootstrap audit/prompt
103
+ * schemes. Takes the plugin collection returned by registerPlugins.
104
+ * Per-plugin store/db access is provided per-turn via RummyContext;
105
+ * PluginContext itself holds only name + hooks.
46
106
  */
47
- export async function initPlugins(db, store, hooks) {
107
+ export async function initPlugins(db, hooks, instances) {
48
108
  for (const name of AUDIT_SCHEMES) {
109
+ // Audit schemes are written only by system-level code (reasoning,
110
+ // user/assistant/model messages, etc.). Closing the door on model
111
+ // writes and plugin writes here.
49
112
  await db.upsert_scheme.run({
50
113
  name,
51
114
  model_visible: 0,
52
115
  category: "audit",
116
+ default_scope: "run",
117
+ writable_by: JSON.stringify(["system"]),
53
118
  });
54
119
  }
55
120
  for (const name of PROMPT_SCHEMES) {
121
+ // Prompt entries are created by the prompt plugin on user input;
122
+ // model doesn't emit <set path="prompt://...">.
56
123
  await db.upsert_scheme.run({
57
124
  name,
58
125
  model_visible: 1,
59
126
  category: "prompt",
127
+ default_scope: "run",
128
+ writable_by: JSON.stringify(["plugin"]),
129
+ });
130
+ }
131
+ for (const name of LOG_SCHEMES) {
132
+ await db.upsert_scheme.run({
133
+ name,
134
+ model_visible: 1,
135
+ category: "logging",
136
+ default_scope: "run",
137
+ writable_by: JSON.stringify(["system", "plugin", "model"]),
138
+ });
139
+ }
140
+ for (const name of LIFECYCLE_SCHEMES) {
141
+ // Lifecycle entries are client-addressable mirrors of server state.
142
+ // Not model-visible. System writes internally; plugins and clients
143
+ // write via the 6 primitives.
144
+ await db.upsert_scheme.run({
145
+ name,
146
+ model_visible: 0,
147
+ category: "logging",
148
+ default_scope: "run",
149
+ writable_by: JSON.stringify(["system", "plugin", "client"]),
60
150
  });
61
151
  }
62
152
 
63
153
  for (const ctx of instances.values()) {
64
- ctx.db = db;
65
- ctx.entries = store;
66
154
  for (const scheme of ctx.schemes) {
67
155
  await db.upsert_scheme.run(scheme);
68
156
  }
69
157
  }
70
158
 
71
159
  // Register default schemes for tools that plugins ensured but didn't registerScheme for
72
- if (hooks) {
73
- const registered = new Set();
74
- for (const ctx of instances.values()) {
75
- for (const s of ctx.schemes) registered.add(s.name);
76
- }
77
- for (const name of AUDIT_SCHEMES) registered.add(name);
78
- for (const name of PROMPT_SCHEMES) registered.add(name);
79
-
80
- for (const toolName of hooks.tools.names) {
81
- if (registered.has(toolName)) continue;
82
- await db.upsert_scheme.run({
83
- name: toolName,
84
- model_visible: 1,
85
- category: "logging",
86
- });
87
- }
160
+ const registered = new Set();
161
+ for (const ctx of instances.values()) {
162
+ for (const s of ctx.schemes) registered.add(s.name);
88
163
  }
164
+ for (const name of AUDIT_SCHEMES) registered.add(name);
165
+ for (const name of PROMPT_SCHEMES) registered.add(name);
89
166
 
90
- if (store) store.loadSchemes(db);
167
+ for (const toolName of hooks.tools.names) {
168
+ if (registered.has(toolName)) continue;
169
+ await db.upsert_scheme.run({
170
+ name: toolName,
171
+ model_visible: 1,
172
+ category: "logging",
173
+ default_scope: "run",
174
+ writable_by: JSON.stringify(["model", "plugin"]),
175
+ });
176
+ }
91
177
  }
92
178
 
93
179
  function resolvePlugin(packageName) {
@@ -99,7 +185,7 @@ function resolvePlugin(packageName) {
99
185
  throw new Error(`Package '${packageName}' not found locally or globally`);
100
186
  }
101
187
 
102
- async function importPlugin(packageName) {
188
+ async function _importPlugin(packageName) {
103
189
  const dir = resolvePlugin(packageName);
104
190
  const pkg = JSON.parse(
105
191
  (await import("node:fs")).readFileSync(join(dir, "package.json"), "utf8"),
@@ -108,133 +194,70 @@ async function importPlugin(packageName) {
108
194
  return import(pathToFileURL(join(dir, entry)).href);
109
195
  }
110
196
 
111
- async function loadEnvPlugins(hooks) {
197
+ async function collectFromEnv(descriptors) {
112
198
  for (const [key, value] of Object.entries(process.env)) {
113
199
  if (!key.startsWith("RUMMY_PLUGIN_") || !value) continue;
114
200
  const name = key.replace("RUMMY_PLUGIN_", "").toLowerCase();
115
201
  try {
116
- const importPromise = isAbsolute(value)
117
- ? importAbsolute(value)
118
- : importPlugin(value);
119
- const { default: Plugin } = await withTimeout(
120
- importPromise,
121
- PLUGIN_LOAD_TIMEOUT,
122
- `Plugin import timed out: ${value}`,
123
- );
124
- if (typeof Plugin?.register === "function") {
125
- await withTimeout(
126
- Plugin.register(hooks),
127
- PLUGIN_LOAD_TIMEOUT,
128
- `Plugin register timed out: ${value}`,
129
- );
130
- } else if (typeof Plugin === "function") {
131
- const ctx = new PluginContext(name, hooks);
132
- new Plugin(ctx);
133
- instances.set(name, ctx);
134
- }
135
- console.log(`[RUMMY] Plugin ${name}: ${value}`);
202
+ const url = isAbsolute(value)
203
+ ? await resolveAbsoluteUrl(value)
204
+ : await resolvePackageUrl(value);
205
+ descriptors.push({ name, url, source: `env:${value}` });
136
206
  } catch (err) {
137
207
  console.warn(`[RUMMY] Plugin ${name} (${value}): ${err.message}`);
138
208
  }
139
209
  }
140
210
  }
141
211
 
142
- async function importAbsolute(dir) {
212
+ async function resolvePackageUrl(packageName) {
213
+ const dir = resolvePlugin(packageName);
214
+ const pkg = JSON.parse(
215
+ (await import("node:fs")).readFileSync(join(dir, "package.json"), "utf8"),
216
+ );
217
+ const entry = pkg.exports?.["."] || pkg.main || "index.js";
218
+ return pathToFileURL(join(dir, entry)).href;
219
+ }
220
+
221
+ async function resolveAbsoluteUrl(dir) {
143
222
  const pkgPath = join(dir, "package.json");
144
223
  if (!existsSync(pkgPath)) {
145
- // Bare .js file
146
- return import(pathToFileURL(dir).href);
224
+ return pathToFileURL(dir).href;
147
225
  }
148
226
  const pkg = JSON.parse(
149
227
  (await import("node:fs")).readFileSync(pkgPath, "utf8"),
150
228
  );
151
229
  const entry = pkg.exports?.["."] || pkg.main || "index.js";
152
- return import(pathToFileURL(join(dir, entry)).href);
230
+ return pathToFileURL(join(dir, entry)).href;
153
231
  }
154
232
 
155
- async function scanDir(dir, hooks, isRoot = false) {
233
+ async function collectFromDir(dir, isRoot, descriptors) {
156
234
  if (!existsSync(dir)) return;
235
+ if (!(await stat(dir)).isDirectory()) return;
157
236
 
158
- let dirStats;
159
- try {
160
- dirStats = await stat(dir);
161
- } catch (_err) {
162
- return;
163
- }
164
-
165
- if (!dirStats.isDirectory()) {
166
- if (process.env.RUMMY_DEBUG === "true") {
167
- console.error(
168
- `[RUMMY] Cannot scan plugin directory (not a directory): ${dir}`,
169
- );
170
- }
171
- return;
172
- }
173
-
174
- let entries;
175
- try {
176
- entries = await readdir(dir);
177
- } catch (err) {
178
- if (process.env.RUMMY_DEBUG === "true") {
179
- console.error(`[RUMMY] Failed to read directory ${dir}:`, err.message);
180
- }
181
- return;
182
- }
183
-
184
- for (const name of entries) {
237
+ for (const name of await readdir(dir)) {
185
238
  if (name.endsWith(".test.js")) continue;
186
239
 
187
240
  const fullPath = join(dir, name);
188
- let stats;
189
- try {
190
- stats = await stat(fullPath);
191
- } catch (_err) {
192
- continue;
193
- }
241
+ const stats = await stat(fullPath);
194
242
 
195
243
  if (stats.isFile() && name.endsWith(".js")) {
196
- if (name === "index.js" || name === `${basename(dir)}.js`) {
197
- await loadPlugin(fullPath, hooks);
198
- } else if (isRoot && name !== "index.js") {
199
- await loadPlugin(fullPath, hooks);
244
+ const isEntryFile = name === "index.js" || name === `${basename(dir)}.js`;
245
+ if (isEntryFile || (isRoot && name !== "index.js")) {
246
+ descriptors.push({
247
+ name: basename(fullPath, ".js"),
248
+ url: pathToFileURL(fullPath).href,
249
+ source: fullPath,
250
+ });
200
251
  }
201
252
  } else if (stats.isDirectory()) {
202
253
  if (existsSync(join(fullPath, "DISABLED"))) continue;
203
- await scanDir(fullPath, hooks, false);
254
+ await collectFromDir(fullPath, false, descriptors);
204
255
  }
205
256
  }
206
257
  }
207
258
 
208
259
  const PLUGIN_LOAD_TIMEOUT = 10000;
209
260
 
210
- async function loadPlugin(filePath, hooks) {
211
- try {
212
- const url = pathToFileURL(filePath).href;
213
- const { default: Plugin } = await withTimeout(
214
- import(url),
215
- PLUGIN_LOAD_TIMEOUT,
216
- `Plugin import timed out: ${filePath}`,
217
- );
218
-
219
- if (typeof Plugin?.register === "function") {
220
- await withTimeout(
221
- Plugin.register(hooks),
222
- PLUGIN_LOAD_TIMEOUT,
223
- `Plugin register timed out: ${filePath}`,
224
- );
225
- } else if (typeof Plugin === "function") {
226
- const name = basename(filePath, ".js");
227
- const ctx = new PluginContext(name, hooks);
228
- const _instance = new Plugin(ctx);
229
- instances.set(name, ctx);
230
- }
231
- } catch (err) {
232
- console.warn(
233
- `[RUMMY] Plugin load failed: ${basename(filePath)} — ${err.message}`,
234
- );
235
- }
236
- }
237
-
238
261
  function withTimeout(promise, ms, message) {
239
262
  return Promise.race([
240
263
  promise,
@@ -1,15 +1,41 @@
1
- # instructions
1
+ # instructions {#instructions_plugin}
2
2
 
3
- Projects the system prompt instructions into model context.
3
+ Projects the model-facing instructions into the assembled packet.
4
+ Cleanly split into a stable system-side base and a dynamic user-side
5
+ phase directive so prompt caching holds across turns within a run.
4
6
 
5
7
  ## Registration
6
8
 
7
- - **View**: `full` — renders preamble + tool docs + persona.
8
- - **Event**: `turn.started` writes `instructions://system` entry.
9
- - **Filter**: `instructions.toolDocs` gathers docs from all tool plugins.
9
+ - **View**: `full` — renders the `instructions.md` base (identity +
10
+ `[%TOOLS%]` + `[%TOOLDOCS%]` + optional persona) for the
11
+ `instructions://system` entry. Stable across turns.
12
+ - **Event**: `turn.started` — writes `instructions://system` entry
13
+ with `{ persona, toolSet }` attributes.
14
+ - **Filter**: `instructions.toolDocs` — gathers docs from all tool
15
+ plugins into a docsMap.
16
+ - **Filter**: `assembly.user` (priority 250) — renders the current
17
+ phase's `instructions_10N.md` as `<instructions>` immediately
18
+ before `<prompt>`. Phase selected from the latest `<update status>`
19
+ emission in this turn's row set.
10
20
 
11
- ## Behavior
21
+ ## Files
12
22
 
13
- Replaces the `[%TOOLS%]` placeholder in the preamble with the active
14
- tool list. Appends tool descriptions gathered via the `toolDocs` filter
15
- and persona text when present in attributes.
23
+ - `instructions.js` plugin registration and assembly logic.
24
+ - `instructions.md` the system-side base template. Static across
25
+ turns; only identity + `[%TOOLS%]` + `[%TOOLDOCS%]` placeholders.
26
+ - `instructions_104.md` … `instructions_108.md` — phase-specific
27
+ directives keyed by the 1XY status encoding (Define / Discover /
28
+ Distill / Demote / Deploy).
29
+ - `protocol.js` — placeholder module reserved for deterministic
30
+ protocol rule enforcement. Currently pass-through.
31
+
32
+ ## Cache shape
33
+
34
+ - System message includes the base template + tool docs + persona.
35
+ Identical bytes every turn within a run → cache-stable.
36
+ - User message includes `<instructions>` at priority 250 — changes
37
+ as the phase advances, which is expected cache-turnover territory.
38
+
39
+ If you add a per-turn-dynamic piece to `instructions.md` by mistake,
40
+ the system prompt changes every turn and the cache prefix collapses.
41
+ Put anything turn-specific in a phase file instead.