@possumtech/rummy 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +13 -2
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +422 -188
  4. package/SPEC.md +440 -106
  5. package/migrations/001_initial_schema.sql +5 -3
  6. package/package.json +17 -5
  7. package/service.js +5 -3
  8. package/src/agent/AgentLoop.js +252 -55
  9. package/src/agent/ContextAssembler.js +20 -4
  10. package/src/agent/KnownStore.js +82 -25
  11. package/src/agent/ProjectAgent.js +4 -1
  12. package/src/agent/ResponseHealer.js +86 -32
  13. package/src/agent/TurnExecutor.js +542 -207
  14. package/src/agent/XmlParser.js +77 -41
  15. package/src/agent/known_store.sql +68 -4
  16. package/src/agent/schemes.sql +3 -0
  17. package/src/agent/tokens.js +7 -21
  18. package/src/agent/turns.sql +15 -1
  19. package/src/hooks/HookRegistry.js +7 -0
  20. package/src/hooks/Hooks.js +15 -0
  21. package/src/hooks/PluginContext.js +14 -1
  22. package/src/hooks/RummyContext.js +16 -4
  23. package/src/hooks/ToolRegistry.js +77 -19
  24. package/src/llm/LlmProvider.js +27 -8
  25. package/src/llm/OpenAiClient.js +20 -0
  26. package/src/llm/OpenRouterClient.js +24 -2
  27. package/src/llm/XaiClient.js +47 -2
  28. package/src/plugins/ask_user/README.md +4 -4
  29. package/src/plugins/ask_user/ask_user.js +5 -5
  30. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  31. package/src/plugins/budget/README.md +31 -0
  32. package/src/plugins/budget/budget.js +55 -0
  33. package/src/plugins/cp/README.md +5 -4
  34. package/src/plugins/cp/cp.js +10 -6
  35. package/src/plugins/cp/cpDoc.js +29 -0
  36. package/src/plugins/engine/engine.sql +1 -8
  37. package/src/plugins/engine/turn_context.sql +4 -9
  38. package/src/plugins/env/README.md +3 -4
  39. package/src/plugins/env/env.js +5 -5
  40. package/src/plugins/env/envDoc.js +29 -0
  41. package/src/plugins/file/README.md +9 -12
  42. package/src/plugins/file/file.js +34 -35
  43. package/src/plugins/get/README.md +2 -2
  44. package/src/plugins/get/get.js +77 -6
  45. package/src/plugins/get/getDoc.js +51 -0
  46. package/src/plugins/hedberg/hedberg.js +2 -1
  47. package/src/plugins/hedberg/matcher.js +10 -29
  48. package/src/plugins/hedberg/normalize.js +28 -0
  49. package/src/plugins/hedberg/patterns.js +25 -27
  50. package/src/plugins/hedberg/sed.js +17 -10
  51. package/src/plugins/index.js +66 -14
  52. package/src/plugins/instructions/README.md +6 -2
  53. package/src/plugins/instructions/instructions.js +20 -4
  54. package/src/plugins/instructions/preamble.md +19 -5
  55. package/src/plugins/known/README.md +10 -7
  56. package/src/plugins/known/known.js +23 -17
  57. package/src/plugins/known/knownDoc.js +34 -0
  58. package/src/plugins/mv/README.md +5 -4
  59. package/src/plugins/mv/mv.js +27 -6
  60. package/src/plugins/mv/mvDoc.js +45 -0
  61. package/src/plugins/performed/README.md +15 -0
  62. package/src/plugins/performed/performed.js +45 -0
  63. package/src/plugins/persona/persona.js +78 -0
  64. package/src/plugins/previous/README.md +3 -2
  65. package/src/plugins/previous/previous.js +33 -24
  66. package/src/plugins/progress/README.md +1 -2
  67. package/src/plugins/progress/progress.js +33 -21
  68. package/src/plugins/prompt/README.md +5 -5
  69. package/src/plugins/prompt/prompt.js +15 -17
  70. package/src/plugins/rm/README.md +4 -4
  71. package/src/plugins/rm/rm.js +32 -20
  72. package/src/plugins/rm/rmDoc.js +30 -0
  73. package/src/plugins/rpc/README.md +15 -28
  74. package/src/plugins/rpc/rpc.js +42 -77
  75. package/src/plugins/set/README.md +13 -12
  76. package/src/plugins/set/set.js +107 -16
  77. package/src/plugins/set/setDoc.js +49 -0
  78. package/src/plugins/sh/README.md +4 -4
  79. package/src/plugins/sh/sh.js +5 -5
  80. package/src/plugins/sh/shDoc.js +29 -0
  81. package/src/plugins/{skills/skills.js → skill/skill.js} +10 -51
  82. package/src/plugins/summarize/README.md +6 -5
  83. package/src/plugins/summarize/summarize.js +7 -6
  84. package/src/plugins/summarize/summarizeDoc.js +33 -0
  85. package/src/plugins/telemetry/telemetry.js +16 -9
  86. package/src/plugins/think/README.md +20 -0
  87. package/src/plugins/think/think.js +5 -0
  88. package/src/plugins/unknown/README.md +6 -5
  89. package/src/plugins/unknown/unknown.js +12 -9
  90. package/src/plugins/unknown/unknownDoc.js +31 -0
  91. package/src/plugins/update/README.md +3 -8
  92. package/src/plugins/update/update.js +7 -6
  93. package/src/plugins/update/updateDoc.js +33 -0
  94. package/src/server/ClientConnection.js +59 -45
  95. package/src/server/RpcRegistry.js +52 -4
  96. package/src/sql/v_model_context.sql +10 -25
  97. package/src/plugins/ask_user/docs.md +0 -2
  98. package/src/plugins/cp/docs.md +0 -2
  99. package/src/plugins/current/README.md +0 -14
  100. package/src/plugins/current/current.js +0 -47
  101. package/src/plugins/env/docs.md +0 -4
  102. package/src/plugins/get/docs.md +0 -10
  103. package/src/plugins/known/docs.md +0 -3
  104. package/src/plugins/mv/docs.md +0 -2
  105. package/src/plugins/rm/docs.md +0 -6
  106. package/src/plugins/set/docs.md +0 -6
  107. package/src/plugins/sh/docs.md +0 -2
  108. package/src/plugins/skills/README.md +0 -25
  109. package/src/plugins/store/README.md +0 -20
  110. package/src/plugins/store/docs.md +0 -6
  111. package/src/plugins/store/store.js +0 -63
  112. package/src/plugins/summarize/docs.md +0 -4
  113. package/src/plugins/unknown/docs.md +0 -5
  114. package/src/plugins/update/docs.md +0 -4
@@ -1,6 +1,6 @@
1
- import { readFileSync } from "node:fs";
2
1
  import KnownStore from "../../agent/KnownStore.js";
3
2
  import { storePatternResult } from "../helpers.js";
3
+ import docs from "./getDoc.js";
4
4
 
5
5
  export default class Get {
6
6
  #core;
@@ -11,10 +11,10 @@ export default class Get {
11
11
  core.on("handler", this.handler.bind(this));
12
12
  core.on("full", this.full.bind(this));
13
13
  core.on("summary", this.summary.bind(this));
14
- const docs = readFileSync(new URL("./docs.md", import.meta.url), "utf8");
15
- core.filter("instructions.toolDocs", async (content) =>
16
- content ? `${content}\n\n${docs}` : docs,
17
- );
14
+ core.filter("instructions.toolDocs", async (docsMap) => {
15
+ docsMap.get = docs;
16
+ return docsMap;
17
+ });
18
18
  }
19
19
 
20
20
  async handler(entry, rummy) {
@@ -30,12 +30,81 @@ export default class Get {
30
30
  const normalized = KnownStore.normalizePath(target);
31
31
  const bodyFilter = entry.attributes.body || null;
32
32
  const isPattern = bodyFilter || normalized.includes("*");
33
+
34
+ const line =
35
+ entry.attributes.line != null
36
+ ? Math.max(1, parseInt(entry.attributes.line, 10))
37
+ : null;
38
+ const limit =
39
+ entry.attributes.limit != null
40
+ ? Math.max(1, parseInt(entry.attributes.limit, 10))
41
+ : null;
42
+
33
43
  const matches = await store.getEntriesByPattern(
34
44
  runId,
35
45
  normalized,
36
46
  bodyFilter,
37
47
  );
48
+
49
+ // Partial read — no fidelity promotion, returns a line slice as the log item.
50
+ if (line !== null || limit !== null) {
51
+ if (isPattern) {
52
+ await store.upsert(
53
+ runId,
54
+ turn,
55
+ entry.resultPath,
56
+ "line/limit requires a single path, not a glob or body filter",
57
+ 400,
58
+ { loopId },
59
+ );
60
+ return;
61
+ }
62
+ if (matches.length === 0) {
63
+ await store.upsert(
64
+ runId,
65
+ turn,
66
+ entry.resultPath,
67
+ `${target} not found`,
68
+ 200,
69
+ { loopId },
70
+ );
71
+ return;
72
+ }
73
+ const allLines = matches[0].body.split("\n");
74
+ const total = allLines.length;
75
+ const startLine = line ?? 1;
76
+ const startIdx = startLine - 1;
77
+ const endIdx = limit !== null ? Math.min(startIdx + limit, total) : total;
78
+ const slice = allLines.slice(startIdx, endIdx).join("\n");
79
+ const endLine = endIdx;
80
+ const header = `[lines ${startLine}–${endLine} / ${total} total]`;
81
+ await store.upsert(
82
+ runId,
83
+ turn,
84
+ entry.resultPath,
85
+ `${header}\n${slice}`,
86
+ 200,
87
+ { loopId },
88
+ );
89
+ return;
90
+ }
91
+
92
+ const VALID_FIDELITY = {
93
+ stored: 1,
94
+ summary: 1,
95
+ index: 1,
96
+ full: 1,
97
+ archive: 1,
98
+ };
99
+ const fidelityAttr = VALID_FIDELITY[entry.attributes.fidelity]
100
+ ? entry.attributes.fidelity
101
+ : null;
102
+
38
103
  await store.promoteByPattern(runId, normalized, bodyFilter, turn);
104
+ if (fidelityAttr) {
105
+ for (const match of matches)
106
+ await store.setFidelity(runId, match.path, fidelityAttr);
107
+ }
39
108
 
40
109
  if (isPattern) {
41
110
  await storePatternResult(
@@ -52,7 +121,9 @@ export default class Get {
52
121
  const total = matches.reduce((s, m) => s + m.tokens_full, 0);
53
122
  const paths = matches.map((m) => m.path).join(", ");
54
123
  const body =
55
- matches.length > 0 ? `${paths} ${total} tokens` : `${target} not found`;
124
+ matches.length > 0
125
+ ? `${paths} loaded into <knowns> (${total} tokens)`
126
+ : `${target} not found`;
56
127
  await store.upsert(runId, turn, entry.resultPath, body, 200, {
57
128
  loopId,
58
129
  });
@@ -0,0 +1,51 @@
1
+ // Tool doc for <get>. 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
+ // --- Syntax: body-form is the primary invocation (simplest)
6
+ ["## <get>[path/to/file]</get> - Load a file or entry into context"],
7
+
8
+ // --- Examples: 3 examples covering single file, known recall, and content search
9
+ [
10
+ "Example: <get>src/app.js</get>",
11
+ "Simplest form. Body = path. Teaches that get is the default read tool.",
12
+ ],
13
+ [
14
+ 'Example: <get path="known://*">auth</get>',
15
+ "Keyword recall: glob in path, search term in body. Cross-scheme hedberg pattern.",
16
+ ],
17
+ [
18
+ 'Example: <get path="src/**/*.js" preview>authentication</get>',
19
+ "Full pattern: recursive glob + preview + content filter. Shows all 3 features at once. Body is a filter keyword, never file content.",
20
+ ],
21
+
22
+ // --- Partial read: line/limit — show before constraints so model sees it as a first-class pattern
23
+ [
24
+ 'Example: <get path="src/agent/AgentLoop.js" line="644" limit="80"/>',
25
+ "Partial read. Returns lines 644–723 as the log item without promoting the entry to full. Use summary fidelity to find line numbers, then target the symbol directly.",
26
+ ],
27
+
28
+ // --- Constraints: RFC-style. Each prevents a specific failure mode.
29
+ [
30
+ "* Paths accept patterns: `src/**/*.js`, `known://api_*`",
31
+ "Reinforces picomatch patterns work everywhere, not just in examples.",
32
+ ],
33
+ [
34
+ "* `preview` shows matches without loading into context",
35
+ "Budget-awareness. Without this, models load everything and blow context.",
36
+ ],
37
+ [
38
+ "* Body text filters results by content match",
39
+ "Generalizes examples 2-3. Body = filter, not just path.",
40
+ ],
41
+ [
42
+ "* `line` and `limit` read a slice without promoting — patterns not allowed",
43
+ "The no-promotion constraint is what makes partial read safe: context budget is unaffected.",
44
+ ],
45
+ [
46
+ '* Use <set path="..." fidelity="archive"/> to remove loaded content from context',
47
+ "Lifecycle: get→set. Load, read, archive. Prevents context hoarding.",
48
+ ],
49
+ ];
50
+
51
+ export default LINES.map(([text]) => text).join("\n");
@@ -1,6 +1,6 @@
1
1
  import { parseEditContent } from "./edits.js";
2
2
  import HeuristicMatcher, { generatePatch } from "./matcher.js";
3
- import { normalizeAttrs } from "./normalize.js";
3
+ import { normalizeAttrs, parseJsonEdit } from "./normalize.js";
4
4
  import { hedmatch, hedsearch } from "./patterns.js";
5
5
  import { parseSed } from "./sed.js";
6
6
 
@@ -29,6 +29,7 @@ export default class Hedberg {
29
29
  replace: Hedberg.replace,
30
30
  parseSed,
31
31
  parseEdits: parseEditContent,
32
+ parseJsonEdit,
32
33
  normalizeAttrs,
33
34
  generatePatch,
34
35
  };
@@ -1,34 +1,15 @@
1
- import { execSync } from "node:child_process";
2
- import { unlinkSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
1
+ import { createTwoFilesPatch } from "diff";
5
2
 
6
3
  export function generatePatch(filePath, oldContent, newContent) {
7
- const id = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
8
- const oldPath = join(tmpdir(), `rummy_diff_old_${id}`);
9
- const newPath = join(tmpdir(), `rummy_diff_new_${id}`);
10
-
11
- try {
12
- writeFileSync(oldPath, oldContent);
13
- writeFileSync(newPath, newContent);
14
-
15
- const result = execSync(
16
- `diff -u --label "${filePath}\told" --label "${filePath}\tnew" "${oldPath}" "${newPath}"`,
17
- { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
18
- );
19
- return result;
20
- } catch (err) {
21
- // diff exits 1 when files differ — that's the success case
22
- if (err.stdout) return err.stdout;
23
- return "";
24
- } finally {
25
- try {
26
- unlinkSync(oldPath);
27
- } catch {}
28
- try {
29
- unlinkSync(newPath);
30
- } catch {}
31
- }
4
+ return createTwoFilesPatch(
5
+ `${filePath}\told`,
6
+ `${filePath}\tnew`,
7
+ oldContent,
8
+ newContent,
9
+ "",
10
+ "",
11
+ { context: 3 },
12
+ );
32
13
  }
33
14
 
34
15
  export default class HeuristicMatcher {
@@ -19,6 +19,8 @@ const KNOWN_ATTRS = new Set([
19
19
  "results",
20
20
  "command",
21
21
  "warn",
22
+ "summary",
23
+ "fidelity",
22
24
  ]);
23
25
 
24
26
  export function normalizeAttrs(attrs) {
@@ -37,5 +39,31 @@ export function normalizeAttrs(attrs) {
37
39
  }
38
40
  }
39
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);
40
46
  return out;
41
47
  }
48
+
49
+ /**
50
+ * Parse JSON-style edit from body content.
51
+ * Accepts: {"search":"old","replace":"new"} and {search="old",replace="new"}
52
+ * Returns { search, replace } or null.
53
+ */
54
+ export function parseJsonEdit(text) {
55
+ const trimmed = text.trim();
56
+ if (!trimmed.startsWith("{") || !/search/.test(trimmed)) return null;
57
+ try {
58
+ const json = JSON.parse(trimmed);
59
+ if (json.search != null)
60
+ return { search: json.search, replace: json.replace ?? "" };
61
+ } catch {
62
+ const searchMatch = trimmed.match(/search\s*=\s*"([^"]*)"/);
63
+ const replaceMatch = trimmed.match(/replace\s*=\s*"([^"]*)"/);
64
+ if (searchMatch) {
65
+ return { search: searchMatch[1], replace: replaceMatch?.[1] ?? "" };
66
+ }
67
+ }
68
+ return null;
69
+ }
@@ -1,4 +1,5 @@
1
1
  import { DOMParser } from "@xmldom/xmldom";
2
+ import picomatch from "picomatch";
2
3
  import xpath from "xpath";
3
4
 
4
5
  export const deterministic = true;
@@ -131,26 +132,7 @@ function detect(pattern) {
131
132
 
132
133
  // --- Compilation ---
133
134
 
134
- function globToRegex(glob) {
135
- let result = "";
136
- for (let i = 0; i < glob.length; i++) {
137
- const c = glob[i];
138
- if (c === "*") result += ".*";
139
- else if (c === "?") result += ".";
140
- else if (c === "[") {
141
- const close = glob.indexOf("]", i + 1);
142
- if (close === -1) {
143
- result += "\\[";
144
- continue;
145
- }
146
- result += glob.slice(i, close + 1);
147
- i = close;
148
- } else if (/[.+^${}()|\\]/.test(c)) {
149
- result += `\\${c}`;
150
- } else result += c;
151
- }
152
- return result;
153
- }
135
+ // Glob matching delegated to picomatch (standard, battle-tested).
154
136
 
155
137
  function parseRegex(pattern) {
156
138
  const lastSlash = pattern.lastIndexOf("/");
@@ -214,12 +196,28 @@ function compile(pattern) {
214
196
  switch (type) {
215
197
  case "literal":
216
198
  return { type, pattern };
217
- case "glob":
218
- return {
219
- type,
220
- anchoredRe: new RegExp(`^${globToRegex(pattern)}$`),
221
- searchRe: new RegExp(globToRegex(pattern)),
222
- };
199
+ case "glob": {
200
+ const escaped = pattern.replace(/([()])/g, "\\$1");
201
+ // Scheme paths have no directory structure — * matches everything
202
+ const opts = escaped.includes("://")
203
+ ? {
204
+ dot: true,
205
+ nobrace: true,
206
+ noextglob: true,
207
+ bash: false,
208
+ regex: true,
209
+ }
210
+ : { dot: true, nobrace: true, noextglob: true };
211
+
212
+ // For scheme paths, convert single * after :// to ** so it crosses "/"
213
+ const prepared = escaped.includes("://")
214
+ ? escaped.replace(/:\/\/\*(?!\*)/, "://**")
215
+ : escaped;
216
+
217
+ const isMatch = picomatch(prepared, opts);
218
+ const picoRe = picomatch.makeRe(prepared, opts);
219
+ return { type, isMatch, searchRe: picoRe };
220
+ }
223
221
  case "regex": {
224
222
  const { body, flags } = parseRegex(pattern);
225
223
  return {
@@ -332,7 +330,7 @@ export function hedmatch(pattern, string) {
332
330
  case "literal":
333
331
  return string === compiled.pattern;
334
332
  case "glob":
335
- return compiled.anchoredRe.test(string);
333
+ return compiled.isMatch(string);
336
334
  case "regex":
337
335
  return compiled.re.test(string);
338
336
  case "sed":
@@ -5,14 +5,15 @@
5
5
  * - Flag extraction (g, i, m, s, v)
6
6
  */
7
7
 
8
- function splitSed(str) {
8
+ function splitSed(str, delim) {
9
9
  const parts = [];
10
10
  let current = "";
11
+ const escaped = `\\${delim}`;
11
12
  for (let i = 0; i < str.length; i++) {
12
13
  if (str[i] === "\\" && i + 1 < str.length) {
13
14
  current += str[i] + str[i + 1];
14
15
  i++;
15
- } else if (str[i] === "/") {
16
+ } else if (str[i] === delim) {
16
17
  parts.push(current);
17
18
  current = "";
18
19
  } else {
@@ -20,26 +21,32 @@ function splitSed(str) {
20
21
  }
21
22
  }
22
23
  parts.push(current);
23
- return parts;
24
+ return { parts, escaped };
24
25
  }
25
26
 
26
27
  export function parseSed(input) {
27
- if (!input.startsWith("s/")) return null;
28
+ // Sed allows any non-alphanumeric delimiter: s/old/new/, s|old|new|, s#old#new#
29
+ const match = input.match(/^s([^\w\s])/);
30
+ if (!match) return null;
28
31
 
32
+ const delim = match[1];
29
33
  const blocks = [];
30
34
  let remaining = input;
31
- while (remaining.startsWith("s/")) {
32
- const parts = splitSed(remaining.slice(2));
35
+ const prefix = `s${delim}`;
36
+
37
+ while (remaining.startsWith(prefix)) {
38
+ const { parts, escaped } = splitSed(remaining.slice(2), delim);
33
39
  if (parts.length < 2) break;
34
40
  const flags = (parts[2] || "").match(/^[gimsv]*/)?.[0] || "";
41
+ const unesc = (s) => s.replaceAll(escaped, delim);
35
42
  blocks.push({
36
- search: parts[0].replaceAll("\\/", "/"),
37
- replace: parts[1].replaceAll("\\/", "/"),
43
+ search: unesc(parts[0]),
44
+ replace: unesc(parts[1]),
38
45
  flags,
39
46
  sed: true,
40
47
  });
41
- const rest = parts.slice(2).join("/");
42
- const next = rest.indexOf("s/");
48
+ const rest = parts.slice(2).join(delim);
49
+ const next = rest.indexOf(prefix);
43
50
  remaining = next >= 0 ? rest.slice(next) : "";
44
51
  }
45
52
 
@@ -1,7 +1,7 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readdir, stat } from "node:fs/promises";
4
- import { basename, join } from "node:path";
4
+ import { basename, isAbsolute, join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import PluginContext from "../hooks/PluginContext.js";
7
7
 
@@ -30,10 +30,6 @@ export async function registerPlugins(dirs = [], hooks) {
30
30
  const AUDIT_SCHEMES = [
31
31
  "instructions",
32
32
  "system",
33
- "prompt",
34
- "ask",
35
- "act",
36
- "progress",
37
33
  "reasoning",
38
34
  "model",
39
35
  "error",
@@ -42,6 +38,8 @@ const AUDIT_SCHEMES = [
42
38
  "content",
43
39
  ];
44
40
 
41
+ const PROMPT_SCHEMES = ["prompt", "progress"];
42
+
45
43
  /**
46
44
  * After DB is ready, inject db and store into all PluginContext instances,
47
45
  * upsert declared schemes, and bootstrap audit schemes.
@@ -50,10 +48,17 @@ export async function initPlugins(db, store, hooks) {
50
48
  for (const name of AUDIT_SCHEMES) {
51
49
  await db.upsert_scheme.run({
52
50
  name,
53
- model_visible: ["ask", "act", "progress"].includes(name) ? 1 : 0,
51
+ model_visible: 0,
54
52
  category: "audit",
55
53
  });
56
54
  }
55
+ for (const name of PROMPT_SCHEMES) {
56
+ await db.upsert_scheme.run({
57
+ name,
58
+ model_visible: 1,
59
+ category: "prompt",
60
+ });
61
+ }
57
62
 
58
63
  for (const ctx of instances.values()) {
59
64
  ctx.db = db;
@@ -70,16 +75,19 @@ export async function initPlugins(db, store, hooks) {
70
75
  for (const s of ctx.schemes) registered.add(s.name);
71
76
  }
72
77
  for (const name of AUDIT_SCHEMES) registered.add(name);
78
+ for (const name of PROMPT_SCHEMES) registered.add(name);
73
79
 
74
80
  for (const toolName of hooks.tools.names) {
75
81
  if (registered.has(toolName)) continue;
76
82
  await db.upsert_scheme.run({
77
83
  name: toolName,
78
84
  model_visible: 1,
79
- category: "result",
85
+ category: "logging",
80
86
  });
81
87
  }
82
88
  }
89
+
90
+ if (store) store.loadSchemes(db);
83
91
  }
84
92
 
85
93
  function resolvePlugin(packageName) {
@@ -105,9 +113,20 @@ async function loadEnvPlugins(hooks) {
105
113
  if (!key.startsWith("RUMMY_PLUGIN_") || !value) continue;
106
114
  const name = key.replace("RUMMY_PLUGIN_", "").toLowerCase();
107
115
  try {
108
- const { default: Plugin } = await importPlugin(value);
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
+ );
109
124
  if (typeof Plugin?.register === "function") {
110
- await Plugin.register(hooks);
125
+ await withTimeout(
126
+ Plugin.register(hooks),
127
+ PLUGIN_LOAD_TIMEOUT,
128
+ `Plugin register timed out: ${value}`,
129
+ );
111
130
  } else if (typeof Plugin === "function") {
112
131
  const ctx = new PluginContext(name, hooks);
113
132
  new Plugin(ctx);
@@ -120,6 +139,19 @@ async function loadEnvPlugins(hooks) {
120
139
  }
121
140
  }
122
141
 
142
+ async function importAbsolute(dir) {
143
+ const pkgPath = join(dir, "package.json");
144
+ if (!existsSync(pkgPath)) {
145
+ // Bare .js file
146
+ return import(pathToFileURL(dir).href);
147
+ }
148
+ const pkg = JSON.parse(
149
+ (await import("node:fs")).readFileSync(pkgPath, "utf8"),
150
+ );
151
+ const entry = pkg.exports?.["."] || pkg.main || "index.js";
152
+ return import(pathToFileURL(join(dir, entry)).href);
153
+ }
154
+
123
155
  async function scanDir(dir, hooks, isRoot = false) {
124
156
  if (!existsSync(dir)) return;
125
157
 
@@ -167,18 +199,29 @@ async function scanDir(dir, hooks, isRoot = false) {
167
199
  await loadPlugin(fullPath, hooks);
168
200
  }
169
201
  } else if (stats.isDirectory()) {
202
+ if (existsSync(join(fullPath, "DISABLED"))) continue;
170
203
  await scanDir(fullPath, hooks, false);
171
204
  }
172
205
  }
173
206
  }
174
207
 
208
+ const PLUGIN_LOAD_TIMEOUT = 10000;
209
+
175
210
  async function loadPlugin(filePath, hooks) {
176
211
  try {
177
212
  const url = pathToFileURL(filePath).href;
178
- const { default: Plugin } = await import(url);
213
+ const { default: Plugin } = await withTimeout(
214
+ import(url),
215
+ PLUGIN_LOAD_TIMEOUT,
216
+ `Plugin import timed out: ${filePath}`,
217
+ );
179
218
 
180
219
  if (typeof Plugin?.register === "function") {
181
- await Plugin.register(hooks);
220
+ await withTimeout(
221
+ Plugin.register(hooks),
222
+ PLUGIN_LOAD_TIMEOUT,
223
+ `Plugin register timed out: ${filePath}`,
224
+ );
182
225
  } else if (typeof Plugin === "function") {
183
226
  const name = basename(filePath, ".js");
184
227
  const ctx = new PluginContext(name, hooks);
@@ -186,8 +229,17 @@ async function loadPlugin(filePath, hooks) {
186
229
  instances.set(name, ctx);
187
230
  }
188
231
  } catch (err) {
189
- if (process.env.RUMMY_DEBUG === "true") {
190
- console.error(`[RUMMY] Plugin load failed at ${filePath}:`, err);
191
- }
232
+ console.warn(
233
+ `[RUMMY] Plugin load failed: ${basename(filePath)} — ${err.message}`,
234
+ );
192
235
  }
193
236
  }
237
+
238
+ function withTimeout(promise, ms, message) {
239
+ return Promise.race([
240
+ promise,
241
+ new Promise((_, reject) =>
242
+ setTimeout(() => reject(new Error(message)), ms),
243
+ ),
244
+ ]);
245
+ }
@@ -4,8 +4,12 @@ Projects the system prompt instructions into model context.
4
4
 
5
5
  ## Registration
6
6
 
7
- - **Projection**: `onProject("instructions", ...)` — no tool handler.
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.
8
10
 
9
11
  ## Behavior
10
12
 
11
- Replaces the `[%TOOLS%]` placeholder in the prompt body with the `tools` attribute. Appends tool descriptions and persona text when present in attributes.
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.
@@ -17,20 +17,36 @@ export default class Instructions {
17
17
  async onTurnStarted({ rummy }) {
18
18
  const { entries: store, sequence: turn, runId } = rummy;
19
19
  const runRow = await rummy.db.get_run_by_id.get({ id: runId });
20
+ const toolSet = rummy.toolSet
21
+ ? [...rummy.toolSet]
22
+ : this.#core.hooks.tools.names;
20
23
  await store.upsert(runId, turn, "instructions://system", "", 200, {
21
- attributes: { persona: runRow?.persona || null },
24
+ attributes: {
25
+ persona: runRow?.persona || null,
26
+ toolSet,
27
+ },
22
28
  });
23
29
  }
24
30
 
25
31
  async full(entry) {
26
32
  const attrs = entry.attributes;
27
- const tools = this.#core.hooks.tools.names.join(", ");
33
+ const activeTools = attrs.toolSet
34
+ ? new Set(attrs.toolSet)
35
+ : new Set(this.#core.hooks.tools.names);
36
+ const sorted = this.#core.hooks.tools.names.filter((n) =>
37
+ activeTools.has(n),
38
+ );
39
+ const tools = sorted.join(", ");
28
40
  let prompt = preamble.replace("[%TOOLS%]", tools);
29
41
  const toolDocs = await this.#core.hooks.instructions.toolDocs.filter(
30
- "",
31
42
  {},
43
+ { toolSet: activeTools },
32
44
  );
33
- if (toolDocs) prompt += `\n\n${toolDocs}`;
45
+ const docsText = sorted
46
+ .filter((key) => toolDocs[key])
47
+ .map((key) => toolDocs[key])
48
+ .join("\n\n");
49
+ if (docsText) prompt += `\n\n${docsText}`;
34
50
  if (attrs.persona) prompt += `\n\n## Persona\n\n${attrs.persona}`;
35
51
  return prompt;
36
52
  }
@@ -1,12 +1,26 @@
1
- You are an assistant. You gather information, then either answer questions or take action.
1
+ You are a folksonomic memory agent. YOU MUST organize all information into searchable taxonomies with navigable path hierarchies and searchable summary tags, then YOU MAY answer questions and/or take action.
2
2
 
3
3
  # Response Rules
4
4
 
5
- * You must register unknowns with <unknown>(thing I don't know yet)</unknown> before acting.
6
- * Save known information with <known>(thing I know now)</known>.
7
- * Respond with Tool Commands. You may use multiple tools in your response.
5
+ Required: YOU MUST respond with Tool Commands in the XML format. YOU MAY use multiple tools in your response.
6
+
7
+ Optional: YOU MAY think in an optional <think></think> tag before using any other Tool Commands.
8
+
9
+ Required: YOU MUST register all unknowns with <unknown>[specific thing I need to learn]</unknown>.
10
+
11
+ Required: YOU MUST register all new facts, decisions, and plans with <known path="topic/subtopic" summary="keyword,keyword,keyword">[specific facts, decisions, or plans]</known>.
12
+ Required: Every <known> MUST include summary="keyword,keyword" tags.
13
+ Info: Paths are addresses for tools. Summary tags tell you what's inside.
14
+ Info: Path and summary information is approximate. YOU MUST use <get/> to verify before acting on summarized content.
15
+ Info: When information conflicts, later turns are more likely to be relevant and correct than earlier turns.
16
+ Info: Your context is limited but your archive is not. Organize and categorize your facts, decisions, plans, and history to optimize your context.
17
+
18
+ Required: YOU MUST promote all relevant "summary" entries to "full".
19
+ Required: YOU MUST demote all irrelevant "full" entries to "summary".
20
+
21
+ Required: YOU MUST conclude every turn with EITHER <update></update> if still working OR <summarize></summarize> if done. Never both.
22
+ Required: YOU MUST use one and only one <update></update> or <summarize></summarize> tag, and only at the end.
8
23
 
9
24
  # Tool Commands
10
25
 
11
26
  Tools: [%TOOLS%]
12
- Required: Either `<update/>` if still working or `<summarize/>` if done. Never both.