@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.
- package/CHANGELOG.md +3 -0
- package/README.md +2 -1
- package/bin/cli.js +18 -0
- package/bin/ideas.js +69 -0
- package/bin/keyring.js +64 -0
- package/bin/lessons.js +72 -0
- package/bin/pack.js +45 -2
- package/bot.js +8 -0
- package/core/actions.js +10 -2
- package/core/config.js +10 -1
- package/core/day-seeds.js +98 -0
- package/core/dream.js +413 -18
- package/core/handlers.js +153 -9
- package/core/ideas.js +114 -0
- package/core/keyring.js +79 -0
- package/core/lessons.js +276 -0
- package/core/pack-review.js +95 -14
- package/core/packs.js +95 -2
- package/core/recall/discoverer.js +5 -2
- package/core/recall/graph.js +17 -0
- package/core/recall/index.js +12 -5
- package/core/redact.js +25 -3
- package/core/runner.js +44 -2
- package/core/subagent.js +20 -4
- package/core/system-prompt.js +51 -4
- package/package.json +11 -3
- package/test-abilities.js +53 -0
- package/test-ability-couse.js +68 -0
- package/test-ability-extraction.js +109 -0
- package/test-ability-merge-guard.js +42 -0
- package/test-ability-tiers.js +57 -0
- package/test-ability-transfer.js +70 -0
- package/test-learning-e2e.js +98 -0
- package/test-project-transcripts-smoke.js +50 -0
- package/test-recall-discoverer.js +3 -0
- package/test-recall-engine.js +7 -5
|
@@ -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 {
|
|
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 };
|
package/core/recall/graph.js
CHANGED
|
@@ -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}`;
|
package/core/recall/index.js
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
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 ||
|
|
26
|
-
return ENGINES[name] ? name :
|
|
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 {
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
|
|
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
|
-
|
|
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"] });
|
package/core/system-prompt.js
CHANGED
|
@@ -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
|
|
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.
|
|
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(", "));
|