@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
package/core/lessons.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Lessons tier. The soul holds identity + hard rules (user-owned); the
|
|
2
|
+
// persona holds voice (Open-Claudia-owned). Lessons are the third
|
|
3
|
+
// always-injected layer: a small, bounded set of cross-cutting rules that
|
|
4
|
+
// Open Claudia got WRONG before and was corrected on.
|
|
5
|
+
//
|
|
6
|
+
// Why this exists: every other piece of learned knowledge (pack Stances,
|
|
7
|
+
// entity Notes) is topic-gated — it only loads when the incoming message
|
|
8
|
+
// FTS-matches its tags. That fails precisely when a fact is relevant on an
|
|
9
|
+
// off-topic turn (e.g. the Kazee mobile-deploy mechanism coming up during
|
|
10
|
+
// an hr-hub deploy conversation), and recall keys on the USER's text, not
|
|
11
|
+
// the assistant's own wrong output. So a fact can be perfectly stored and
|
|
12
|
+
// still never surface at the moment it's needed. Lessons fix that by being
|
|
13
|
+
// ALWAYS loaded, like soul and persona.
|
|
14
|
+
//
|
|
15
|
+
// To keep the always-on budget tiny and high-signal, a rule only graduates
|
|
16
|
+
// here after a MISS: the per-turn reviewer (pack-review.js) sees the user
|
|
17
|
+
// correct the assistant or repeat a known fact — proof topic-matching
|
|
18
|
+
// failed for it — and promotes a one-line lesson. Each lesson keeps a
|
|
19
|
+
// "(src: <pack-dir>)" pointer to the pack that holds the full context. The
|
|
20
|
+
// nightly dream dedupes, demotes lessons safely captured in their source
|
|
21
|
+
// pack when over cap, and enforces the count. Lessons are user-editable
|
|
22
|
+
// (/lessons, or the file directly) and never store secrets.
|
|
23
|
+
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
const crypto = require("crypto");
|
|
27
|
+
|
|
28
|
+
const CONFIG_DIR = require("../config-dir");
|
|
29
|
+
const LESSONS_FILE = process.env.LESSONS_FILE ? path.resolve(process.env.LESSONS_FILE) : path.join(CONFIG_DIR, "lessons.md");
|
|
30
|
+
const LESSONS_META_FILE = path.join(path.dirname(LESSONS_FILE), "lessons.meta.json");
|
|
31
|
+
|
|
32
|
+
// Count cap is the primary bound (dream enforces it with judgment); the
|
|
33
|
+
// char budget is the hard safety net on what actually gets injected.
|
|
34
|
+
const MAX_LESSONS = Number(process.env.LESSONS_MAX || 20);
|
|
35
|
+
const MAX_LESSON_CHARS = Number(process.env.LESSON_MAX_CHARS || 240); // per lesson
|
|
36
|
+
const MAX_LESSONS_CHARS = Number(process.env.LESSONS_MAX_CHARS || 3500); // total injected block
|
|
37
|
+
|
|
38
|
+
const FILE_HEADER = `Lessons learned — always loaded into every conversation.
|
|
39
|
+
One rule per bullet: cross-cutting things I got wrong before and was corrected on.
|
|
40
|
+
Edit freely; keep each to a single line. Optional "(src: <pack-dir>)" points to the pack with the full context.
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
// ── parsing ─────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
// Strip the "(src: ...)" pointer so the id is stable across src changes.
|
|
46
|
+
function stripSrc(text) {
|
|
47
|
+
return String(text || "").replace(/\s*\(src:\s*[^)]*\)\s*$/i, "").trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function srcOf(text) {
|
|
51
|
+
const m = String(text || "").match(/\(src:\s*([^)]+)\)\s*$/i);
|
|
52
|
+
return m ? m[1].trim() : "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalize(text) {
|
|
56
|
+
return stripSrc(text).toLowerCase().replace(/\s+/g, " ").trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function lessonId(text) {
|
|
60
|
+
return crypto.createHash("sha1").update(normalize(text)).digest("hex").slice(0, 8);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Parse only top-level "- " bullets; everything else (header lines, blank
|
|
64
|
+
// lines, the user's decorative text) is ignored. Robust to hand-edits.
|
|
65
|
+
function parseLessons(raw) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const line of String(raw || "").split("\n")) {
|
|
69
|
+
const m = line.match(/^\s*[-*]\s+(.*\S)\s*$/);
|
|
70
|
+
if (!m) continue;
|
|
71
|
+
const full = m[1].trim();
|
|
72
|
+
const body = stripSrc(full);
|
|
73
|
+
if (!body) continue;
|
|
74
|
+
const id = lessonId(full);
|
|
75
|
+
if (seen.has(id)) continue; // dedupe identical lessons
|
|
76
|
+
seen.add(id);
|
|
77
|
+
out.push({ id, text: body, src: srcOf(full) });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readLessons() {
|
|
83
|
+
try { return parseLessons(fs.readFileSync(LESSONS_FILE, "utf-8")); }
|
|
84
|
+
catch (e) { return []; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── meta sidecar (lifecycle: origin / created / reinforced / src) ────
|
|
88
|
+
|
|
89
|
+
function readMeta() {
|
|
90
|
+
try { return JSON.parse(fs.readFileSync(LESSONS_META_FILE, "utf-8")) || {}; }
|
|
91
|
+
catch (e) { return {}; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeMeta(meta) {
|
|
95
|
+
fs.mkdirSync(path.dirname(LESSONS_META_FILE), { recursive: true, mode: 0o700 });
|
|
96
|
+
fs.writeFileSync(LESSONS_META_FILE, JSON.stringify(meta, null, 2) + "\n", { mode: 0o600 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Self-heal: drop meta for lessons that no longer exist, seed defaults for
|
|
100
|
+
// lessons that have none (e.g. hand-added by the user). Idempotent.
|
|
101
|
+
function reconcileMeta(lessons) {
|
|
102
|
+
lessons = lessons || readLessons();
|
|
103
|
+
const meta = readMeta();
|
|
104
|
+
const now = new Date().toISOString();
|
|
105
|
+
const next = {};
|
|
106
|
+
let changed = false;
|
|
107
|
+
for (const l of lessons) {
|
|
108
|
+
if (meta[l.id]) {
|
|
109
|
+
next[l.id] = meta[l.id];
|
|
110
|
+
if (l.src && next[l.id].src !== l.src) { next[l.id].src = l.src; changed = true; }
|
|
111
|
+
} else {
|
|
112
|
+
next[l.id] = { origin: "user", created: now, reinforced: 0, reinforcedAt: null, src: l.src || "" };
|
|
113
|
+
changed = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (Object.keys(meta).length !== Object.keys(next).length) changed = true;
|
|
117
|
+
if (changed) writeMeta(next);
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── serialization ───────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function lessonLine(l) {
|
|
124
|
+
return l.src ? `- ${l.text} (src: ${l.src})` : `- ${l.text}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function writeLessons(lessons) {
|
|
128
|
+
const body = lessons.map(lessonLine).join("\n");
|
|
129
|
+
const content = FILE_HEADER + "\n" + body + (body ? "\n" : "");
|
|
130
|
+
fs.mkdirSync(path.dirname(LESSONS_FILE), { recursive: true, mode: 0o700 });
|
|
131
|
+
fs.writeFileSync(LESSONS_FILE, content, { mode: 0o600 });
|
|
132
|
+
return LESSONS_FILE;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── public API ──────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
// Structured list merged with meta, newest first by created.
|
|
138
|
+
function listLessons() {
|
|
139
|
+
const lessons = readLessons();
|
|
140
|
+
const meta = reconcileMeta(lessons);
|
|
141
|
+
return lessons
|
|
142
|
+
.map((l) => ({ ...l, ...(meta[l.id] || {}) }))
|
|
143
|
+
.sort((a, b) => String(b.created || "").localeCompare(String(a.created || "")));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The always-injected block. Highest-priority lessons (most reinforced,
|
|
147
|
+
// then most recently reinforced) survive the char budget. Returns "" when
|
|
148
|
+
// there are none, so the system prompt stays clean on a fresh install.
|
|
149
|
+
function loadLessonsBlock() {
|
|
150
|
+
const lessons = readLessons();
|
|
151
|
+
if (lessons.length === 0) return "";
|
|
152
|
+
const meta = reconcileMeta(lessons);
|
|
153
|
+
const ranked = lessons.slice().sort((a, b) => {
|
|
154
|
+
const ma = meta[a.id] || {}, mb = meta[b.id] || {};
|
|
155
|
+
return (mb.reinforced || 0) - (ma.reinforced || 0)
|
|
156
|
+
|| String(mb.reinforcedAt || "").localeCompare(String(ma.reinforcedAt || ""))
|
|
157
|
+
|| String(mb.created || "").localeCompare(String(ma.created || ""));
|
|
158
|
+
});
|
|
159
|
+
const out = [];
|
|
160
|
+
let used = 0;
|
|
161
|
+
for (const l of ranked) {
|
|
162
|
+
const line = lessonLine(l);
|
|
163
|
+
if (used + line.length + 1 > MAX_LESSONS_CHARS) break;
|
|
164
|
+
out.push(line);
|
|
165
|
+
used += line.length + 1;
|
|
166
|
+
}
|
|
167
|
+
if (out.length === 0) return "";
|
|
168
|
+
return out.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Add or reinforce a lesson. Dedupe is by normalized text (id), so the same
|
|
172
|
+
// fact phrased the same way reinforces instead of duplicating. No eviction
|
|
173
|
+
// here — the char budget bounds injection and dream enforces the count cap
|
|
174
|
+
// with judgment, so a hard-won lesson is never silently dropped on add.
|
|
175
|
+
function addLesson({ text, src = "", origin = "reviewer" } = {}) {
|
|
176
|
+
const body = stripSrc(text);
|
|
177
|
+
if (!body) return { added: false, reason: "empty" };
|
|
178
|
+
if (body.length > MAX_LESSON_CHARS) return { added: false, reason: `too long (>${MAX_LESSON_CHARS} chars)` };
|
|
179
|
+
|
|
180
|
+
const lessons = readLessons();
|
|
181
|
+
const id = lessonId(body);
|
|
182
|
+
const meta = reconcileMeta(lessons);
|
|
183
|
+
const now = new Date().toISOString();
|
|
184
|
+
const existing = lessons.find((l) => l.id === id);
|
|
185
|
+
|
|
186
|
+
if (existing) {
|
|
187
|
+
if (src && !existing.src) { existing.src = src; writeLessons(lessons); }
|
|
188
|
+
const m = meta[id] || { origin, created: now, reinforced: 0, src };
|
|
189
|
+
m.reinforced = (m.reinforced || 0) + 1;
|
|
190
|
+
m.reinforcedAt = now;
|
|
191
|
+
if (src && !m.src) m.src = src;
|
|
192
|
+
meta[id] = m;
|
|
193
|
+
writeMeta(meta);
|
|
194
|
+
return { added: false, reinforced: true, id, count: lessons.length };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
lessons.push({ id, text: body, src });
|
|
198
|
+
writeLessons(lessons);
|
|
199
|
+
meta[id] = { origin, created: now, reinforced: 0, reinforcedAt: null, src };
|
|
200
|
+
writeMeta(meta);
|
|
201
|
+
const count = lessons.length;
|
|
202
|
+
return { added: true, id, count, overCap: count > MAX_LESSONS };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Rewrite a lesson's text in place, preserving its meta (reinforced count,
|
|
206
|
+
// created, origin). Used by the dream to tighten/merge wording. If the new
|
|
207
|
+
// text collides with another existing lesson, the old one is dropped (the
|
|
208
|
+
// survivor keeps its own meta).
|
|
209
|
+
function editLesson(idOrText, newText, { src } = {}) {
|
|
210
|
+
const body = stripSrc(newText);
|
|
211
|
+
if (!body) return { ok: false, reason: "empty" };
|
|
212
|
+
if (body.length > MAX_LESSON_CHARS) return { ok: false, reason: `too long (>${MAX_LESSON_CHARS})` };
|
|
213
|
+
const arr = readLessons();
|
|
214
|
+
const oldId = arr.some((l) => l.id === idOrText) ? idOrText : lessonId(idOrText);
|
|
215
|
+
const idx = arr.findIndex((l) => l.id === oldId);
|
|
216
|
+
if (idx === -1) return { ok: false, reason: "not found" };
|
|
217
|
+
const old = arr[idx];
|
|
218
|
+
const newId = lessonId(body);
|
|
219
|
+
const meta = readMeta();
|
|
220
|
+
if (newId !== oldId && arr.some((l) => l.id === newId)) {
|
|
221
|
+
arr.splice(idx, 1); // merge into the existing twin
|
|
222
|
+
writeLessons(arr);
|
|
223
|
+
delete meta[oldId];
|
|
224
|
+
writeMeta(meta);
|
|
225
|
+
return { ok: true, merged: true, id: newId };
|
|
226
|
+
}
|
|
227
|
+
arr[idx] = { id: newId, text: body, src: src != null ? src : old.src };
|
|
228
|
+
writeLessons(arr);
|
|
229
|
+
const m = meta[oldId] || { origin: "user", created: new Date().toISOString(), reinforced: 0, reinforcedAt: null, src: old.src };
|
|
230
|
+
if (oldId !== newId) delete meta[oldId];
|
|
231
|
+
if (src != null) m.src = src;
|
|
232
|
+
meta[newId] = m;
|
|
233
|
+
writeMeta(meta);
|
|
234
|
+
return { ok: true, id: newId };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function removeLesson(idOrText) {
|
|
238
|
+
const key = String(idOrText || "").trim();
|
|
239
|
+
if (!key) return false;
|
|
240
|
+
const lessons = readLessons();
|
|
241
|
+
const id = lessons.some((l) => l.id === key) ? key : lessonId(key);
|
|
242
|
+
const next = lessons.filter((l) => l.id !== id);
|
|
243
|
+
if (next.length === lessons.length) return false;
|
|
244
|
+
writeLessons(next);
|
|
245
|
+
const meta = readMeta();
|
|
246
|
+
delete meta[id];
|
|
247
|
+
writeMeta(meta);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Overwrite from raw text (the /lessons edit path and manual edits). Bounds
|
|
252
|
+
// the total size, then reconciles meta to the new contents.
|
|
253
|
+
function saveLessonsRaw(raw) {
|
|
254
|
+
const text = String(raw || "");
|
|
255
|
+
if (text.length > MAX_LESSONS_CHARS * 3) {
|
|
256
|
+
throw new Error(`lessons file too large (${text.length} chars); trim it`);
|
|
257
|
+
}
|
|
258
|
+
fs.mkdirSync(path.dirname(LESSONS_FILE), { recursive: true, mode: 0o700 });
|
|
259
|
+
// Re-serialize through the parser so the stored form is canonical.
|
|
260
|
+
writeLessons(parseLessons(text));
|
|
261
|
+
reconcileMeta();
|
|
262
|
+
return LESSONS_FILE;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function lessonsExist() {
|
|
266
|
+
return readLessons().length > 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
LESSONS_FILE, LESSONS_META_FILE,
|
|
271
|
+
MAX_LESSONS, MAX_LESSON_CHARS, MAX_LESSONS_CHARS,
|
|
272
|
+
normalize, lessonId, parseLessons,
|
|
273
|
+
readLessons, listLessons, loadLessonsBlock,
|
|
274
|
+
readMeta, writeMeta, reconcileMeta,
|
|
275
|
+
addLesson, editLesson, removeLesson, saveLessonsRaw, lessonsExist,
|
|
276
|
+
};
|
package/core/pack-review.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|