@inetafrica/open-claudia 2.6.36 → 2.6.38

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.
@@ -145,7 +145,7 @@ async function run(ctx) {
145
145
  // 1: pre-gate.
146
146
  if (!needsRecall(userText, seedCount)) {
147
147
  metrics.logTurn({ engine: "discoverer", query: userText, gated: true, latencyMs: Date.now() - started });
148
- return { packBlock: "", entityBlock: "", packMatches: [], entityMatches: [] };
148
+ return { packBlock: "", entityBlock: "", packMatches: [], entityMatches: [], why: {}, gated: true };
149
149
  }
150
150
 
151
151
  // 3: spreading activation from seeds across the graph.
@@ -225,7 +225,10 @@ async function run(ctx) {
225
225
  latencyMs: Date.now() - started,
226
226
  });
227
227
 
228
- return { packBlock, entityBlock, packMatches: finalPacks, entityMatches: finalEnts };
228
+ return {
229
+ packBlock, entityBlock, packMatches: finalPacks, entityMatches: finalEnts,
230
+ why: whyById ? Object.fromEntries(whyById) : {}, gated: false,
231
+ };
229
232
  }
230
233
 
231
234
  module.exports = { name: "discoverer", run, needsRecall, walk };
@@ -264,6 +264,7 @@ function parseLinks(text) {
264
264
  }
265
265
 
266
266
  function isSharedConcern(pack) {
267
+ if (pack && pack.kind === "ability") return true;
267
268
  const tags = (pack.tags || []).map((t) => t.toLowerCase());
268
269
  return tags.includes("shared") || tags.includes("concern") || tags.includes("cross-cutting");
269
270
  }
@@ -303,6 +304,22 @@ function syncFromCorpus(packsLib, entitiesLib) {
303
304
  const type = targetPack && isSharedConcern(targetPack) ? "governed-by" : "related";
304
305
  if (addEdge(id, target, type)) count++;
305
306
  }
307
+ // Abilities transfer across projects: derive governed-by edges from the
308
+ // ability's OWN provenance (learned_on + applied_on) so the link forms after
309
+ // the FIRST occurrence, without having to mutate the project packs. Direction
310
+ // matches the [[link]] convention — project (child) → ability (concern).
311
+ if (p.kind === "ability") {
312
+ const projects = new Set(
313
+ [p.learned_on, ...(p.applied_on || [])]
314
+ .map((x) => String(x || "").trim().toLowerCase())
315
+ .filter(Boolean)
316
+ );
317
+ for (const proj of projects) {
318
+ const src = resolve(proj);
319
+ if (!src || src === id) continue;
320
+ if (addEdge(src, id, "governed-by")) count++;
321
+ }
322
+ }
306
323
  }
307
324
  for (const e of entities) {
308
325
  const id = `entity:${e.slug}`;
@@ -4,8 +4,10 @@
4
4
  // Each engine implements: async run(ctx) -> { packBlock, entityBlock,
5
5
  // packMatches, entityMatches }. The active engine is chosen per channel via
6
6
  // the `recallEngine` setting (set by the /engine slash command), falling back
7
- // to the RECALL_ENGINE env var, then to "classic". Unknown names fall back to
8
- // classic so a bad value can never break recall.
7
+ // to the RECALL_ENGINE env var, then to DEFAULT_ENGINE. Unknown names fall
8
+ // back to the default so a bad value can never break recall. The discoverer is
9
+ // the default: it's fail-open (falls back to the classic keyword baseline on
10
+ // any error), so it never recalls worse than classic, only better.
9
11
 
10
12
  const classic = require("./classic");
11
13
  const discoverer = require("./discoverer");
@@ -15,6 +17,11 @@ const ENGINES = {
15
17
  discoverer,
16
18
  };
17
19
 
20
+ // The default recall engine for channels that haven't explicitly chosen one
21
+ // (recallEngine === null). Single source of truth — flip this to change the
22
+ // product default. `classic` remains selectable as an explicit opt-out.
23
+ const DEFAULT_ENGINE = "discoverer";
24
+
18
25
  function listEngines() {
19
26
  return Object.keys(ENGINES);
20
27
  }
@@ -22,12 +29,12 @@ function listEngines() {
22
29
  function activeEngineName(settings) {
23
30
  const fromSettings = settings && settings.recallEngine;
24
31
  const fromEnv = process.env.RECALL_ENGINE;
25
- const name = String(fromSettings || fromEnv || "classic").toLowerCase();
26
- return ENGINES[name] ? name : "classic";
32
+ const name = String(fromSettings || fromEnv || DEFAULT_ENGINE).toLowerCase();
33
+ return ENGINES[name] ? name : DEFAULT_ENGINE;
27
34
  }
28
35
 
29
36
  function getEngine(name) {
30
37
  return ENGINES[name] || classic;
31
38
  }
32
39
 
33
- module.exports = { ENGINES, listEngines, activeEngineName, getEngine };
40
+ module.exports = { ENGINES, listEngines, activeEngineName, getEngine, DEFAULT_ENGINE };
package/core/redact.js CHANGED
@@ -1,15 +1,37 @@
1
1
  // Secret-redaction + terminal-control stripping. Used everywhere we ship
2
2
  // CLI output (stderr, stdout, transcripts) back to a chat surface.
3
3
 
4
+ // Known literal secrets (operational keyring values) registered at startup
5
+ // and whenever one is set. Matched by literal substring, not regex, so a
6
+ // value containing regex metacharacters can't break or widen the match.
7
+ const dynamicSecrets = new Set();
8
+
9
+ // Register one or more secret literals to scrub from all future output.
10
+ // Short values are ignored to avoid redacting common substrings.
11
+ function registerSecrets(values) {
12
+ for (const v of [].concat(values || [])) {
13
+ const s = String(v == null ? "" : v);
14
+ if (s.length >= 6) dynamicSecrets.add(s);
15
+ }
16
+ }
17
+
18
+ function redactDynamic(text) {
19
+ let out = String(text);
20
+ for (const secret of dynamicSecrets) {
21
+ if (out.includes(secret)) out = out.split(secret).join("[REDACTED_SECRET]");
22
+ }
23
+ return out;
24
+ }
25
+
4
26
  function redactSensitive(value) {
5
- return String(value || "")
27
+ return redactDynamic(String(value || "")
6
28
  .replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
7
29
  .replace(/sk-proj-[A-Za-z0-9._-]+/g, "[REDACTED_OPENAI_KEY]")
8
30
  .replace(/sk-[A-Za-z0-9._-]{20,}/g, "[REDACTED_OPENAI_KEY]")
9
31
  .replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
10
32
  .replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
11
33
  .replace(/(OPENAI_API_KEY\s*=\s*)\S+/gi, "$1[REDACTED_OPENAI_KEY]")
12
- .replace(/([?&](?:token|access_token|refresh_token|api_key)=)[^\s&]+/gi, "$1[REDACTED]");
34
+ .replace(/([?&](?:token|access_token|refresh_token|api_key)=)[^\s&]+/gi, "$1[REDACTED]"));
13
35
  }
14
36
 
15
37
  function stripTerminalControls(value) {
@@ -23,4 +45,4 @@ function extractUrls(text) {
23
45
  return [...stripTerminalControls(text).matchAll(/https?:\/\/[^\s)]+/g)].map((m) => m[0]);
24
46
  }
25
47
 
26
- module.exports = { redactSensitive, stripTerminalControls, extractUrls };
48
+ module.exports = { redactSensitive, registerSecrets, stripTerminalControls, extractUrls };
package/core/runner.js CHANGED
@@ -726,6 +726,16 @@ async function compactActiveSession(cwd, opts = {}) {
726
726
 
727
727
  const { fullBrief, condensed } = splitCompactionBrief(summary);
728
728
  const briefPath = archiveCompactionBrief(fullBrief, state);
729
+ // Append the condensed digest to today's per-day seed file so the nightly
730
+ // dream can review what was worked on across the day. Best-effort.
731
+ try {
732
+ require("./day-seeds").appendDaySeed({
733
+ summary: condensed || fullBrief,
734
+ project: state.currentSession ? `${state.currentSession.name} (${state.currentSession.dir})` : null,
735
+ channel: currentChannelId(),
736
+ briefPath,
737
+ });
738
+ } catch (e) {}
729
739
  // Only seed with the condensed version when the full text actually made it to disk.
730
740
  const seedSummary = (condensed && briefPath) ? condensed : (condensed ? `${fullBrief}\n\n${condensed}` : fullBrief);
731
741
  const repoFacts = collectRepoStateFacts(cwd);
@@ -881,7 +891,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
881
891
  // `open-claudia pack show <dir>` / `entity show <slug>` — so the banner
882
892
  // reflects what was read, not what was pushed. (consumeLastInjected is
883
893
  // drained here to keep the per-turn buffer from leaking into the next turn.)
884
- try { require("./system-prompt").consumeLastInjected(); } catch (e) { /* best-effort */ }
894
+ try {
895
+ const injected = require("./system-prompt").consumeLastInjected();
896
+ if (settings.showRecall && injected && injected.recall) {
897
+ const r = injected.recall;
898
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
899
+ const fmt = (arr, icon) => arr.map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
900
+ const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤")];
901
+ if (lines.length) send(`🧠 <b>Recall this turn</b> (${esc(r.engine)})\n${lines.join("\n")}`).catch(() => {});
902
+ else if (r.gated) send(`🧠 <b>Recall</b> (${esc(r.engine)}): skipped by pre-gate — trivial turn.`).catch(() => {});
903
+ }
904
+ } catch (e) { /* best-effort */ }
885
905
  const binaryPath = getActiveBinary();
886
906
  const proc = spawn(binaryPath, args, {
887
907
  cwd,
@@ -1135,10 +1155,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1135
1155
  if (settings.budget) settings.budget = null;
1136
1156
  state.statusMessageId = null;
1137
1157
 
1158
+ // Outcome signal: only learn from turns that actually completed. A turn that
1159
+ // errored out is not evidence that a recalled pattern "helped" — reinforcing
1160
+ // or recording reuse on it would teach the wrong lesson (gap: reinforcement
1161
+ // must track "helped", not merely "opened").
1162
+ const turnSucceeded = (code === 0 || code === null);
1163
+
1138
1164
  // Hebbian co-use: nodes the agent actually opened together this turn get
1139
1165
  // their `related` edges reinforced, so future spreading activation pulls
1140
1166
  // the cluster together. Reinforce on co-USE (📖), never co-recall.
1141
- if (openedThisTurn.size > 0) {
1167
+ if (turnSucceeded && openedThisTurn.size > 0) {
1142
1168
  try {
1143
1169
  const recallGraph = require("./recall/graph");
1144
1170
  if (openedThisTurn.size > 1) recallGraph.reinforceSet([...openedThisTurn]);
@@ -1146,6 +1172,22 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1146
1172
  } catch (e) { /* best-effort */ }
1147
1173
  }
1148
1174
 
1175
+ // Close the learning loop: when an ABILITY pack is opened in the same turn
1176
+ // as a project (context) pack, the ability was demonstrably applied while
1177
+ // working on that project. recordCoUse grows the ability's applied_on, which
1178
+ // forms the project→ability governed-by edge on the next structural sync —
1179
+ // so reuse transfers AUTOMATICALLY from actual use, without waiting on the
1180
+ // reviewer to infer it. This runs before the (async) reviewer, so it wins
1181
+ // the race and the reviewer sees applied_on already set (no double-announce).
1182
+ if (turnSucceeded && openedThisTurn.size > 1) {
1183
+ try {
1184
+ for (const t of packsLib.recordCoUse([...openedThisTurn])) {
1185
+ notifySkill(`applied:${t.ability}:${t.project}`,
1186
+ `🧩 Reused the "${t.abilityName}" ability on ${t.projectName} — it transfers there now too.`);
1187
+ }
1188
+ } catch (e) { /* best-effort */ }
1189
+ }
1190
+
1149
1191
  // Post-turn pack review: fire-and-forget on a cheap model; never
1150
1192
  // blocks queue drain or the next turn.
1151
1193
  if ((code === 0 || code === null) && assistantText.trim()) {
package/core/subagent.js CHANGED
@@ -55,14 +55,30 @@ async function spawnSubagent(prompt, opts = {}) {
55
55
  }
56
56
 
57
57
  return new Promise((resolve, reject) => {
58
- const args = [
59
- "-p",
58
+ const args = ["-p"];
59
+ // Tool restriction. --allowedTools/--disallowedTools take variadic
60
+ // <tools...>, which would greedily swallow the trailing prompt arg — so we
61
+ // pass a single comma-joined token AND place these flags first, where the
62
+ // next arg (--output-format) is itself a flag, which stops the variadic.
63
+ // With --dangerously-skip-permissions on, a whitelist is the structural way
64
+ // to make a sub-agent genuinely read-only (e.g. the dream's introspection).
65
+ if (opts.allowedTools) args.push("--allowedTools", [].concat(opts.allowedTools).join(","));
66
+ if (opts.disallowedTools) args.push("--disallowedTools", [].concat(opts.disallowedTools).join(","));
67
+ args.push(
60
68
  "--output-format", opts.json ? "json" : "text",
61
69
  "--verbose",
62
70
  "--append-system-prompt", opts.systemPrompt || buildSubagentSystemPrompt(role),
63
- "--dangerously-skip-permissions",
64
- ];
71
+ );
72
+ // Permissions. A genuinely read-only sub-agent (e.g. the dream's
73
+ // introspection) must use plan mode — verified to be the ONLY mechanism
74
+ // that blocks writes: --dangerously-skip-permissions OVERRIDES both
75
+ // --allowedTools and --disallowedTools, so a whitelist alone does NOT
76
+ // restrict. Default remains skip-permissions for research sub-agents that
77
+ // legitimately need Bash/Write.
78
+ if (opts.permissionMode) args.push("--permission-mode", opts.permissionMode);
79
+ else args.push("--dangerously-skip-permissions");
65
80
  if (opts.model) args.push("--model", opts.model);
81
+ if (opts.effort) args.push("--effort", opts.effort);
66
82
  args.push(prompt);
67
83
  const env = { ...botSubprocessEnv(), ...claudeSubprocessEnv() };
68
84
  const proc = spawn(CLAUDE_PATH, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
@@ -30,6 +30,42 @@ function buildPersonaBlock() {
30
30
  }
31
31
  }
32
32
 
33
+ // Always-injected lessons: cross-cutting rules learned from past mistakes.
34
+ // Unlike packs/entities these are NOT topic-gated — they load every turn
35
+ // because the whole point is they apply when the topic ISN'T matched. They
36
+ // are binding defaults that override generic assumptions; the (src: pack)
37
+ // pointer is where the full context lives.
38
+ function buildLessonsBlock() {
39
+ try {
40
+ const { loadLessonsBlock, LESSONS_FILE } = require("./lessons");
41
+ const block = loadLessonsBlock();
42
+ if (!block) return "";
43
+ return `\n## Lessons learned\nHard-won rules distilled from past mistakes — things you got wrong before and were corrected on. These are always loaded (unlike topic-matched packs/notes) precisely because they apply when the topic ISN'T matched. Treat them as binding defaults that override generic assumptions; before acting on one, verify against its "(src: <pack>)" pointer, which holds the full context. Edit via /lessons or ${LESSONS_FILE}.\n\n${block}\n`;
44
+ } catch (e) {
45
+ return "";
46
+ }
47
+ }
48
+
49
+ // Always-on skill index (Hermes Tier-1): names + descriptions of packs you've
50
+ // flagged as reusable how-tos, so the agent knows each skill exists every turn
51
+ // even when the topic isn't matched. The full Procedure is NOT injected here —
52
+ // it loads on demand via `open-claudia pack show <dir>` (Tier-3 progressive
53
+ // disclosure). Only skill-flagged packs appear; the 80+ project-tracker packs
54
+ // stay topic-gated so this stays a short, high-signal list.
55
+ function buildSkillIndexBlock() {
56
+ try {
57
+ const packs = require("./packs").listSkillPacks();
58
+ if (!packs.length) return "";
59
+ const lines = packs
60
+ .slice(0, 30)
61
+ .map((p) => `- ${p.name} (\`${p.dir}\`) — ${p.description || "(no description)"}`)
62
+ .join("\n");
63
+ return `\n### Skills you've learned (always available)\nVerified how-tos you've been taught and can re-run. This is the index only — before doing one, load its full steps with \`open-claudia pack show <dir>\` and follow them rather than improvising (the Procedure encodes prerequisites and pitfalls you hit before):\n\n${lines}\n`;
64
+ } catch (e) {
65
+ return "";
66
+ }
67
+ }
68
+
33
69
  function buildSystemPrompt() {
34
70
  const state = currentState();
35
71
  const soul = loadSoul();
@@ -112,6 +148,7 @@ Keep replies clean and mobile-readable. Use short paragraphs and bullets. Avoid
112
148
  return `
113
149
  ${soul}
114
150
  ${buildPersonaBlock()}
151
+ ${buildLessonsBlock()}
115
152
  ## Runtime Context
116
153
  - Interface: ${channelLabel} chat through Open Claudia.
117
154
  - Active project path: ${state.currentSession ? state.currentSession.dir : "none"}
@@ -122,11 +159,13 @@ ${buildPersonaBlock()}
122
159
  Open Claudia learned skills are stored as context packs under ${path.join(CONFIG_DIR, "packs")}. Older \`~/.claude/skills/<name>/SKILL.md\` skills may have been migrated into packs; their reusable instructions live in the pack's Procedure section.
123
160
 
124
161
  If the user asks for a skill by name, do not rely only on the backend harness's native "Available skills" list. First use any Active context pack injected into the current request as the requested Open Claudia skill. If no matching pack was injected, inspect with \`open-claudia pack list\` / \`open-claudia pack show <dir>\` and legacy \`/skills\` paths before saying the skill does not exist.
162
+ ${buildSkillIndexBlock()}
125
163
 
126
164
  ## Stable Local Paths
127
165
  - Bot code: ${path.join(BOT_DIR, "bot.js")}
128
166
  - Soul file (identity + hard rules): ${SOUL_FILE}
129
167
  - Persona file (voice, evolved by dream): ${require("./persona").PERSONA_FILE}
168
+ - Lessons file (always-loaded learned rules; /lessons): ${require("./lessons").LESSONS_FILE}
130
169
  - Cron config: ${CRONS_FILE}
131
170
  - Vault file: ${VAULT_FILE}
132
171
  - Bot environment: ${path.join(BOT_DIR, ".env")} (sensitive; never expose values)
@@ -349,10 +388,10 @@ function tryUseRecallBudget(budget, text) {
349
388
  // What the last promptWithDynamicContext call freshly injected (not the
350
389
  // deduped repeats) — consumed by the runner to announce recalls in chat,
351
390
  // mirroring the write-side announcements.
352
- let lastInjected = { packs: [], entities: [] };
391
+ let lastInjected = { packs: [], entities: [], recall: null };
353
392
  function consumeLastInjected() {
354
393
  const out = lastInjected;
355
- lastInjected = { packs: [], entities: [] };
394
+ lastInjected = { packs: [], entities: [], recall: null };
356
395
  return out;
357
396
  }
358
397
 
@@ -613,7 +652,7 @@ function bumpFtsMissCounter(n) {
613
652
  }
614
653
 
615
654
  async function promptWithDynamicContext(prompt, opts = {}) {
616
- lastInjected = { packs: [], entities: [] };
655
+ lastInjected = { packs: [], entities: [], recall: null };
617
656
  try {
618
657
  const { userText, contextText } = recallMatchParts(prompt);
619
658
  let historyText = "";
@@ -633,9 +672,17 @@ async function promptWithDynamicContext(prompt, opts = {}) {
633
672
  packsLib, entitiesLib, mergeMatches, filterMatches, logRecall,
634
673
  buildPackBlock, buildEntityBlock,
635
674
  };
636
- const { packBlock, entityBlock } = await engine.run({
675
+ const result = await engine.run({
637
676
  userText, contextText, fullContext, packLimit, budget, helpers,
638
677
  });
678
+ const { packBlock, entityBlock } = result;
679
+ const why = result.why || {};
680
+ lastInjected.recall = {
681
+ engine: engine.name || recall.activeEngineName(settings),
682
+ gated: !!result.gated,
683
+ packs: (result.packMatches || []).map((m) => ({ name: m.name || m.dir, why: why[`pack:${m.dir}`] || "" })),
684
+ entities: (result.entityMatches || []).map((m) => ({ name: m.name || m.slug, why: why[`entity:${m.slug}`] || "" })),
685
+ };
639
686
  const budgetNote = budget.omitted > 0
640
687
  ? `\n\n## Memory budget\n${budget.omitted} matched memory item${budget.omitted === 1 ? " was" : "s were"} omitted to keep this turn under the recall budget (${budget.maxChars} chars). Use \`open-claudia pack show <dir>\`, \`entity show <slug>\`, or transcript search if deeper context is needed.`
641
688
  : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.36",
3
+ "version": "2.6.38",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js"
12
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -33,7 +33,15 @@
33
33
  "test-usage-accounting.js",
34
34
  "test-recall-engine.js",
35
35
  "test-recall-graph.js",
36
- "test-recall-discoverer.js"
36
+ "test-recall-discoverer.js",
37
+ "test-project-transcripts-smoke.js",
38
+ "test-abilities.js",
39
+ "test-ability-extraction.js",
40
+ "test-ability-couse.js",
41
+ "test-ability-transfer.js",
42
+ "test-ability-tiers.js",
43
+ "test-ability-merge-guard.js",
44
+ "test-learning-e2e.js"
37
45
  ],
38
46
  "keywords": [
39
47
  "claude",
@@ -0,0 +1,53 @@
1
+ // Abilities are first-class packs (kind:"ability") with reuse provenance
2
+ // (learned_on / applied_on). Verify the frontmatter round-trips, context packs
3
+ // stay churn-free, and the helpers behave.
4
+ const assert = require("assert");
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+
9
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "abilities-"));
10
+ process.env.PACKS_DIR = path.join(tmp, "packs");
11
+
12
+ const packs = require("./core/packs");
13
+
14
+ // An ability with reuse provenance.
15
+ packs.createPack({
16
+ dir: "mobile-app-deploy", name: "Mobile App Deploy",
17
+ description: "Ship a mobile app: versionCode bump, APK, in-app updater",
18
+ stance: "Reusable how-to.", procedure: "1. bump 2. build 3. update",
19
+ kind: "ability", learned_on: "chat-mobile", applied_on: ["chat-mobile"],
20
+ });
21
+ let p = packs.readPack("mobile-app-deploy");
22
+ assert.strictEqual(p.kind, "ability", "kind persists");
23
+ assert.strictEqual(p.learned_on, "chat-mobile", "learned_on persists");
24
+ assert.deepStrictEqual(p.applied_on, ["chat-mobile"], "applied_on persists");
25
+
26
+ // A default pack is context and emits NO kind line (no churn on 121 existing packs).
27
+ packs.createPack({ dir: "billing", name: "Billing", description: "invoices" });
28
+ assert.strictEqual(packs.readPack("billing").kind, "context", "default kind is context");
29
+ const raw = fs.readFileSync(path.join(process.env.PACKS_DIR, "billing", "PACK.md"), "utf-8");
30
+ assert.ok(!/^kind:/m.test(raw), "context packs emit no kind line");
31
+ assert.ok(!/^learned_on:/m.test(raw), "context packs emit no learned_on line");
32
+
33
+ // listAbilities returns only abilities.
34
+ assert.deepStrictEqual(packs.listAbilities().map((a) => a.dir), ["mobile-app-deploy"], "listAbilities filters to abilities");
35
+
36
+ // recordApplied appends + dedupes, sets learned_on if unset.
37
+ packs.recordApplied("mobile-app-deploy", "spaces");
38
+ packs.recordApplied("mobile-app-deploy", "spaces");
39
+ p = packs.readPack("mobile-app-deploy");
40
+ assert.deepStrictEqual(p.applied_on, ["chat-mobile", "spaces"], "applied_on appends + dedupes");
41
+
42
+ // setSkill promotes to always-on AND implies ability (promoted things are abilities).
43
+ packs.createPack({ dir: "ci-verify", name: "CI Verify", description: "verify ci" });
44
+ packs.setSkill("ci-verify", true);
45
+ const sk = packs.readPack("ci-verify");
46
+ assert.strictEqual(sk.skill, true, "setSkill sets skill flag");
47
+ assert.strictEqual(sk.kind, "ability", "setSkill implies kind:ability");
48
+
49
+ // setKind can demote/relabel.
50
+ packs.setKind("ci-verify", "context");
51
+ assert.strictEqual(packs.readPack("ci-verify").kind, "context", "setKind relabels");
52
+
53
+ console.log("abilities OK — kind/learned_on/applied_on round-trip; helpers behave; context packs churn-free");
@@ -0,0 +1,68 @@
1
+ // PROOF (closed loop / automatic transfer): when the agent actually OPENS an
2
+ // ability pack alongside a project pack in one turn (the 📖 co-use signal),
3
+ // recordCoUse records the application, the governed-by edge forms on the next
4
+ // sync, and the ability becomes graph-discoverable from that project — WITHOUT
5
+ // the reviewer inferring anything. This is the automatic feedback loop.
6
+ const assert = require("assert");
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
11
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ability-couse-"));
12
+ process.env.PACKS_DIR = path.join(tmp, "packs");
13
+ process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
14
+
15
+ const packs = require("./core/packs");
16
+ const graph = require("./core/recall/graph");
17
+ const entStub = { listEntities: () => [] };
18
+
19
+ // An ability learned on spaces, plus two other project context packs it has NOT
20
+ // been applied on yet.
21
+ packs.createPack({
22
+ dir: "mobile-app-deploy", name: "Mobile App Deploy",
23
+ description: "Ship a mobile app: bump versionCode, build APK, push in-app updater",
24
+ tags: ["mobile", "deploy", "apk"], kind: "ability",
25
+ learned_on: "spaces", applied_on: ["spaces"], procedure: "1. bump 2. build 3. update",
26
+ });
27
+ packs.createPack({ dir: "spaces", name: "Spaces", description: "Spaces app" });
28
+ packs.createPack({ dir: "chat-mobile", name: "Chat Mobile", description: "Spaces chat client" });
29
+ packs.createPack({ dir: "billing", name: "Billing", description: "invoices" });
30
+
31
+ // ── 1. CO-USE: the agent opens the ability and the chat-mobile project pack in
32
+ // the same turn. recordCoUse records the transfer and reports it as new.
33
+ const transferred = packs.recordCoUse(["pack:mobile-app-deploy", "pack:chat-mobile"]);
34
+ assert.strictEqual(transferred.length, 1, "one new transfer recorded");
35
+ assert.strictEqual(transferred[0].ability, "mobile-app-deploy");
36
+ assert.strictEqual(transferred[0].project, "chat-mobile");
37
+ assert.deepStrictEqual(packs.readPack("mobile-app-deploy").applied_on, ["spaces", "chat-mobile"], "applied_on grew from real use");
38
+
39
+ // Idempotent: opening them together again is not a NEW transfer (no re-announce).
40
+ assert.strictEqual(packs.recordCoUse(["pack:mobile-app-deploy", "pack:chat-mobile"]).length, 0, "repeat co-use is not new");
41
+
42
+ // Guard rails: an ability opened alone, or two context packs together, or an
43
+ // ability + entity, record nothing.
44
+ assert.strictEqual(packs.recordCoUse(["pack:mobile-app-deploy"]).length, 0, "ability alone records nothing");
45
+ assert.strictEqual(packs.recordCoUse(["pack:spaces", "pack:billing"]).length, 0, "two context packs record nothing");
46
+ assert.strictEqual(packs.recordCoUse(["pack:mobile-app-deploy", "entity:someone"]).length, 0, "ability + entity records nothing");
47
+ assert.deepStrictEqual(packs.readPack("billing").applied_on, [], "billing never gained applied_on");
48
+
49
+ if (!graph.available()) { console.log("ability co-use OK (graph asserts skipped — no node:sqlite)"); process.exit(0); }
50
+
51
+ // ── 2. The edge forms from the use-grown provenance, and the ability is now
52
+ // discoverable from chat-mobile via the graph.
53
+ graph.syncFromCorpus(packs, entStub);
54
+ const edges = graph.allEdges();
55
+ assert.ok(
56
+ edges.some((e) => e.src === "pack:chat-mobile" && e.dst === "pack:mobile-app-deploy" && e.type === "governed-by"),
57
+ "governed-by edge formed for chat-mobile from co-use"
58
+ );
59
+ const fromChat = graph.expand([{ id: "pack:chat-mobile", score: 4 }], {});
60
+ assert.ok(fromChat.has("pack:mobile-app-deploy"), "ability surfaces from chat-mobile after co-use");
61
+
62
+ // Negative control: billing was never co-used, so it does not surface it.
63
+ const fromBilling = graph.expand([{ id: "pack:billing", score: 4 }], {});
64
+ assert.ok(!fromBilling.has("pack:mobile-app-deploy"), "ability does NOT surface from an unrelated project");
65
+
66
+ console.log("ability co-use OK — actual use auto-records reuse, forms the edge, transfers the ability cross-project");
67
+ console.log(" applied_on after co-use:", packs.readPack("mobile-app-deploy").applied_on.join(", "));
68
+ console.log(" working on chat-mobile → graph pulls in:", [...fromChat.keys()].join(", ") || "(none)");
@@ -0,0 +1,109 @@
1
+ // PROOF (capture-time extraction): a reusable how-to demonstrated on ONE turn is
2
+ // extracted by the REVIEWER into an ability pack, the project→ability link forms
3
+ // after that FIRST occurrence (no [[link]] hand-authoring), and once the ability
4
+ // is re-applied to a SECOND project it becomes graph-discoverable from there too.
5
+ // Runs the real reviewer applyAction, real packs.js store, and real recall graph.
6
+ const assert = require("assert");
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
11
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ability-extract-"));
12
+ process.env.PACKS_DIR = path.join(tmp, "packs");
13
+ process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
14
+
15
+ const packs = require("./core/packs");
16
+ const graph = require("./core/recall/graph");
17
+ const review = require("./core/pack-review");
18
+ const entStub = { listEntities: () => [] };
19
+
20
+ // Two real project context packs (so learned_on/applied_on can resolve to nodes),
21
+ // plus a negative-control project.
22
+ packs.createPack({ dir: "spaces", name: "Spaces", description: "Spaces app", stance: "Spaces project." });
23
+ packs.createPack({ dir: "chat-mobile", name: "Chat Mobile", description: "Spaces chat client", stance: "Chat project." });
24
+ packs.createPack({ dir: "billing", name: "Billing", description: "invoices and payments", stance: "Billing project." });
25
+
26
+ // ── 1. CAPTURE: the reviewer extracts an ability from a turn that shipped a
27
+ // mobile build on `spaces`. This is exactly the JSON the model is now told to
28
+ // emit (kind:"ability" + activity name/desc/tags + learned_on).
29
+ const created = review.applyAction({
30
+ action: "create",
31
+ dir: "mobile-app-deploy",
32
+ name: "Mobile App Deploy",
33
+ description: "Ship a mobile app: bump versionCode, build the APK, push the in-app updater",
34
+ tags: ["mobile", "deploy", "apk", "versioncode", "release"],
35
+ kind: "ability",
36
+ learned_on: "spaces",
37
+ applied_on: ["spaces"],
38
+ procedure: "1. bump versionCode 2. build APK 3. push in-app updater",
39
+ journal: "Shipped the spaces mobile build via versionCode bump + APK + in-app updater.",
40
+ });
41
+ assert.strictEqual(created.kind, "create", "ability is created");
42
+ assert.strictEqual(created.ability, true, "create result is flagged as an ability");
43
+ assert.strictEqual(created.learned_on, "spaces", "learned_on surfaces for the chat announcement");
44
+
45
+ const ability = packs.readPack("mobile-app-deploy");
46
+ assert.strictEqual(ability.kind, "ability", "persisted as kind:ability");
47
+ assert.strictEqual(ability.learned_on, "spaces", "learned_on persisted");
48
+ assert.deepStrictEqual(ability.applied_on, ["spaces"], "applied_on persisted");
49
+
50
+ // Activity-oriented metadata so the FIRST cross-project hit can match via FTS
51
+ // before any edge exists (the cold-start path).
52
+ assert.ok(/deploy|apk|versioncode/i.test(ability.description), "description is activity-oriented");
53
+ assert.ok(ability.tags.includes("apk"), "tags carry activity terms");
54
+
55
+ // The project pack was NOT mutated — no [[link]] injected, no provenance fight.
56
+ const spacesBody = Object.values(packs.readPack("spaces").sections).join("\n");
57
+ assert.ok(!/\[\[mobile-app-deploy\]\]/.test(spacesBody), "project pack left untouched (link is metadata-derived)");
58
+
59
+ if (!graph.available()) { console.log("ability extraction OK (graph asserts skipped — no node:sqlite)"); process.exit(0); }
60
+
61
+ // ── 2. LINK AFTER FIRST OCCURRENCE: syncFromCorpus derives governed-by from the
62
+ // ability's own provenance.
63
+ graph.syncFromCorpus(packs, entStub);
64
+ let edges = graph.allEdges();
65
+ assert.ok(
66
+ edges.some((e) => e.src === "pack:spaces" && e.dst === "pack:mobile-app-deploy" && e.type === "governed-by"),
67
+ "governed-by edge formed from metadata after the first occurrence"
68
+ );
69
+
70
+ // ── 3. REUSE ON ORIGIN: working on spaces surfaces the ability.
71
+ const fromSpaces = graph.expand([{ id: "pack:spaces", score: 4 }], {});
72
+ assert.ok(fromSpaces.has("pack:mobile-app-deploy"), "ability surfaces from its origin project");
73
+
74
+ // Negative control BEFORE reuse: an unrelated project does not pull it in.
75
+ const billingBefore = graph.expand([{ id: "pack:billing", score: 4 }], {});
76
+ assert.ok(!billingBefore.has("pack:mobile-app-deploy"), "unrelated project does not surface the ability");
77
+
78
+ // ── 4. CROSS-PROJECT TRANSFER: a later turn re-applies the SAME ability on
79
+ // chat-mobile. The reviewer records it via an update with applied_on (the
80
+ // inferred-from-content path; the co-use signal is the automatic one).
81
+ const reused = review.applyAction({
82
+ action: "update",
83
+ pack: "mobile-app-deploy",
84
+ applied_on: ["chat-mobile"],
85
+ journal: "Reused the mobile deploy flow to ship chat-mobile.",
86
+ });
87
+ assert.strictEqual(reused.appliedTo, "chat-mobile", "update records the cross-project reuse");
88
+ assert.deepStrictEqual(packs.readPack("mobile-app-deploy").applied_on, ["spaces", "chat-mobile"], "applied_on grew");
89
+
90
+ graph.syncFromCorpus(packs, entStub);
91
+ edges = graph.allEdges();
92
+ assert.ok(
93
+ edges.some((e) => e.src === "pack:chat-mobile" && e.dst === "pack:mobile-app-deploy" && e.type === "governed-by"),
94
+ "governed-by edge formed for the second project after reuse"
95
+ );
96
+ const fromChat = graph.expand([{ id: "pack:chat-mobile", score: 4 }], {});
97
+ assert.ok(fromChat.has("pack:mobile-app-deploy"), "ability now surfaces from the second project too");
98
+
99
+ // applied_on must NOT leak onto context packs (churn guard): re-applying to a
100
+ // context pack is ignored.
101
+ const ctxUpdate = review.applyAction({ action: "update", pack: "billing", applied_on: ["spaces"], journal: "noted" });
102
+ assert.strictEqual(ctxUpdate.appliedTo, null, "applied_on ignored on context packs");
103
+ assert.strictEqual(packs.readPack("billing").kind, "context", "billing stays context");
104
+ assert.deepStrictEqual(packs.readPack("billing").applied_on, [], "context pack gains no applied_on");
105
+
106
+ console.log("ability extraction OK — reviewer extracts an ability, link forms after first occurrence, transfers cross-project on reuse");
107
+ console.log(" working on spaces → graph pulls in:", [...fromSpaces.keys()].join(", ") || "(none)");
108
+ console.log(" working on chat-mobile → graph pulls in:", [...fromChat.keys()].join(", ") || "(none)");
109
+ console.log(" provenance on ability → learned on", ability.learned_on, "| applied on", packs.readPack("mobile-app-deploy").applied_on.join(", "));