@possumtech/rummy 0.3.1 → 0.5.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 (63) hide show
  1. package/.env.example +12 -0
  2. package/FIDELITY_CONTRACT.md +172 -0
  3. package/README.md +5 -1
  4. package/SPEC.md +31 -17
  5. package/migrations/001_initial_schema.sql +3 -4
  6. package/package.json +1 -1
  7. package/src/agent/AgentLoop.js +51 -153
  8. package/src/agent/ContextAssembler.js +2 -0
  9. package/src/agent/KnownStore.js +16 -9
  10. package/src/agent/ResponseHealer.js +54 -1
  11. package/src/agent/TurnExecutor.js +125 -323
  12. package/src/agent/XmlParser.js +172 -42
  13. package/src/agent/known_queries.sql +1 -1
  14. package/src/agent/known_store.sql +29 -72
  15. package/src/agent/runs.sql +2 -2
  16. package/src/hooks/Hooks.js +1 -0
  17. package/src/hooks/PluginContext.js +8 -2
  18. package/src/hooks/RummyContext.js +6 -3
  19. package/src/hooks/ToolRegistry.js +29 -32
  20. package/src/plugins/ask_user/ask_user.js +2 -2
  21. package/src/plugins/ask_user/ask_userDoc.js +7 -10
  22. package/src/plugins/budget/README.md +28 -18
  23. package/src/plugins/budget/budget.js +80 -3
  24. package/src/plugins/budget/recovery.js +47 -0
  25. package/src/plugins/cp/cp.js +5 -5
  26. package/src/plugins/cp/cpDoc.js +1 -14
  27. package/src/plugins/engine/engine.sql +1 -1
  28. package/src/plugins/env/env.js +4 -4
  29. package/src/plugins/env/envDoc.js +4 -9
  30. package/src/plugins/file/file.js +2 -7
  31. package/src/plugins/get/get.js +32 -13
  32. package/src/plugins/get/getDoc.js +26 -44
  33. package/src/plugins/helpers.js +4 -4
  34. package/src/plugins/instructions/instructions.js +9 -7
  35. package/src/plugins/instructions/preamble.md +45 -26
  36. package/src/plugins/known/known.js +71 -15
  37. package/src/plugins/known/knownDoc.js +4 -20
  38. package/src/plugins/mv/mv.js +6 -6
  39. package/src/plugins/mv/mvDoc.js +4 -30
  40. package/src/plugins/policy/policy.js +47 -0
  41. package/src/plugins/previous/previous.js +10 -14
  42. package/src/plugins/progress/progress.js +29 -48
  43. package/src/plugins/prompt/prompt.js +18 -6
  44. package/src/plugins/rm/rm.js +4 -4
  45. package/src/plugins/rm/rmDoc.js +5 -14
  46. package/src/plugins/rpc/rpc.js +4 -2
  47. package/src/plugins/set/set.js +86 -91
  48. package/src/plugins/set/setDoc.js +28 -41
  49. package/src/plugins/sh/sh.js +4 -4
  50. package/src/plugins/sh/shDoc.js +4 -9
  51. package/src/plugins/skill/skill.js +2 -1
  52. package/src/plugins/summarize/summarize.js +9 -2
  53. package/src/plugins/summarize/summarizeDoc.js +10 -16
  54. package/src/plugins/telemetry/telemetry.js +36 -11
  55. package/src/plugins/think/think.js +13 -0
  56. package/src/plugins/think/thinkDoc.js +16 -0
  57. package/src/plugins/unknown/unknown.js +37 -9
  58. package/src/plugins/unknown/unknownDoc.js +7 -16
  59. package/src/plugins/update/update.js +9 -2
  60. package/src/plugins/update/updateDoc.js +12 -14
  61. package/src/server/ClientConnection.js +11 -1
  62. package/src/sql/functions/slugify.js +13 -1
  63. package/src/sql/v_model_context.sql +6 -6
@@ -1,5 +1,3 @@
1
- import docs from "./unknownDoc.js";
2
-
3
1
  export default class Unknown {
4
2
  #core;
5
3
 
@@ -9,16 +7,43 @@ export default class Unknown {
9
7
  core.registerScheme({
10
8
  category: "unknown",
11
9
  });
12
- core.on("full", this.full.bind(this));
10
+ core.on("handler", this.handler.bind(this));
11
+ core.on("promoted", this.full.bind(this));
12
+ core.on("demoted", this.summary.bind(this));
13
13
  core.filter("assembly.system", this.assembleUnknowns.bind(this), 300);
14
- core.filter("instructions.toolDocs", async (docsMap) => {
15
- docsMap.unknown = docs;
16
- return docsMap;
17
- });
14
+ // <unknown> is internal — written via <set path="unknown://...">. Hidden
15
+ // from all model-facing tool lists. Handler still dispatches if the
16
+ // model emits <unknown> directly out of habit.
17
+ core.markHidden();
18
+ }
19
+
20
+ async handler(entry, rummy) {
21
+ const { entries: store, sequence: turn, runId, loopId } = rummy;
22
+
23
+ // Deduplicate — if this exact body already exists, skip
24
+ const existingValues = await store.getUnknownValues(runId);
25
+ if (existingValues.has(entry.body)) {
26
+ console.warn(`[RUMMY] Unknown deduped: "${entry.body.slice(0, 60)}"`);
27
+ return;
28
+ }
29
+
30
+ // Generate slug path and upsert. Summary (if provided) becomes the
31
+ // path so the model can round-trip it via <get>; body is the fallback.
32
+ const unknownPath = await store.slugPath(
33
+ runId,
34
+ "unknown",
35
+ entry.body,
36
+ entry.attributes?.summary,
37
+ );
38
+ await store.upsert(runId, turn, unknownPath, entry.body, 200, { loopId });
18
39
  }
19
40
 
20
41
  full(entry) {
21
- return `# unknown\n${entry.body}`;
42
+ return entry.body;
43
+ }
44
+
45
+ summary() {
46
+ return "";
22
47
  }
23
48
 
24
49
  async assembleUnknowns(content, ctx) {
@@ -28,7 +53,10 @@ export default class Unknown {
28
53
  const lines = entries.map((u) => {
29
54
  const fidelity = u.fidelity ? ` fidelity="${u.fidelity}"` : "";
30
55
  const tokens = u.tokens ? ` tokens="${u.tokens}"` : "";
31
- return `<unknown path="${u.path}" turn="${u.source_turn || u.turn}"${fidelity}${tokens}>${u.body}</unknown>`;
56
+ if (u.body) {
57
+ return `<unknown path="${u.path}" turn="${u.source_turn || u.turn}"${fidelity}${tokens}>${u.body}</unknown>`;
58
+ }
59
+ return `<unknown path="${u.path}" turn="${u.source_turn || u.turn}"${fidelity}${tokens}/>`;
32
60
  });
33
61
  return `${content}\n\n<unknowns>\n${lines.join("\n")}\n</unknowns>`;
34
62
  }
@@ -2,29 +2,20 @@
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: body = what you need to learn
6
5
  [
7
- `## <unknown>[specific thing I need to learn]</unknown> - Track open questions`,
8
- ],
9
-
10
- // --- Examples: concrete unknowns, not abstract
11
- [
12
- `Example: <unknown path="unknown://answer">contents of answer.txt</unknown>`,
13
- `Specific and actionable. Shows that unknowns are concrete investigation targets.`,
6
+ "## <unknown>[specific thing I need to learn]</unknown> - Register gaps for research",
14
7
  ],
15
8
  [
16
- `Example: <unknown>which database adapter is configured</unknown>`,
17
- `Domain question. Shows unknowns for configuration/architecture questions.`,
9
+ 'Example: <unknown path="unknown://answer">contents of answer.txt</unknown>',
10
+ "Path form: explicit unknown path for structured tracking.",
18
11
  ],
19
-
20
- // --- Lifecycle: register → investigate → resolve
21
12
  [
22
- `* Investigate with Tool Commands`,
23
- `Cross-tool lifecycle: unknowns drive get/env/ask_user actions.`,
13
+ "* Investigate with Tool Commands",
14
+ "Unknowns drive action get, env, search, ask_user.",
24
15
  ],
25
16
  [
26
- `* When resolved or irrelevant, remove with <rm path="unknown://..."/>`,
27
- `Cross-tool lifecycle: rm cleans resolved unknowns from context.`,
17
+ '* When resolved or irrelevant, remove with <set path="unknown://..." fidelity="archived"/>',
18
+ "Archive instead of delete preserves the question for context history.",
28
19
  ],
29
20
  ];
30
21
 
@@ -7,14 +7,21 @@ export default class Update {
7
7
  this.#core = core;
8
8
  core.ensureTool();
9
9
  core.registerScheme({ category: "logging" });
10
- core.on("full", this.full.bind(this));
11
- core.on("summary", this.summary.bind(this));
10
+ core.on("handler", this.handler.bind(this));
11
+ core.on("promoted", this.full.bind(this));
12
+ core.on("demoted", this.summary.bind(this));
12
13
  core.filter("instructions.toolDocs", async (docsMap) => {
13
14
  docsMap.update = docs;
14
15
  return docsMap;
15
16
  });
16
17
  }
17
18
 
19
+ async handler(entry, rummy) {
20
+ const { entries: store, sequence: turn, runId, loopId } = rummy;
21
+ const statusPath = await store.slugPath(runId, "update", entry.body);
22
+ await store.upsert(runId, turn, statusPath, entry.body, 200, { loopId });
23
+ }
24
+
18
25
  full(entry) {
19
26
  return `# update\n${entry.body}`;
20
27
  }
@@ -2,31 +2,29 @@
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
6
- ["## <update>[brief status]</update> - Signal continuation"],
7
-
8
- // --- Examples: research progress and multi-step work
5
+ [
6
+ "## <update>[brief status]</update> - Heartbeat for ongoing work (one per turn, at the end)",
7
+ "Header defines position and frequency. Without this, model uses update as inline narration between tools — multiple updates per turn.",
8
+ ],
9
9
  [
10
10
  "Example: <update>Reading config files</update>",
11
- "Progress checkpoint. Shows update as a status signal, not a log entry.",
11
+ "Progress checkpoint. Status signal, not a log entry.",
12
12
  ],
13
13
  [
14
14
  "Example: <update>Found 3 issues, fixing first</update>",
15
- "Multi-step progress. Shows update for ongoing work.",
15
+ "Multi-step progress. Ongoing work.",
16
16
  ],
17
-
18
- // --- Constraints: RFC-style MUST/MUST NOT
19
17
  [
20
- "* YOU MUST use <update> if still working describes the current state",
21
- "Continuation signal. Triggers the next turn in the loop.",
18
+ "* Urgent: ONE <update></update> per turn, AT THE END. Not inline narration between tools.",
19
+ "Single-update-per-turn is the missing rule. Model was emitting 3-6 updates per turn as progress commentary.",
22
20
  ],
23
21
  [
24
- "* YOU MUST NOT use <update> if done use <summarize/> instead",
25
- "Mutual exclusion with summarize. Prevents infinite loops.",
22
+ "* If you'd repeat the same <update></update> as last turn, the work is either stuck or done. Take a different action or <summarize></summarize>.",
23
+ "Points at the zombie-loop failure mode directly. Gives the model a trigger (same-text-as-prior-update) and two remedies.",
26
24
  ],
27
25
  [
28
- "* YOU MUST keep <update> to <= 80 characters",
29
- "Length cap. Prevents models from writing essays in status updates.",
26
+ "* YOU MUST keep <update></update> to <= 80 characters",
27
+ "Length cap.",
30
28
  ],
31
29
  ];
32
30
 
@@ -36,6 +36,15 @@ export default class ClientConnection {
36
36
  }
37
37
  };
38
38
 
39
+ #onProposal = (payload) => {
40
+ if (payload.projectId === this.#context.projectId) {
41
+ this.#sendNotification("run/proposal", {
42
+ run: payload.run,
43
+ proposed: payload.proposed,
44
+ });
45
+ }
46
+ };
47
+
39
48
  #onRender = (payload) => {
40
49
  if (payload.projectId === this.#context.projectId) {
41
50
  this.#sendNotification("ui/render", {
@@ -63,7 +72,6 @@ export default class ClientConnection {
63
72
  summary: payload.summary,
64
73
  history: payload.history,
65
74
  unknowns: payload.unknowns,
66
- proposed: payload.proposed,
67
75
  telemetry: payload.telemetry,
68
76
  });
69
77
  }
@@ -71,6 +79,7 @@ export default class ClientConnection {
71
79
 
72
80
  #setupNotifications() {
73
81
  this.#hooks.run.progress.on(this.#onProgress);
82
+ this.#hooks.turn.proposal.on(this.#onProposal);
74
83
  this.#hooks.ui.render.on(this.#onRender);
75
84
  this.#hooks.ui.notify.on(this.#onNotify);
76
85
  this.#hooks.run.state.on(this.#onState);
@@ -78,6 +87,7 @@ export default class ClientConnection {
78
87
 
79
88
  #teardown() {
80
89
  this.#hooks.run.progress.off(this.#onProgress);
90
+ this.#hooks.turn.proposal.off(this.#onProposal);
81
91
  this.#hooks.ui.render.off(this.#onRender);
82
92
  this.#hooks.ui.notify.off(this.#onNotify);
83
93
  this.#hooks.run.state.off(this.#onState);
@@ -1,6 +1,18 @@
1
1
  export const deterministic = true;
2
2
 
3
+ // Build URI paths the model can round-trip:
4
+ // "history,mongol,khan" → "history/mongol/khan" (commas become path separators)
5
+ // "contents of Document 1" → "contents_of_Document_1" (spaces become underscores)
6
+ // Slice on decoded text, then split-encode-join per segment so / survives as
7
+ // a separator while anything URL-unsafe inside a segment gets escaped.
3
8
  export default function slugify(text) {
4
9
  if (!text) return "";
5
- return encodeURIComponent(text).slice(0, 80);
10
+ return text
11
+ .slice(0, 80)
12
+ .replace(/,/g, "/")
13
+ .replace(/ /g, "_")
14
+ .split("/")
15
+ .filter(Boolean)
16
+ .map(encodeURIComponent)
17
+ .join("/");
6
18
  }
@@ -13,11 +13,11 @@ visible AS (
13
13
  , ke.turn
14
14
  , ke.updated_at
15
15
  , ke.attributes
16
- , ke.tokens AS tokens_full
16
+ , ke.tokens
17
17
  , COALESCE(s.category, 'logging') AS category
18
18
  , CASE
19
19
  -- Archived entries not in context
20
- WHEN ke.fidelity = 'archive' THEN NULL
20
+ WHEN ke.fidelity = 'archived' THEN NULL
21
21
  -- 202 Accepted (proposed) hidden until resolved
22
22
  WHEN ke.status = 202 THEN NULL
23
23
  -- Audit schemes (model_visible = 0) hidden
@@ -41,8 +41,9 @@ projected AS (
41
41
  , attributes
42
42
  -- Category comes from schemes table — plugins declare it via registerScheme().
43
43
  , category
44
+ , tokens
44
45
  , CASE
45
- WHEN visible_fidelity IN ('full', 'summary') THEN body
46
+ WHEN visible_fidelity IN ('promoted', 'demoted') THEN body
46
47
  ELSE ''
47
48
  END AS body
48
49
  FROM visible
@@ -71,9 +72,8 @@ SELECT
71
72
  END
72
73
  , CASE scheme WHEN 'skill' THEN 0 ELSE 1 END
73
74
  , CASE fidelity
74
- WHEN 'index' THEN 0
75
- WHEN 'summary' THEN 1
76
- ELSE 2
75
+ WHEN 'demoted' THEN 0
76
+ ELSE 1
77
77
  END
78
78
  , turn
79
79
  , updated_at