@possumtech/rummy 0.3.0 → 0.4.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 (65) hide show
  1. package/.env.example +13 -1
  2. package/PLUGINS.md +1 -1
  3. package/README.md +5 -1
  4. package/SPEC.md +211 -54
  5. package/migrations/001_initial_schema.sql +3 -4
  6. package/package.json +7 -3
  7. package/service.js +5 -3
  8. package/src/agent/AgentLoop.js +183 -238
  9. package/src/agent/ContextAssembler.js +2 -0
  10. package/src/agent/KnownStore.js +36 -85
  11. package/src/agent/ResponseHealer.js +65 -31
  12. package/src/agent/TurnExecutor.js +284 -382
  13. package/src/agent/XmlParser.js +28 -4
  14. package/src/agent/known_queries.sql +1 -1
  15. package/src/agent/known_store.sql +32 -34
  16. package/src/agent/runs.sql +2 -2
  17. package/src/agent/tokens.js +1 -0
  18. package/src/agent/turns.sql +5 -0
  19. package/src/hooks/HookRegistry.js +7 -0
  20. package/src/hooks/Hooks.js +2 -4
  21. package/src/hooks/ToolRegistry.js +8 -13
  22. package/src/plugins/ask_user/ask_userDoc.js +3 -8
  23. package/src/plugins/budget/README.md +26 -30
  24. package/src/plugins/budget/budget.js +69 -36
  25. package/src/plugins/budget/recovery.js +47 -0
  26. package/src/plugins/cp/cp.js +1 -1
  27. package/src/plugins/cp/cpDoc.js +5 -10
  28. package/src/plugins/env/envDoc.js +3 -8
  29. package/src/plugins/get/get.js +70 -2
  30. package/src/plugins/get/getDoc.js +19 -16
  31. package/src/plugins/hedberg/matcher.js +10 -29
  32. package/src/plugins/helpers.js +2 -2
  33. package/src/plugins/instructions/instructions.js +3 -2
  34. package/src/plugins/instructions/preamble.md +33 -12
  35. package/src/plugins/known/known.js +66 -17
  36. package/src/plugins/known/knownDoc.js +7 -10
  37. package/src/plugins/mv/mv.js +18 -1
  38. package/src/plugins/mv/mvDoc.js +9 -10
  39. package/src/plugins/{current → performed}/README.md +4 -3
  40. package/src/plugins/{current/current.js → performed/performed.js} +15 -20
  41. package/src/plugins/policy/policy.js +47 -0
  42. package/src/plugins/previous/README.md +2 -1
  43. package/src/plugins/previous/previous.js +31 -25
  44. package/src/plugins/progress/README.md +1 -2
  45. package/src/plugins/progress/progress.js +10 -60
  46. package/src/plugins/prompt/prompt.js +10 -8
  47. package/src/plugins/rm/rm.js +27 -15
  48. package/src/plugins/rm/rmDoc.js +6 -11
  49. package/src/plugins/rpc/rpc.js +3 -1
  50. package/src/plugins/set/set.js +125 -92
  51. package/src/plugins/set/setDoc.js +28 -37
  52. package/src/plugins/sh/shDoc.js +2 -7
  53. package/src/plugins/summarize/summarize.js +7 -0
  54. package/src/plugins/summarize/summarizeDoc.js +6 -11
  55. package/src/plugins/telemetry/telemetry.js +14 -9
  56. package/src/plugins/think/think.js +12 -0
  57. package/src/plugins/think/thinkDoc.js +18 -0
  58. package/src/plugins/unknown/README.md +2 -1
  59. package/src/plugins/unknown/unknown.js +26 -4
  60. package/src/plugins/unknown/unknownDoc.js +9 -14
  61. package/src/plugins/update/update.js +7 -0
  62. package/src/plugins/update/updateDoc.js +6 -11
  63. package/src/server/ClientConnection.js +69 -45
  64. package/src/sql/v_model_context.sql +7 -17
  65. package/src/plugins/budget/BudgetGuard.js +0 -74
@@ -1,25 +1,26 @@
1
- export default class Current {
1
+ export default class Performed {
2
2
  #core;
3
3
 
4
4
  constructor(core) {
5
5
  this.#core = core;
6
- core.filter("assembly.user", this.assembleCurrent.bind(this), 100);
6
+ core.filter("assembly.user", this.assemblePerformed.bind(this), 100);
7
7
  }
8
8
 
9
- async assembleCurrent(content, ctx) {
9
+ async assemblePerformed(content, ctx) {
10
10
  const entries = ctx.rows.filter(
11
- (r) => r.category === "logging" && r.source_turn >= ctx.loopStartTurn,
11
+ (r) =>
12
+ r.category === "logging" &&
13
+ r.source_turn >= ctx.loopStartTurn &&
14
+ r.scheme !== "unknown",
12
15
  );
13
16
  if (entries.length === 0) return content;
14
17
 
15
- const lines = await Promise.all(
16
- entries.map((e) => renderToolTag(e, this.#core)),
17
- );
18
- return `${content}<current>\n${lines.join("\n")}\n</current>\n`;
18
+ const lines = entries.map((e) => renderToolTag(e));
19
+ return `${content}<performed>\n${lines.join("\n")}\n</performed>\n`;
19
20
  }
20
21
  }
21
22
 
22
- async function renderToolTag(entry, core) {
23
+ function renderToolTag(entry) {
23
24
  const attrs =
24
25
  typeof entry.attributes === "string"
25
26
  ? JSON.parse(entry.attributes)
@@ -28,23 +29,17 @@ async function renderToolTag(entry, core) {
28
29
  const target = attrs?.path || attrs?.file || attrs?.command || "";
29
30
  const turn = entry.source_turn ? ` turn="${entry.source_turn}"` : "";
30
31
  const status = entry.status ? ` status="${entry.status}"` : "";
32
+ const fidelity = entry.fidelity ? ` fidelity="${entry.fidelity}"` : "";
33
+ const tokens = entry.tokens ? ` tokens="${entry.tokens}"` : "";
31
34
  const summary =
32
35
  typeof attrs?.summary === "string"
33
36
  ? ` summary="${attrs.summary.slice(0, 80)}"`
34
37
  : "";
35
38
 
36
- let body;
37
- try {
38
- body = await core.hooks.tools.view(entry.scheme, {
39
- ...entry,
40
- attributes: attrs,
41
- });
42
- } catch {
43
- body = entry.body;
44
- }
39
+ const body = entry.body || null;
45
40
 
46
41
  if (body) {
47
- return `<${entry.scheme} path="${target}"${turn}${status}${summary}>${body}</${entry.scheme}>`;
42
+ return `<${entry.scheme} path="${target}"${turn}${status}${summary}${fidelity}${tokens}>${body}</${entry.scheme}>`;
48
43
  }
49
- return `<${entry.scheme} path="${target}"${turn}${status}${summary}/>`;
44
+ return `<${entry.scheme} path="${target}"${turn}${status}${summary}${fidelity}${tokens}/>`;
50
45
  }
@@ -0,0 +1,47 @@
1
+ import KnownStore from "../../agent/KnownStore.js";
2
+
3
+ export default class Policy {
4
+ constructor(core) {
5
+ core.filter("entry.recording", this.#enforceAskMode.bind(this), 1);
6
+ }
7
+
8
+ async #enforceAskMode(entry, ctx) {
9
+ if (ctx.mode !== "ask") return entry;
10
+
11
+ if (entry.scheme === "sh") {
12
+ console.warn("[RUMMY] Rejected <sh> in ask mode");
13
+ return { ...entry, status: 403 };
14
+ }
15
+
16
+ if (entry.scheme === "set" && entry.attributes?.path) {
17
+ const scheme = KnownStore.scheme(entry.attributes.path);
18
+ if (scheme === null && entry.body) {
19
+ console.warn(
20
+ `[RUMMY] Rejected file edit to ${entry.attributes.path} in ask mode`,
21
+ );
22
+ return { ...entry, status: 403 };
23
+ }
24
+ }
25
+
26
+ if (entry.scheme === "rm") {
27
+ const pathAttr = entry.attributes?.path || entry.path;
28
+ const scheme = KnownStore.scheme(pathAttr);
29
+ if (scheme === null) {
30
+ console.warn(`[RUMMY] Rejected file rm of ${pathAttr} in ask mode`);
31
+ return { ...entry, status: 403 };
32
+ }
33
+ }
34
+
35
+ if (entry.scheme === "mv" || entry.scheme === "cp") {
36
+ const destScheme = KnownStore.scheme(entry.attributes?.to);
37
+ if (destScheme === null) {
38
+ console.warn(
39
+ `[RUMMY] Rejected ${entry.scheme} to file ${entry.attributes?.to} in ask mode`,
40
+ );
41
+ return { ...entry, status: 403 };
42
+ }
43
+ }
44
+
45
+ return entry;
46
+ }
47
+ }
@@ -12,4 +12,5 @@ history from prior ask/act invocations on this run.
12
12
 
13
13
  Filters turn_context rows where `category` is `logging` or `prompt`
14
14
  and `source_turn < loopStartTurn`. Renders each entry chronologically
15
- with turn number and status.
15
+ with turn, status, summary, fidelity, and tokens. The model can target
16
+ these entries by path with `<set>` or `<rm>` to free context space.
@@ -9,11 +9,20 @@ export default class Previous {
9
9
  async assemblePrevious(content, ctx) {
10
10
  if (ctx.loopStartTurn <= 1) return content;
11
11
 
12
- const entries = ctx.rows.filter(
13
- (r) =>
14
- (r.category === "logging" || r.category === "prompt") &&
15
- r.source_turn < ctx.loopStartTurn,
16
- );
12
+ const entries = ctx.rows
13
+ .filter(
14
+ (r) =>
15
+ (r.category === "logging" || r.category === "prompt") &&
16
+ r.source_turn < ctx.loopStartTurn,
17
+ )
18
+ .toSorted((a, b) => {
19
+ if (a.source_turn !== b.source_turn)
20
+ return a.source_turn - b.source_turn;
21
+ // Within the same turn: prompt first (cause before effect)
22
+ if (a.category === "prompt" && b.category !== "prompt") return -1;
23
+ if (b.category === "prompt" && a.category !== "prompt") return 1;
24
+ return 0;
25
+ });
17
26
  if (entries.length === 0) return content;
18
27
 
19
28
  const lines = await Promise.all(
@@ -23,7 +32,7 @@ export default class Previous {
23
32
  }
24
33
  }
25
34
 
26
- async function renderToolTag(entry, core) {
35
+ async function renderToolTag(entry, _core) {
27
36
  const attrs =
28
37
  typeof entry.attributes === "string"
29
38
  ? JSON.parse(entry.attributes)
@@ -32,23 +41,20 @@ async function renderToolTag(entry, core) {
32
41
  const target = attrs?.path || attrs?.file || attrs?.command || "";
33
42
  const turn = entry.source_turn ? ` turn="${entry.source_turn}"` : "";
34
43
  const status = entry.status ? ` status="${entry.status}"` : "";
35
- const summary =
36
- typeof attrs?.summary === "string"
37
- ? ` summary="${attrs.summary.slice(0, 80)}"`
38
- : "";
39
-
40
- let body;
41
- try {
42
- body = await core.hooks.tools.view(entry.scheme, {
43
- ...entry,
44
- attributes: attrs,
45
- });
46
- } catch {
47
- body = entry.body;
48
- }
49
-
50
- if (body) {
51
- return `<${entry.scheme} path="${target}"${turn}${status}${summary}>${body}</${entry.scheme}>`;
52
- }
53
- return `<${entry.scheme} path="${target}"${turn}${status}${summary}/>`;
44
+ const fidelity = entry.fidelity ? ` fidelity="${entry.fidelity}"` : "";
45
+ const tokens = entry.tokens ? ` tokens="${entry.tokens}"` : "";
46
+
47
+ // Previous entries render at summary. Prompts get 512 chars for orientation.
48
+ const limit = entry.scheme === "prompt" ? 512 : 80;
49
+ const rawSummary =
50
+ (typeof attrs?.summary === "string" ? attrs.summary : null) ||
51
+ entry.body?.slice(0, limit) ||
52
+ "";
53
+ // Strip internal dedup namespace prefixes (e.g. "get://turn_3/src/app.js" → "src/app.js")
54
+ const summaryText = rawSummary.replace(/\b\w+:\/\/turn_\d+\//g, "");
55
+ const summaryAttr = summaryText
56
+ ? ` summary="${summaryText.replace(/"/g, "'").slice(0, limit)}"`
57
+ : "";
58
+
59
+ return `<${entry.scheme} path="${target}"${turn}${status}${summaryAttr}${fidelity}${tokens}/>`;
54
60
  }
@@ -9,9 +9,8 @@ current work log to the active prompt.
9
9
 
10
10
  ## Behavior
11
11
 
12
- On first turn: "Begin."
12
+ Emits `<progress turn="N">` carrying token budget and fidelity stats.
13
13
  On continuation turns with current entries: "The above actions were
14
14
  performed in response to the following prompt:"
15
- If a `progress://` entry exists, uses its body directly.
16
15
 
17
16
  Progress text is the tuning knob for model orientation between turns.
@@ -7,70 +7,20 @@ export default class Progress {
7
7
  }
8
8
 
9
9
  async assembleProgress(content, ctx) {
10
- // Use last turn's real assembled token count when available.
11
- // Falls back to row token sum (less accurate — missing system prompt overhead).
12
- const rowTokens = ctx.rows.reduce((sum, r) => sum + (r.tokens || 0), 0);
13
- const usedTokens = ctx.lastContextTokens || rowTokens;
14
- const contextSize = ctx.contextSize || 0;
10
+ const { lastContextTokens: usedTokens, contextSize } = ctx;
15
11
  const pct = contextSize ? Math.round((usedTokens / contextSize) * 100) : 0;
16
12
 
17
- // Fidelity distribution across known/file entries
18
- const entries = ctx.rows.filter((r) => r.category === "data");
19
- const fullEntries = entries.filter((r) => r.fidelity === "full");
20
- const summaryEntries = entries.filter((r) => r.fidelity === "summary");
21
- const indexEntries = entries.filter((r) => r.fidelity === "index");
22
- const fullTokens = fullEntries.reduce((s, r) => s + (r.tokens || 0), 0);
23
- const summaryTokens = summaryEntries.reduce(
24
- (s, r) => s + (r.tokens || 0),
25
- 0,
26
- );
27
- const indexTokens = indexEntries.reduce((s, r) => s + (r.tokens || 0), 0);
28
-
29
- const unknownCount = ctx.rows.filter(
30
- (r) => r.category === "unknown",
31
- ).length;
32
-
33
- const hasCurrent = ctx.rows.some(
34
- (r) => r.category === "logging" && r.source_turn >= ctx.loopStartTurn,
35
- );
36
-
37
- const parts = [];
38
-
39
- const knownCount = entries.length;
40
- const tokenLine = contextSize
41
- ? `${usedTokens} of ${contextSize} tokens (${pct}%) · ${knownCount} known${knownCount !== 1 ? "s" : ""} · ${unknownCount} unknown${unknownCount !== 1 ? "s" : ""}`
42
- : "";
43
- if (tokenLine) parts.push(tokenLine);
44
-
45
- // Fidelity distribution
46
- const fidelityParts = [];
47
- if (fullEntries.length > 0)
48
- fidelityParts.push(`${fullEntries.length} full (${fullTokens} tok)`);
49
- if (summaryEntries.length > 0)
50
- fidelityParts.push(
51
- `${summaryEntries.length} summary (${summaryTokens} tok)`,
52
- );
53
- if (indexEntries.length > 0)
54
- fidelityParts.push(`${indexEntries.length} index (${indexTokens} tok)`);
55
- if (fidelityParts.length > 0)
56
- parts.push(`Entries: ${fidelityParts.join(" · ")}`);
57
-
58
- if (pct > 75) {
59
- parts.push(
60
- 'Context above 75%. YOU MUST free space: <set fidelity="summary" summary="topic,detail,keyword"/>, <set fidelity="archive"/>, or <rm/>. Target the largest entries.',
61
- );
62
- } else if (pct > 50) {
63
- parts.push(
64
- 'Context above 50%. You may free space: <set fidelity="summary" summary="topic,detail,keyword"/>, <set fidelity="archive"/>, or <rm/>.',
65
- );
66
- }
67
-
68
- if (hasCurrent) {
69
- parts.push(
70
- "The above actions were performed in response to the following prompt:",
13
+ const lines = [];
14
+ if (contextSize) {
15
+ lines.push(
16
+ `Using ${usedTokens} tokens (${pct}%) of ${contextSize} token budget. Use <get/> or set entry fidelity to "full" to spend tokens. Set entry fidelity to "summary" to save tokens.`,
71
17
  );
72
18
  }
19
+ lines.push(
20
+ 'Conclude with a brief <update></update> to continue or a brief <summarize></summarize> if done.',
21
+ );
22
+ const body = lines.join("\n");
73
23
 
74
- return `${content}<progress>${parts.join("\n")}</progress>\n`;
24
+ return `${content}<progress turn="${ctx.turn}">${body}</progress>\n`;
75
25
  }
76
26
  }
@@ -3,8 +3,16 @@ export default class Prompt {
3
3
 
4
4
  constructor(core) {
5
5
  this.#core = core;
6
- core.hooks.tools.onView("prompt", (entry) => entry.body);
7
- core.hooks.tools.onView("progress", (entry) => entry.body);
6
+ core.hooks.tools.onView("prompt", (entry) => {
7
+ if (entry.fidelity === "summary") {
8
+ const limit = 500;
9
+ const text = entry.body?.slice(0, limit) || "";
10
+ return text.length < (entry.body?.length || 0)
11
+ ? `${text}\n[truncated — promote to full to see the complete prompt]`
12
+ : text;
13
+ }
14
+ return entry.body;
15
+ });
8
16
  core.on("turn.started", this.onTurnStarted.bind(this));
9
17
  core.filter("assembly.user", this.assemblePrompt.bind(this), 300);
10
18
  }
@@ -17,10 +25,6 @@ export default class Prompt {
17
25
  attributes: { mode },
18
26
  loopId,
19
27
  });
20
- } else {
21
- await store.upsert(runId, turn, `progress://${turn}`, prompt || "", 200, {
22
- loopId,
23
- });
24
28
  }
25
29
  }
26
30
 
@@ -41,8 +45,6 @@ export default class Prompt {
41
45
  const tools = toolNames.join(",");
42
46
  let warn = "";
43
47
  if (mode === "ask") warn = ' warn="File editing disallowed."';
44
- if (mode === "panic")
45
- warn = ' warn="Context overflow. Free space to continue."';
46
48
 
47
49
  return `${content}<prompt mode="${mode}" tools="${tools}"${warn}>${body}</prompt>`;
48
50
  }
@@ -41,25 +41,37 @@ export default class Rm {
41
41
  return;
42
42
  }
43
43
 
44
- for (const match of matches) {
45
- const resultPath = `rm://${match.path}`;
46
- if (match.scheme === null) {
47
- await store.upsert(runId, turn, resultPath, match.path, 202, {
48
- attributes: { path: match.path },
49
- loopId,
50
- });
51
- } else {
52
- await store.remove(runId, match.path);
53
- await store.upsert(runId, turn, resultPath, match.path, 200, {
54
- attributes: { path: match.path },
55
- loopId,
56
- });
57
- }
44
+ const fileMatches = matches.filter((m) => m.scheme === null);
45
+ const schemeMatches = matches.filter((m) => m.scheme !== null);
46
+
47
+ // Scheme entries: remove all, write one aggregate result entry
48
+ for (const match of schemeMatches) await store.remove(runId, match.path);
49
+ if (schemeMatches.length > 0) {
50
+ const paths = schemeMatches.map((m) => m.path).join("\n");
51
+ await store.upsert(runId, turn, entry.resultPath, paths, 200, {
52
+ attributes: { path: target },
53
+ loopId,
54
+ });
55
+ }
56
+
57
+ // File entries: individual 202 proposals (require user resolution)
58
+ if (fileMatches.length > 0 && schemeMatches.length > 0)
59
+ await store.remove(runId, entry.resultPath);
60
+ for (const match of fileMatches) {
61
+ const resultPath =
62
+ schemeMatches.length === 0 && fileMatches.length === 1
63
+ ? entry.resultPath
64
+ : await store.dedup(runId, "rm", match.path, turn);
65
+ await store.upsert(runId, turn, resultPath, match.path, 202, {
66
+ attributes: { path: match.path },
67
+ loopId,
68
+ });
58
69
  }
59
70
  }
60
71
 
61
72
  full(entry) {
62
- return `# rm ${entry.attributes.path || entry.path}`;
73
+ const header = `# rm ${entry.attributes.path || entry.path}`;
74
+ return entry.body ? `${header}\n${entry.body}` : header;
63
75
  }
64
76
 
65
77
  summary(entry) {
@@ -2,28 +2,23 @@
2
2
  // Text goes to the model. Rationale stays in source.
3
3
  // Changing ANY line requires reading ALL rationales first.
4
4
  const LINES = [
5
- // --- Syntax: path attr, self-closing
6
5
  ['## <rm path="[path]"/> - Remove a file or entry'],
7
-
8
- // --- Examples: file, known (with slug path), preview safety
9
6
  ['Example: <rm path="src/config.js"/>', "File removal. Simplest form."],
10
7
  [
11
- 'Example: <rm path="known://donald-rumsfeld-was-born-in-1932"/>',
12
- "Shows the slugified path convention. Model sees these paths in <knowns> section.",
8
+ 'Example: <rm path="known://config/deprecated_service"/>',
9
+ "Shows topic-hierarchy path convention.",
13
10
  ],
14
11
  [
15
12
  'Example: <rm path="known://temp_*" preview/>',
16
- "Preview before deleting. Glob pattern. Safety pattern for bulk operations.",
13
+ "Preview before deleting. Safety pattern for bulk operations.",
17
14
  ],
18
-
19
- // --- Constraints
20
15
  [
21
16
  '* Permanent. Prefer <set fidelity="archive"/> to preserve for later retrieval',
22
- "Nudges toward archive over rm. Archive keeps the key; rm deletes permanently.",
17
+ "Nudges toward archive over rm.",
23
18
  ],
24
19
  [
25
- "* Paths accept globs — use `preview` to check matches first",
26
- "Reinforces preview safety pattern. Prevents accidental bulk deletion.",
20
+ "* Use `preview` to check matches before pattern-based bulk deletion",
21
+ "Reinforces preview safety pattern.",
27
22
  ],
28
23
  ];
29
24
 
@@ -178,7 +178,7 @@ export default class Rpc {
178
178
  scheme: e.scheme,
179
179
  status: e.status,
180
180
  fidelity: e.fidelity,
181
- tokens: e.tokens_full,
181
+ tokens: e.tokens,
182
182
  }));
183
183
  },
184
184
  description: "Query entries by pattern.",
@@ -233,6 +233,7 @@ export default class Rpc {
233
233
  contextLimit: params.contextLimit,
234
234
  noRepo: params.noRepo,
235
235
  noInteraction: params.noInteraction,
236
+ noProposals: params.noProposals,
236
237
  noWeb: params.noWeb,
237
238
  fork: params.fork,
238
239
  },
@@ -269,6 +270,7 @@ export default class Rpc {
269
270
  contextLimit: params.contextLimit,
270
271
  noRepo: params.noRepo,
271
272
  noInteraction: params.noInteraction,
273
+ noProposals: params.noProposals,
272
274
  noWeb: params.noWeb,
273
275
  fork: params.fork,
274
276
  },