@inetafrica/open-claudia 2.6.37 → 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.
@@ -9,6 +9,7 @@
9
9
  const { spawnSubagent } = require("./subagent");
10
10
  const packs = require("./packs");
11
11
  const entities = require("./entities");
12
+ const lessons = require("./lessons");
12
13
  const packGuard = require("./pack-guard");
13
14
  const { redactSensitive } = require("./redact");
14
15
 
@@ -17,6 +18,9 @@ const MAX_TEXT_CHARS = 7000;
17
18
  const REVIEW_MODEL = process.env.PACK_REVIEW_MODEL || "haiku";
18
19
  const MAX_ACTIONS = 2;
19
20
  const MAX_ENTITY_ACTIONS = 3;
21
+ // Lessons are precious always-on budget — at most one promotion per turn,
22
+ // and only when the turn shows a genuine miss.
23
+ const MAX_LESSON_ACTIONS = 1;
20
24
 
21
25
  function enabled() {
22
26
  return (process.env.PACK_REVIEW || "on").toLowerCase() !== "off";
@@ -52,14 +56,22 @@ function formatAnnouncement(lines) {
52
56
  }
53
57
 
54
58
  function buildReviewPrompt(userText, assistantText) {
55
- const index = packs.listPacks().map((p) =>
56
- `- ${p.dir}: ${p.name} ${p.description}${p.tags.length ? ` [${p.tags.join(", ")}]` : ""}`
57
- ).join("\n") || "(none yet)";
59
+ const index = packs.listPacks().map((p) => {
60
+ const ab = p.kind === "ability" ? " ◆ability" : "";
61
+ const prov = p.kind === "ability" && p.learned_on
62
+ ? ` (learned on ${p.learned_on}${p.applied_on && p.applied_on.length ? `; applied on ${p.applied_on.join(", ")}` : ""})`
63
+ : "";
64
+ return `- ${p.dir}: ${p.name} — ${p.description}${p.tags.length ? ` [${p.tags.join(", ")}]` : ""}${ab}${prov}`;
65
+ }).join("\n") || "(none yet)";
58
66
 
59
67
  const entityIndex = entities.listEntities().map((e) =>
60
68
  `- ${e.slug}: ${e.name} (${e.type})${e.aliases.length ? ` aka ${e.aliases.join(", ")}` : ""} — ${e.description}`
61
69
  ).join("\n") || "(none yet)";
62
70
 
71
+ const lessonIndex = lessons.listLessons().map((l) =>
72
+ `- ${l.text}${l.src ? ` (src: ${l.src})` : ""}`
73
+ ).join("\n") || "(none yet)";
74
+
63
75
  return `You are the memory reviewer for a personal AI assistant. After each conversation turn you decide whether the assistant's long-term "context packs" and "entity notes" should change.
64
76
 
65
77
  A context pack is a living document about ONE topic (a project, a system, a recurring task, a domain). It has four sections:
@@ -72,12 +84,19 @@ An entity note is a short memory file about ONE specific named entity — a pers
72
84
  - Notes: current truth about the entity (who/what it is, role, preferences, relationships). Replaces wholesale.
73
85
  - Log: one-line dated observations, appended.
74
86
 
87
+ An ABILITY is a special kind of pack (kind:"ability") for a REUSABLE HOW-TO that is NOT tied to one project — a procedure, pattern, or technique you would follow just as well on a different project later (e.g. "ship a mobile app: bump versionCode, build the APK, push the in-app updater"; "safely run a destructive DB write"; "wire up a new ArgoCD app"). A normal pack (kind:"context", the default) is about ONE project/system and stays scoped to it. Decide by NATURE, not by repetition: if THIS turn demonstrated a self-contained method you would re-run on a DIFFERENT project, capture it as an ability the FIRST time you see it — do not wait for it to recur. Give an ability an ACTIVITY-oriented name, description, and tags (what you DO — the verbs, tools, and artifacts involved) so it can be found later from a different project by the work being done, not by a project name. Set "learned_on" to the project pack dir this turn worked on and list that same dir in "applied_on". If an ability below (marked ◆ability) already covers the method, do NOT duplicate it — UPDATE it and add the current project dir to "applied_on" so it visibly transfers.
88
+
89
+ A LESSON is a single always-loaded rule (NOT topic-gated like packs/entities — it loads on every turn). Lessons exist for ONE purpose: to stop a recurring mistake. Propose a lesson ONLY when THIS turn shows the assistant MISSED something it should already have known — i.e. the user corrected the assistant ("no", "that's wrong", "actually it's X"), or signalled repetition ("again", "I keep telling you", "as I said", "I've told you before", "like I mentioned"). That friction is the proof that topic-matched memory failed, so the corrected fact must move to the always-on tier. No correction/repetition signal in the turn → NO lesson (return an empty lessons array). A lesson is the CORRECT fact written as one crisp imperative line, cross-cutting and durable (a mechanism, rule, or constraint that will matter on future, possibly off-topic turns) — never a one-off task detail or a fact with no miss behind it. Point "src" at the pack that should hold the full context if one fits. If an existing lesson below already covers it, reuse its EXACT wording so it reinforces rather than duplicating.
90
+
75
91
  Existing packs:
76
92
  ${index}
77
93
 
78
94
  Known entities:
79
95
  ${entityIndex}
80
96
 
97
+ Current lessons (always loaded):
98
+ ${lessonIndex}
99
+
81
100
  The turn to review:
82
101
 
83
102
  <user_message>
@@ -93,21 +112,26 @@ Decide. Rules:
93
112
  - UPDATE an existing pack when the turn touched that topic: append a journal line, and rewrite State if where-things-stand changed. Update Stance when the user expressed a lasting preference or rule; Procedure when a verified working method emerged.
94
113
  - PROMOTE durable conclusions out of the Journal. A confirmed root-cause diagnosis, an established fact, or a settled design decision must go into Stance/Procedure/State (the parts always injected in full), NOT only a Journal line — Journal is truncated to the last few lines on injection, so a conclusion left only there will silently fall out of recall over time.
95
114
  - CREATE a pack when the turn worked on a durable topic no existing pack covers. Topics recur more than you expect — a named system or project is durable by default. Do not create packs for one-off trivia, and prefer update when an existing pack fits.
115
+ - ABILITY vs CONTEXT: most creates are context packs (a project/system tracker). Create an ability (kind:"ability") only for a genuinely reusable how-to as defined above, and always set learned_on + applied_on to the originating project dir. When a turn re-used an EXISTING ability on a NEW project, do NOT create anything — UPDATE that ability and put the new project dir in "applied_on" so the cross-project reuse is recorded.
96
116
  - Never store secrets, tokens, passwords, or credentials.
97
117
  - State should be concise (under 150 words). Journal entries one sentence. Never start a journal or log sentence with a date — dates are prepended automatically.
98
118
  - At most ${MAX_ACTIONS} pack actions.
99
119
  - Entities: add an item when the turn revealed something durable about a specific named person, place, project, org, or system — their role, status, preferences, or relationship to other work. Use the existing entity name when one matches (check aliases). Skip entities mentioned only in passing with nothing learned. Notes under 100 words; "notes" null means leave Notes unchanged. At most ${MAX_ENTITY_ACTIONS} entity items.
100
120
  - Packs and entities are independent — a turn can update both, either, or neither. Do not duplicate the same fact in a pack AND an entity unless it genuinely belongs to both.
121
+ - Lessons are the rare case: at most ${MAX_LESSON_ACTIONS} per turn, and ONLY on a real correction/repetition signal as defined above. The default is an empty lessons array. When you do promote one, ALSO record the same fact in the appropriate pack (update/create) so the durable home stays authoritative — the lesson is just the always-on shortcut. Never put secrets in a lesson.
101
122
 
102
123
  Reply with ONLY a JSON object, no prose, no code fences:
103
124
  {"actions": [
104
- {"action": "update", "pack": "<existing dir>", "journal": "<one sentence>", "state": "<full new State or null>", "stance": null, "procedure": null}
105
- | {"action": "create", "dir": "<kebab-slug>", "name": "<title>", "description": "<one line: when this pack is relevant>", "tags": ["..."], "stance": "<or empty>", "procedure": "<or empty>", "state": "<where things stand>", "journal": "<one sentence>"}
125
+ {"action": "update", "pack": "<existing dir>", "journal": "<one sentence>", "state": "<full new State or null>", "stance": null, "procedure": null, "applied_on": ["<project dir, ONLY when this turn re-applied an existing ability to a new project>"]}
126
+ | {"action": "create", "dir": "<kebab-slug>", "name": "<title>", "description": "<one line: when this pack is relevant>", "tags": ["..."], "kind": "context|ability", "learned_on": "<originating project dir — abilities only>", "applied_on": ["<project dirs — abilities only>"], "stance": "<or empty>", "procedure": "<or empty>", "state": "<where things stand>", "journal": "<one sentence>"}
106
127
  ],
107
128
  "entities": [
108
129
  {"name": "<canonical name>", "type": "person|place|project|org|system|thing", "aliases": ["..."], "description": "<one line: who/what this is>", "notes": "<full new Notes or null>", "log": "<one sentence observation>"}
130
+ ],
131
+ "lessons": [
132
+ {"text": "<the correct fact as one crisp imperative line>", "src": "<pack dir that holds the full context, or empty>", "trigger": "<the exact correction/repetition phrase from the turn>"}
109
133
  ]}
110
- or {"actions": [], "entities": []}`;
134
+ or {"actions": [], "entities": [], "lessons": []}`;
111
135
  }
112
136
 
113
137
  function parseDecision(text) {
@@ -119,7 +143,8 @@ function parseDecision(text) {
119
143
  const obj = JSON.parse(raw.slice(start, end + 1));
120
144
  const actions = Array.isArray(obj.actions) ? obj.actions.slice(0, MAX_ACTIONS) : (obj.action ? [obj] : []);
121
145
  const entityActions = Array.isArray(obj.entities) ? obj.entities.slice(0, MAX_ENTITY_ACTIONS) : [];
122
- return { actions, entities: entityActions };
146
+ const lessonActions = Array.isArray(obj.lessons) ? obj.lessons.slice(0, MAX_LESSON_ACTIONS) : [];
147
+ return { actions, entities: entityActions, lessons: lessonActions };
123
148
  } catch (e) {
124
149
  return null;
125
150
  }
@@ -154,12 +179,27 @@ function applyAction(a) {
154
179
  stance: typeof a.stance === "string" ? a.stance : "",
155
180
  procedure: typeof a.procedure === "string" ? a.procedure : "",
156
181
  }, "reviewer");
157
- return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || "state updated", protectedStance: !!r.protectedStance };
182
+ // Cross-project reuse: only abilities carry applied_on, so ignore the field
183
+ // on context packs (keeps project trackers churn-free).
184
+ let appliedTo = null;
185
+ if (existing.kind === "ability" && Array.isArray(a.applied_on)) {
186
+ for (const proj of a.applied_on.map((x) => String(x || "").trim()).filter(Boolean)) {
187
+ if (packs.recordApplied(a.pack, proj)) appliedTo = proj;
188
+ }
189
+ }
190
+ return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || (appliedTo ? `reused on ${appliedTo}` : "state updated"), protectedStance: !!r.protectedStance, appliedTo };
158
191
  }
159
192
  if (a.action === "create" && (a.dir || a.name)) {
160
193
  const dir = packs.slugify(a.dir || a.name);
194
+ const kind = a.kind === "ability" ? "ability" : "context";
195
+ const learned_on = String(a.learned_on || "").trim();
196
+ const applied_on = Array.isArray(a.applied_on)
197
+ ? a.applied_on.map((x) => String(x || "").trim()).filter(Boolean)
198
+ : [];
199
+ const projects = applied_on.length ? applied_on : (learned_on ? [learned_on] : []);
161
200
  if (packs.readPack(dir)) {
162
201
  packs.updatePack(dir, { journal: a.journal || "", state: a.state || "" }, "reviewer");
202
+ if (kind === "ability") for (const proj of projects) packs.recordApplied(dir, proj);
163
203
  return { kind: "update", dir, name: a.name || dir, note: a.journal || "state updated" };
164
204
  }
165
205
  const pack = packs.createPack({
@@ -171,8 +211,11 @@ function applyAction(a) {
171
211
  procedure: a.procedure || "",
172
212
  state: a.state || "",
173
213
  journal: a.journal || "",
214
+ kind,
215
+ learned_on: kind === "ability" ? learned_on : "",
216
+ applied_on: kind === "ability" ? projects : [],
174
217
  }, "reviewer");
175
- return { kind: "create", dir: pack.dir, name: pack.name, note: a.description || "" };
218
+ return { kind: "create", dir: pack.dir, name: pack.name, note: a.description || "", ability: kind === "ability", learned_on: pack.learned_on };
176
219
  }
177
220
  return null;
178
221
  }
@@ -190,6 +233,25 @@ function applyEntityAction(e) {
190
233
  return { kind: created ? "create" : "update", slug: entity.slug, name: entity.name, type: entity.type, note: e.log || e.description || "" };
191
234
  }
192
235
 
236
+ function applyLessonAction(l) {
237
+ if (!l || typeof l !== "object") return null;
238
+ const text = String(l.text || "").trim();
239
+ if (!text) return null;
240
+ // Structural enforcement of "promote only after a miss": the model must
241
+ // cite the in-turn correction/repetition phrase. No trigger → no lesson,
242
+ // regardless of what the model decided.
243
+ if (!String(l.trigger || "").trim()) return null;
244
+ const guard = packGuard.scanForInjection(text, { strict: true });
245
+ if (guard.flagged) {
246
+ console.warn(`[pack-review] guard blocked lesson (${guard.kind}): ${guard.evidence || text.slice(0, 60)}`);
247
+ return { kind: "skipped", reason: guard.kind };
248
+ }
249
+ const r = lessons.addLesson({ text, src: String(l.src || "").trim(), origin: "reviewer" });
250
+ if (r.added) return { kind: "create", id: r.id, text, overCap: !!r.overCap };
251
+ if (r.reinforced) return { kind: "reinforce", id: r.id, text };
252
+ return { kind: "skipped", reason: r.reason || "not added" };
253
+ }
254
+
193
255
  // Fire-and-forget. `announce` is an async (text) => void bound to the
194
256
  // originating channel; failures are logged, never thrown into the turn.
195
257
  function reviewTurn({ userText, assistantText, channelId, announce }) {
@@ -206,17 +268,19 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
206
268
  systemPrompt: "You are a background memory reviewer. Reply with ONLY the requested JSON object. No prose, no markdown, no tool use.",
207
269
  }).then(({ text }) => {
208
270
  const decision = parseDecision(text);
209
- if (!decision || (decision.actions.length === 0 && decision.entities.length === 0)) return;
271
+ if (!decision || (decision.actions.length === 0 && decision.entities.length === 0 && (decision.lessons || []).length === 0)) return;
210
272
  const lines = [];
211
273
  for (const a of decision.actions) {
212
274
  try {
213
275
  const r = applyAction(a);
214
276
  if (r && r.kind === "skipped") {
215
277
  lines.push(`🛡️ Skipped a memory write that looked like an injected instruction (${r.reason}).`);
278
+ } else if (r && r.kind === "create" && r.ability) {
279
+ lines.push(`🧩 New ability: ${r.name}${r.learned_on ? ` (learned on ${r.learned_on})` : ""}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
280
+ } else if (r && r.kind === "create") {
281
+ lines.push(`📦 New pack: ${r.name}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
216
282
  } else if (r) {
217
- lines.push(r.kind === "create"
218
- ? `📦 New pack: ${r.name}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`
219
- : `✏️ ${r.name} — ${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
283
+ lines.push(`✏️ ${r.name} ${clipWords(r.note, 180)}${r.appliedTo ? ` 🧩 (now also applies to ${r.appliedTo})` : ""}\n↳ open-claudia pack show ${r.dir}`);
220
284
  }
221
285
  } catch (e) {
222
286
  console.warn(`[pack-review] apply failed: ${e.message}`);
@@ -235,6 +299,23 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
235
299
  console.warn(`[pack-review] entity apply failed: ${e.message}`);
236
300
  }
237
301
  }
302
+ for (const la of (decision.lessons || [])) {
303
+ try {
304
+ const r = applyLessonAction(la);
305
+ if (!r) continue;
306
+ if (r.kind === "skipped") {
307
+ if (r.reason && /exfil|override|base64/.test(r.reason)) {
308
+ lines.push(`🛡️ Skipped a lesson that looked like an injected instruction (${r.reason}).`);
309
+ }
310
+ } else if (r.kind === "create") {
311
+ lines.push(`📌 New lesson — I got this wrong before, so I'll always keep it loaded now:\n"${clipWords(r.text, 200)}"${r.overCap ? "\n(That's over the lessons cap — the nightly dream will tidy.)" : ""}`);
312
+ } else if (r.kind === "reinforce") {
313
+ lines.push(`📌 Reinforced a lesson I keep needing — clearly worth keeping front of mind:\n"${clipWords(r.text, 200)}"`);
314
+ }
315
+ } catch (e) {
316
+ console.warn(`[pack-review] lesson apply failed: ${e.message}`);
317
+ }
318
+ }
238
319
  if (lines.length > 0 && typeof announce === "function") {
239
320
  announce(formatAnnouncement(lines)).catch(() => {});
240
321
  }
@@ -243,4 +324,4 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
243
324
  });
244
325
  }
245
326
 
246
- module.exports = { reviewTurn, parseDecision, applyAction, applyEntityAction, buildReviewPrompt, clipWords, ENTITY_EMOJI };
327
+ module.exports = { reviewTurn, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, ENTITY_EMOJI };
package/core/packs.js CHANGED
@@ -66,6 +66,10 @@ function serialize(pack) {
66
66
  `tags: ${(pack.tags || []).join(", ")}`,
67
67
  ];
68
68
  if (pack.parent) fmLines.push(`parent: ${pack.parent}`);
69
+ if (pack.skill) fmLines.push("skill: true");
70
+ if (pack.kind && pack.kind !== "context") fmLines.push(`kind: ${pack.kind}`);
71
+ if (pack.learned_on) fmLines.push(`learned_on: ${pack.learned_on}`);
72
+ if (Array.isArray(pack.applied_on) && pack.applied_on.length) fmLines.push(`applied_on: ${pack.applied_on.join(", ")}`);
69
73
  fmLines.push(
70
74
  `created: ${pack.created || new Date().toISOString()}`,
71
75
  `updated: ${pack.updated || new Date().toISOString()}`,
@@ -98,6 +102,10 @@ function readPack(dir) {
98
102
  description: fm.description || "",
99
103
  tags: (fm.tags || "").split(",").map((t) => t.trim()).filter(Boolean),
100
104
  parent: fm.parent || null,
105
+ skill: fm.skill === "true" || fm.skill === true,
106
+ kind: fm.kind || "context",
107
+ learned_on: fm.learned_on || "",
108
+ applied_on: (fm.applied_on || "").split(",").map((t) => t.trim()).filter(Boolean),
101
109
  created: fm.created || "",
102
110
  updated: fm.updated || (stat ? stat.mtime.toISOString() : ""),
103
111
  last_used: fm.last_used || "",
@@ -128,6 +136,83 @@ function findPack(nameOrDir) {
128
136
  return listPacks().find((p) => p.dir.toLowerCase() === needle || p.name.toLowerCase() === needle) || null;
129
137
  }
130
138
 
139
+ // Packs explicitly flagged as reusable how-tos. These get an always-on
140
+ // Tier-1 index in the system prompt (name + description), so the agent knows
141
+ // the skill exists and can load its Procedure on demand. Most packs are
142
+ // project trackers, not skills — so this is opt-in, not "any pack with a
143
+ // Procedure".
144
+ function listSkillPacks() {
145
+ return listPacks().filter((p) => p.skill && !p.archived);
146
+ }
147
+
148
+ function setSkill(nameOrDir, on) {
149
+ const pack = findPack(nameOrDir);
150
+ if (!pack) return null;
151
+ pack.skill = !!on;
152
+ if (on && pack.kind !== "ability") pack.kind = "ability";
153
+ pack.updated = new Date().toISOString();
154
+ writePack(pack);
155
+ return pack;
156
+ }
157
+
158
+ // Reusable abilities (how-tos / patterns / themes) — kind:"ability". These are
159
+ // the nodes that transfer across projects via governed-by edges, and the pool
160
+ // the always-on skill index is promoted from. Project trackers are kind:"context".
161
+ function listAbilities() {
162
+ return listPacks().filter((p) => p.kind === "ability" && !p.archived);
163
+ }
164
+
165
+ function setKind(nameOrDir, kind) {
166
+ const pack = findPack(nameOrDir);
167
+ if (!pack) return null;
168
+ pack.kind = String(kind || "context").trim() || "context";
169
+ pack.updated = new Date().toISOString();
170
+ writePack(pack);
171
+ return pack;
172
+ }
173
+
174
+ // Record that an ability was applied on a project (provenance of reuse): appends
175
+ // to applied_on (deduped) and sets learned_on to the first project if unset.
176
+ function recordApplied(nameOrDir, project) {
177
+ const pack = findPack(nameOrDir);
178
+ if (!pack || !project) return null;
179
+ const proj = String(project).trim();
180
+ if (!proj) return pack;
181
+ if (!pack.learned_on) pack.learned_on = proj;
182
+ const set = new Set(pack.applied_on || []);
183
+ set.add(proj);
184
+ pack.applied_on = [...set];
185
+ pack.updated = new Date().toISOString();
186
+ writePack(pack);
187
+ return pack;
188
+ }
189
+
190
+ // Close the learning loop from actual USE. Given the node ids the agent opened
191
+ // together in one turn (pack:<dir> / entity:<slug>), treat every ability opened
192
+ // alongside a project (context) pack as "this ability was applied while working
193
+ // on that project" and record it. applied_on growing is what forms the
194
+ // project→ability governed-by edge on the next structural sync, so reuse
195
+ // transfers automatically from real use. Returns the pairs NEWLY transferred
196
+ // (for a one-line "it transfers there now too" announcement).
197
+ function recordCoUse(openedIds) {
198
+ const opened = [...new Set(openedIds || [])]
199
+ .filter((id) => typeof id === "string" && id.startsWith("pack:"))
200
+ .map((id) => readPack(id.slice(5)))
201
+ .filter(Boolean);
202
+ const abilities = opened.filter((p) => p.kind === "ability");
203
+ const contexts = opened.filter((p) => p.kind !== "ability");
204
+ const transferred = [];
205
+ for (const ab of abilities) {
206
+ for (const ctx of contexts) {
207
+ if (ab.dir === ctx.dir) continue;
208
+ const isNew = !(ab.applied_on || []).includes(ctx.dir);
209
+ recordApplied(ab.dir, ctx.dir);
210
+ if (isNew) transferred.push({ ability: ab.dir, abilityName: ab.name, project: ctx.dir, projectName: ctx.name });
211
+ }
212
+ }
213
+ return transferred;
214
+ }
215
+
131
216
  function writePack(pack) {
132
217
  ensureDir();
133
218
  const dir = path.join(PACKS_DIR, pack.dir);
@@ -137,7 +222,7 @@ function writePack(pack) {
137
222
  return pack.dir;
138
223
  }
139
224
 
140
- function createPack({ dir, name, description, tags, stance, procedure, state, journal, parent }, origin = "user") {
225
+ function createPack({ dir, name, description, tags, stance, procedure, state, journal, parent, skill, kind, learned_on, applied_on }, origin = "user") {
141
226
  const d = slugify(dir || name);
142
227
  if (!d) throw new Error("pack needs a name");
143
228
  if (readPack(d)) throw new Error(`pack ${d} already exists`);
@@ -147,6 +232,10 @@ function createPack({ dir, name, description, tags, stance, procedure, state, jo
147
232
  description: description || "",
148
233
  tags: Array.isArray(tags) ? tags : [],
149
234
  parent: parent || null,
235
+ skill: !!skill,
236
+ kind: kind || "context",
237
+ learned_on: learned_on || "",
238
+ applied_on: Array.isArray(applied_on) ? applied_on : [],
150
239
  created: new Date().toISOString(),
151
240
  sections: {
152
241
  Stance: stance || "",
@@ -227,12 +316,15 @@ function recordForegroundWrite(dir, { tool, oldString, content } = {}) {
227
316
 
228
317
  // Apply a reviewer mutation. Only supplied fields change; journal entries
229
318
  // append (capped); state replaces (it represents "current truth").
230
- function updatePack(dir, { description, tags, stance, procedure, state, journal } = {}, origin = "user") {
319
+ function updatePack(dir, { description, tags, stance, procedure, state, journal, skill, kind, learned_on } = {}, origin = "user") {
231
320
  const pack = readPack(dir);
232
321
  if (!pack) throw new Error(`no pack: ${dir}`);
233
322
  pack.updated = new Date().toISOString();
234
323
  if (description) pack.description = description;
235
324
  if (Array.isArray(tags) && tags.length) pack.tags = tags;
325
+ if (typeof skill === "boolean") pack.skill = skill;
326
+ if (typeof kind === "string" && kind.trim()) pack.kind = kind.trim();
327
+ if (typeof learned_on === "string" && learned_on.trim()) pack.learned_on = learned_on.trim();
236
328
  const applied = [];
237
329
  let protectedStance = false;
238
330
  if (typeof stance === "string" && stance.trim()) {
@@ -470,6 +562,7 @@ function matchPacks(text, { limit = 3, threshold = null } = {}) {
470
562
  module.exports = {
471
563
  PACKS_DIR, SECTIONS, slugify,
472
564
  listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
565
+ listSkillPacks, setSkill, listAbilities, setKind, recordApplied, recordCoUse,
473
566
  touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
474
567
  archivePack, restorePack, listArchived,
475
568
  readProvenance, provenanceOf, setProvenance, recordForegroundWrite,
@@ -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);
@@ -1145,10 +1155,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1145
1155
  if (settings.budget) settings.budget = null;
1146
1156
  state.statusMessageId = null;
1147
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
+
1148
1164
  // Hebbian co-use: nodes the agent actually opened together this turn get
1149
1165
  // their `related` edges reinforced, so future spreading activation pulls
1150
1166
  // the cluster together. Reinforce on co-USE (📖), never co-recall.
1151
- if (openedThisTurn.size > 0) {
1167
+ if (turnSucceeded && openedThisTurn.size > 0) {
1152
1168
  try {
1153
1169
  const recallGraph = require("./recall/graph");
1154
1170
  if (openedThisTurn.size > 1) recallGraph.reinforceSet([...openedThisTurn]);
@@ -1156,6 +1172,22 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1156
1172
  } catch (e) { /* best-effort */ }
1157
1173
  }
1158
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
+
1159
1191
  // Post-turn pack review: fire-and-forget on a cheap model; never
1160
1192
  // blocks queue drain or the next turn.
1161
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"] });