@possumtech/rummy 0.2.8 → 0.3.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 (108) hide show
  1. package/.env.example +11 -1
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +422 -188
  4. package/SPEC.md +284 -93
  5. package/migrations/001_initial_schema.sql +6 -4
  6. package/package.json +13 -5
  7. package/src/agent/AgentLoop.js +166 -15
  8. package/src/agent/ContextAssembler.js +18 -4
  9. package/src/agent/KnownStore.js +127 -13
  10. package/src/agent/ProjectAgent.js +4 -1
  11. package/src/agent/ResponseHealer.js +21 -1
  12. package/src/agent/TurnExecutor.js +365 -175
  13. package/src/agent/XmlParser.js +72 -39
  14. package/src/agent/known_store.sql +20 -4
  15. package/src/agent/schemes.sql +3 -0
  16. package/src/agent/tokens.js +6 -21
  17. package/src/agent/turns.sql +10 -1
  18. package/src/hooks/Hooks.js +18 -0
  19. package/src/hooks/PluginContext.js +14 -1
  20. package/src/hooks/RummyContext.js +16 -4
  21. package/src/hooks/ToolRegistry.js +83 -19
  22. package/src/llm/LlmProvider.js +27 -8
  23. package/src/llm/OpenAiClient.js +20 -0
  24. package/src/llm/OpenRouterClient.js +24 -2
  25. package/src/llm/XaiClient.js +47 -2
  26. package/src/plugins/ask_user/README.md +4 -4
  27. package/src/plugins/ask_user/ask_user.js +5 -5
  28. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  29. package/src/plugins/budget/BudgetGuard.js +74 -0
  30. package/src/plugins/budget/README.md +43 -0
  31. package/src/plugins/budget/budget.js +79 -0
  32. package/src/plugins/cp/README.md +5 -4
  33. package/src/plugins/cp/cp.js +10 -6
  34. package/src/plugins/cp/cpDoc.js +29 -0
  35. package/src/plugins/current/README.md +4 -4
  36. package/src/plugins/current/current.js +9 -6
  37. package/src/plugins/engine/engine.sql +1 -8
  38. package/src/plugins/engine/turn_context.sql +4 -9
  39. package/src/plugins/env/README.md +3 -4
  40. package/src/plugins/env/env.js +5 -5
  41. package/src/plugins/env/envDoc.js +29 -0
  42. package/src/plugins/file/README.md +9 -12
  43. package/src/plugins/file/file.js +34 -35
  44. package/src/plugins/get/README.md +2 -2
  45. package/src/plugins/get/get.js +6 -5
  46. package/src/plugins/get/getDoc.js +41 -0
  47. package/src/plugins/hedberg/hedberg.js +2 -1
  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 +9 -5
  55. package/src/plugins/known/README.md +10 -7
  56. package/src/plugins/known/known.js +29 -17
  57. package/src/plugins/known/knownDoc.js +33 -0
  58. package/src/plugins/mv/README.md +5 -4
  59. package/src/plugins/mv/mv.js +10 -6
  60. package/src/plugins/mv/mvDoc.js +31 -0
  61. package/src/plugins/persona/persona.js +78 -0
  62. package/src/plugins/previous/README.md +2 -2
  63. package/src/plugins/previous/previous.js +9 -6
  64. package/src/plugins/progress/progress.js +41 -15
  65. package/src/plugins/prompt/README.md +5 -5
  66. package/src/plugins/prompt/prompt.js +18 -13
  67. package/src/plugins/rm/README.md +4 -4
  68. package/src/plugins/rm/rm.js +5 -5
  69. package/src/plugins/rm/rmDoc.js +30 -0
  70. package/src/plugins/rpc/README.md +15 -28
  71. package/src/plugins/rpc/rpc.js +42 -77
  72. package/src/plugins/set/README.md +13 -12
  73. package/src/plugins/set/set.js +60 -5
  74. package/src/plugins/set/setDoc.js +45 -0
  75. package/src/plugins/sh/README.md +4 -4
  76. package/src/plugins/sh/sh.js +5 -5
  77. package/src/plugins/sh/shDoc.js +29 -0
  78. package/src/plugins/{skills/skills.js → skill/skill.js} +10 -51
  79. package/src/plugins/summarize/README.md +6 -5
  80. package/src/plugins/summarize/summarize.js +7 -6
  81. package/src/plugins/summarize/summarizeDoc.js +33 -0
  82. package/src/plugins/telemetry/telemetry.js +3 -1
  83. package/src/plugins/think/README.md +20 -0
  84. package/src/plugins/think/think.js +5 -0
  85. package/src/plugins/unknown/README.md +5 -5
  86. package/src/plugins/unknown/unknown.js +9 -7
  87. package/src/plugins/unknown/unknownDoc.js +31 -0
  88. package/src/plugins/update/README.md +3 -8
  89. package/src/plugins/update/update.js +7 -6
  90. package/src/plugins/update/updateDoc.js +33 -0
  91. package/src/server/RpcRegistry.js +52 -4
  92. package/src/sql/v_model_context.sql +16 -21
  93. package/src/plugins/ask_user/docs.md +0 -2
  94. package/src/plugins/cp/docs.md +0 -2
  95. package/src/plugins/env/docs.md +0 -4
  96. package/src/plugins/get/docs.md +0 -10
  97. package/src/plugins/known/docs.md +0 -3
  98. package/src/plugins/mv/docs.md +0 -2
  99. package/src/plugins/rm/docs.md +0 -6
  100. package/src/plugins/set/docs.md +0 -6
  101. package/src/plugins/sh/docs.md +0 -2
  102. package/src/plugins/skills/README.md +0 -25
  103. package/src/plugins/store/README.md +0 -20
  104. package/src/plugins/store/docs.md +0 -6
  105. package/src/plugins/store/store.js +0 -63
  106. package/src/plugins/summarize/docs.md +0 -4
  107. package/src/plugins/unknown/docs.md +0 -5
  108. package/src/plugins/update/docs.md +0 -4
@@ -1,34 +1,45 @@
1
1
  import { isAbsolute, relative } from "node:path";
2
2
 
3
+ /**
4
+ * File plugin: projections and constraints for filesystem entries.
5
+ *
6
+ * Bare file paths (src/app.js) have scheme=NULL in the DB because
7
+ * schemeOf() only recognizes "://" patterns. The schemes table has
8
+ * a "file" entry so v_model_context can JOIN via COALESCE(scheme, 'file').
9
+ * This is the one exception to "every scheme has a plugin owner" —
10
+ * the file plugin owns the NULL scheme through the "file" registry entry.
11
+ */
3
12
  export default class File {
4
13
  #core;
5
14
 
6
15
  constructor(core) {
7
16
  this.#core = core;
8
- core.registerScheme({ category: "file" });
9
- core.registerScheme({ name: "http", category: "file" });
10
- core.registerScheme({ name: "https", category: "file" });
17
+ // "file" scheme covers bare paths (scheme IS NULL in DB)
18
+ core.registerScheme({ category: "data" });
19
+ core.registerScheme({ name: "http", category: "data" });
20
+ core.registerScheme({ name: "https", category: "data" });
11
21
  core.on("full", this.full.bind(this));
12
-
13
- // Register identity projections for schemes that just pass through body
14
- for (const scheme of ["known", "skill", "ask", "act", "progress"]) {
15
- core.hooks.tools.onView(scheme, (entry) => entry.body);
16
- }
22
+ core.on("summary", this.summary.bind(this));
23
+ // Default identity views for http/https rummy.web overrides these
24
+ core.hooks.tools.onView("http", (entry) => entry.body);
25
+ core.hooks.tools.onView("https", (entry) => entry.body);
17
26
  }
18
27
 
19
28
  full(entry) {
20
29
  return entry.body;
21
30
  }
22
31
 
23
- static async activate(
24
- db,
25
- knownStore,
26
- projectId,
27
- pattern,
28
- visibility = "active",
29
- ) {
32
+ summary() {
33
+ return "";
34
+ }
35
+
36
+ /**
37
+ * Set a project-level file constraint. Backbone operation —
38
+ * constraints are project config, not tool dispatch.
39
+ */
40
+ static async setConstraint(db, projectId, pattern, visibility = "active") {
30
41
  const path = await normalizePath(db, projectId, pattern);
31
- if (!path) return { status: "ok" };
42
+ if (!path) return null;
32
43
 
33
44
  await db.upsert_file_constraint.run({
34
45
  project_id: projectId,
@@ -36,34 +47,22 @@ export default class File {
36
47
  visibility,
37
48
  });
38
49
 
39
- const runs = await db.get_all_runs.all({ project_id: projectId });
40
- if (visibility === "active") {
41
- for (const run of runs) {
42
- await knownStore.promoteByPattern(run.id, path, null, 0);
43
- }
44
- } else if (visibility === "ignore") {
45
- for (const run of runs) {
46
- await knownStore.demoteByPattern(run.id, path, null);
47
- }
48
- }
49
-
50
- return { status: "ok" };
51
- }
52
-
53
- static async ignore(db, knownStore, projectId, pattern) {
54
- return File.activate(db, knownStore, projectId, pattern, "ignore");
50
+ return path;
55
51
  }
56
52
 
57
- static async drop(db, projectId, pattern) {
53
+ /**
54
+ * Remove a project-level file constraint.
55
+ */
56
+ static async dropConstraint(db, projectId, pattern) {
58
57
  const path = await normalizePath(db, projectId, pattern);
59
- if (!path) return { status: "ok" };
58
+ if (!path) return null;
60
59
 
61
60
  await db.delete_file_constraint.run({
62
61
  project_id: projectId,
63
62
  pattern: path,
64
63
  });
65
64
 
66
- return { status: "ok" };
65
+ return path;
67
66
  }
68
67
  }
69
68
 
@@ -5,8 +5,7 @@ Retrieves and promotes entries by path or glob pattern.
5
5
  ## Registration
6
6
 
7
7
  - **Tool**: `get`
8
- - **Modes**: ask, act
9
- - **Category**: ask
8
+ - **Category**: `logging`
10
9
  - **Handler**: Fetches matching entries via `getEntriesByPattern`, promotes them with `promoteByPattern`, and records the result.
11
10
 
12
11
  ## Projection
@@ -17,3 +16,4 @@ Shows `get {path}` followed by the entry body.
17
16
 
18
17
  - Pattern queries (globs or body filters) produce a summary of matched paths.
19
18
  - Exact path queries report the path and token count, or "not found".
19
+ - Budget check: rejects with 413 if incoming tokens exceed remaining context.
@@ -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) {
@@ -35,6 +35,7 @@ export default class Get {
35
35
  normalized,
36
36
  bodyFilter,
37
37
  );
38
+
38
39
  await store.promoteByPattern(runId, normalized, bodyFilter, turn);
39
40
 
40
41
  if (isPattern) {
@@ -0,0 +1,41 @@
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>TODO</get>',
19
+ "Full pattern: recursive glob + preview + content filter. Shows all 3 features at once.",
20
+ ],
21
+
22
+ // --- Constraints: RFC-style. Each prevents a specific failure mode.
23
+ [
24
+ "* Paths accept globs: `src/**/*.js`, `known://api_*`",
25
+ "Reinforces picomatch patterns work everywhere, not just in examples.",
26
+ ],
27
+ [
28
+ "* `preview` shows matches without loading into context",
29
+ "Budget-awareness. Without this, models load everything and blow context.",
30
+ ],
31
+ [
32
+ "* Body text filters results by content match",
33
+ "Generalizes examples 2-3. Body = filter, not just path.",
34
+ ],
35
+ [
36
+ '* Use <set path="..." fidelity="index"/> to archive loaded content',
37
+ "Lifecycle: get→set. Load, read, archive. Prevents context hoarding.",
38
+ ],
39
+ ];
40
+
41
+ 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
  };
@@ -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,16 @@
1
- You are an assistant. You gather information, then either answer questions or take action.
1
+ You are an assistant. YOU MUST gather information, then YOU MAY either answer questions 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
+ Optional: YOU MAY think in an optional <think></think> tag before using any other Tool Commands.
7
+ Required: YOU MUST register all unknowns with <unknown>(specific thing I need to learn)</unknown>.
8
+ Required: YOU MUST register all new information, decisions, and plans with <known>(specific information, ideas, or plans)</known>.
9
+ Required: YOU MUST conclude every turn with EITHER <update/> if still working OR <summarize/> if done. Never both.
10
+ Required: Path and summary information is approximate. YOU MUST use <get> to verify before acting on summarized content.
11
+ Info: When information conflicts, later turns are more likely to be relevant and correct than earlier turns.
12
+ Info: Your context is limited but your storage is not. Organize and categorize your information, ideas, plans, and history to optimize your context.
8
13
 
9
14
  # Tool Commands
10
15
 
11
16
  Tools: [%TOOLS%]
12
- Required: Either `<update/>` if still working or `<summarize/>` if done. Never both.
@@ -1,18 +1,21 @@
1
1
  # known
2
2
 
3
- Writes arbitrary key/value entries into the store at full fidelity.
3
+ Writes knowledge entries into the store at full fidelity.
4
4
 
5
5
  ## Registration
6
6
 
7
7
  - **Tool**: `known`
8
- - **Modes**: ask, act
9
- - **Category**: act
10
- - **Handler**: Upserts the entry body at the target path with `full` state.
8
+ - **Category**: `data`
9
+ - **Handler**: Upserts the entry body at the target path with status 200.
10
+ - **Filter**: `assembly.system` at priority 100 renders `<knowns>` section.
11
11
 
12
12
  ## Projection
13
13
 
14
- Shows `known {path}` followed by the entry body.
14
+ Shows `# known {path}` followed by the entry body.
15
15
 
16
- ## Behavior
16
+ ## Assembly
17
17
 
18
- The target path defaults to `entry.resultPath` but can be overridden via `attrs.path`. Used by the model to persist structured notes and context.
18
+ Filters turn_context rows where `category === "data"`. Renders all
19
+ data entries (files, knowledge, skills, URLs) into the `<knowns>` section
20
+ of the system message. Third-party plugins that register with
21
+ `category: "data"` automatically appear here.