@inetafrica/open-claudia 2.6.37 → 2.6.39

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/core/handlers.js CHANGED
@@ -19,7 +19,8 @@ const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabe
19
19
  const { isOnboarded, startOnboarding } = require("./onboarding");
20
20
  const { listProjects, findProject, projectKeyboard, workspacePath } = require("./projects");
21
21
  const { vault } = require("./vault-store");
22
- const { redactSensitive } = require("./redact");
22
+ const keyring = require("./keyring");
23
+ const { redactSensitive, registerSecrets } = require("./redact");
23
24
  const { runDoctorChecks, formatDoctorReport } = require("./doctor");
24
25
  const { isNewerVersion } = require("./version");
25
26
  const jobs = require("./jobs");
@@ -702,18 +703,18 @@ register({
702
703
  const active = recall.activeEngineName(settings);
703
704
  if (tail) {
704
705
  const v = tail.trim().toLowerCase();
705
- if (v === "classic" || v === "default") settings.recallEngine = null;
706
+ if (v === "default" || v === recall.DEFAULT_ENGINE) settings.recallEngine = null;
706
707
  else if (recall.listEngines().includes(v)) settings.recallEngine = v;
707
708
  else return send(`Unknown engine "${v}". Options: ${recall.listEngines().join(", ")}.`);
708
709
  saveState();
709
- return send(`Recall engine: ${settings.recallEngine || "classic (default)"}`);
710
+ return send(`Recall engine: ${recall.activeEngineName(settings)}${settings.recallEngine ? "" : " (default)"}`);
710
711
  }
711
712
  send(
712
713
  `Recall engine: ${active}${settings.recallEngine ? "" : " (default)"}\n\n` +
713
- "• classickeyword FTS + relevance judge (stable default)\n" +
714
- "• discoverertyped-edge graph + spreading activation + why-bullets (new, opt-in)",
714
+ "• discoverertyped-edge graph + spreading activation + why-bullets (default)\n" +
715
+ "• classickeyword FTS + relevance judge (opt-out fallback)",
715
716
  { keyboard: { inline_keyboard: [
716
- [{ text: "Classic (default)", callback_data: "eng:classic" }, { text: "Discoverer", callback_data: "eng:discoverer" }],
717
+ [{ text: "Discoverer (default)", callback_data: "eng:discoverer" }, { text: "Classic", callback_data: "eng:classic" }],
717
718
  ] } },
718
719
  );
719
720
  },
@@ -965,8 +966,16 @@ register({
965
966
  setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
966
967
  state.runningProcess = null;
967
968
  if (state.streamInterval) clearTimeout(state.streamInterval);
969
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
968
970
  state.messageQueue = [];
969
971
  await send("Cancelled.");
972
+ } else if (state.preparingRun) {
973
+ // Turn is mid-recall/compaction — no process to kill yet. Flag it so
974
+ // runClaude bails at its pre-spawn checkpoint, and stop typing now.
975
+ state.cancelRequested = true;
976
+ state.messageQueue = [];
977
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
978
+ await send("Cancelled.");
970
979
  } else await send("Nothing running.");
971
980
  },
972
981
  });
@@ -988,7 +997,7 @@ register({
988
997
  const { settings } = state;
989
998
  const backendLabel = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
990
999
  const cronCount = jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => j.kind === "cron").length;
991
- const activeEngine = settings.recallEngine || "classic (default)";
1000
+ const activeEngine = require("./recall").activeEngineName(settings) + (settings.recallEngine ? "" : " (default)");
992
1001
  send([
993
1002
  `Project: ${state.currentSession.name}`,
994
1003
  `Backend: ${backendLabel}`,
@@ -1057,6 +1066,77 @@ register({
1057
1066
  },
1058
1067
  });
1059
1068
 
1069
+ register({
1070
+ name: "lessons", description: "View/manage always-loaded learned rules", args: "[add <rule> | remove <id>]",
1071
+ handler: async (env, { tail }) => {
1072
+ if (!authorized(env)) return;
1073
+ const lessonsLib = require("./lessons");
1074
+ const trimmed = (tail || "").trim();
1075
+ const [sub, ...restArr] = trimmed ? trimmed.split(/\s+/) : [];
1076
+ const subCmd = (sub || "").toLowerCase();
1077
+
1078
+ if (subCmd === "add") {
1079
+ const text = restArr.join(" ").trim();
1080
+ if (!text) return send('Usage: /lessons add <rule>');
1081
+ const r = lessonsLib.addLesson({ text, origin: "user" });
1082
+ if (r.added) return send(`📌 Added lesson [${r.id}].${r.overCap ? ` That's ${r.count}, over the cap of ${lessonsLib.MAX_LESSONS} — the nightly dream will tidy.` : ""}`);
1083
+ if (r.reinforced) return send(`Already knew that one — reinforced [${r.id}].`);
1084
+ return send(`Couldn't add it: ${r.reason}.`);
1085
+ }
1086
+
1087
+ if (subCmd === "remove" || subCmd === "rm") {
1088
+ const key = restArr.join(" ").trim();
1089
+ if (!key) return send("Usage: /lessons remove <id>");
1090
+ return send(lessonsLib.removeLesson(key) ? `Removed ${key}.` : `No matching lesson: ${key}`);
1091
+ }
1092
+
1093
+ const all = lessonsLib.listLessons();
1094
+ if (all.length === 0) {
1095
+ return send(`No lessons yet. These are cross-cutting rules I always load — I add them automatically when you correct me on something I should have known. Add one manually: /lessons add <rule>\n\nFile: ${lessonsLib.LESSONS_FILE}`);
1096
+ }
1097
+ const lines = all.map((l) => `📌 [${l.id}] ${l.text}${l.src ? `\n src: ${l.src}` : ""}${l.reinforced ? ` · reinforced ${l.reinforced}×` : ""}`);
1098
+ let msg = `Lessons I always load (${all.length}/${lessonsLib.MAX_LESSONS}):\n\n${lines.join("\n")}`;
1099
+ msg += `\n\n/lessons add <rule> · /lessons remove <id>\nFile: ${lessonsLib.LESSONS_FILE}`;
1100
+ return send(msg);
1101
+ },
1102
+ });
1103
+
1104
+ register({
1105
+ name: "ideas", description: "View/manage the self-improvement backlog", args: "[add <idea> | remove <id>]",
1106
+ handler: async (env, { tail }) => {
1107
+ if (!authorized(env)) return;
1108
+ const ideasLib = require("./ideas");
1109
+ const trimmed = (tail || "").trim();
1110
+ const [sub, ...restArr] = trimmed ? trimmed.split(/\s+/) : [];
1111
+ const subCmd = (sub || "").toLowerCase();
1112
+
1113
+ if (subCmd === "add") {
1114
+ const text = restArr.join(" ").trim();
1115
+ if (!text) return send("Usage: /ideas add <idea>");
1116
+ const r = ideasLib.addIdea({ text });
1117
+ if (r.added) return send(`💡 Added idea [${r.id}] (now ${r.count}).`);
1118
+ if (r.duplicate) return send(`Already had that one [${r.id}].`);
1119
+ return send(`Couldn't add it: ${r.reason}.`);
1120
+ }
1121
+
1122
+ if (subCmd === "remove" || subCmd === "rm") {
1123
+ const key = restArr.join(" ").trim();
1124
+ if (!key) return send("Usage: /ideas remove <id>");
1125
+ return send(ideasLib.removeIdea(key) ? `Removed ${key}.` : `No matching idea: ${key}`);
1126
+ }
1127
+
1128
+ const all = ideasLib.listIdeas();
1129
+ if (all.length === 0) {
1130
+ return send(`No ideas yet. The nightly dream jots self-improvement ideas here from the day's work. Add one manually: /ideas add <idea>\n\nFile: ${ideasLib.IDEAS_FILE}`);
1131
+ }
1132
+ const lines = all.slice(0, 30).map((i) => `💡 [${i.id}] ${i.date ? i.date + " " : ""}${i.scope ? "(" + i.scope + ") " : ""}${i.text}`);
1133
+ let msg = `Ideas backlog (${all.length}/${ideasLib.MAX_IDEAS}):\n\n${lines.join("\n")}`;
1134
+ if (all.length > 30) msg += `\n\n… and ${all.length - 30} more — see ${ideasLib.IDEAS_FILE}`;
1135
+ msg += `\n\n/ideas add <idea> · /ideas remove <id>`;
1136
+ return send(msg);
1137
+ },
1138
+ });
1139
+
1060
1140
  register({
1061
1141
  name: "dreamsummary", description: "Toggle the post-dream memory summary in chat", args: "[on|off]",
1062
1142
  handler: async (env, { tail }) => {
@@ -1135,7 +1215,8 @@ register({
1135
1215
  `The user typed /learn: capture the most recent substantial piece of work in this conversation as a reusable skill.${hint} ` +
1136
1216
  `Skills live in context packs (~/.open-claudia/packs/<dir>/PACK.md), NOT the legacy ~/.claude/skills dir. ` +
1137
1217
  `First check the already-injected packs and 'open-claudia pack list' for a pack this work belongs to; if one fits, fold the reusable how-to into that pack's Procedure section (exact commands in order, prerequisites, pitfalls) and append a dated Journal line — edit the PACK.md directly. ` +
1138
- `Only if no pack fits, create a new one: 'open-claudia pack' has no create subcommand, so write ~/.open-claudia/packs/<kebab-name>/PACK.md following the four-section format (Stance, Procedure, State, Journal) with name + a when-to-use description in the frontmatter. ` +
1218
+ `Only if no pack fits, create a new one: 'open-claudia pack' has no create subcommand, so write ~/.open-claudia/packs/<kebab-name>/PACK.md following the four-section format (Stance, Procedure, State, Journal) with name + a when-to-use description in the frontmatter. Give it an ACTIVITY-oriented name and description (what you DO — the verbs, tools, and artifacts) so it is findable from other projects by the work being done, not by a project name; and if this how-to came from a specific project, add a 'learned_on: <project-pack-dir>' frontmatter line so its cross-project reuse can be tracked. ` +
1219
+ `Then mark that pack as a reusable skill so it is always surfaced in future conversations: run 'open-claudia pack skill <dir> on'. This flags it as a reusable ability and adds it to the always-on skill index (name + description); the Procedure still loads on demand. ` +
1139
1220
  `No secrets or tokens. When done, reply with one short line naming the pack you created or updated and what it covers. ` +
1140
1221
  `If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
1141
1222
  await runClaude(prompt, currentState().currentSession.dir, env.messageId);
@@ -1406,6 +1487,54 @@ register({
1406
1487
 
1407
1488
  module.exports.PENDING_VAULT_TTL_MS = PENDING_VAULT_TTL_MS;
1408
1489
 
1490
+ // ── Keyring (operational, plaintext) ───────────────────────────────
1491
+ // Unlike the vault, the keyring is always available to the agent (no
1492
+ // password, no lock) so it can repeat work you've shown it once. Use it for
1493
+ // creds the agent is allowed to use unattended; use /vault for personal
1494
+ // secrets it must not. Values are injected into the agent's env each turn.
1495
+
1496
+ register({
1497
+ name: "keyring", description: "Manage operational credentials (plaintext, agent-usable)", args: "[set|get|remove] ...",
1498
+ handler: async (env, { tail }) => {
1499
+ if (!authorized(env)) return;
1500
+
1501
+ if (!tail) {
1502
+ const entries = keyring.list();
1503
+ const names = Object.keys(entries);
1504
+ if (names.length === 0) {
1505
+ return send("Keyring is empty.\n\nStore a credential the agent can reuse:\n/keyring set <name> <value>\n\n(For personal secrets the agent should NOT use on its own, use /vault instead.)");
1506
+ }
1507
+ return send("Keyring (available to the agent as env vars):\n\n" + names.map((k) => `${k}: ${entries[k]}`).join("\n") + "\n\nGet a value: /keyring get <name>");
1508
+ }
1509
+
1510
+ const setMatch = tail.match(/^set\s+(\S+)\s+([\s\S]+)$/);
1511
+ if (setMatch) {
1512
+ await deleteMessage(env.messageId);
1513
+ if (!ownerEnv(env)) return send("Owner only.");
1514
+ const value = setMatch[2].trim();
1515
+ keyring.set(setMatch[1], value);
1516
+ registerSecrets([value]);
1517
+ return send(`Stored ${setMatch[1]}. I deleted your message. It's available to the agent from the next turn.`);
1518
+ }
1519
+
1520
+ const getMatch = tail.match(/^get\s+(\S+)$/);
1521
+ if (getMatch) {
1522
+ if (!ownerEnv(env)) return send("Owner only.");
1523
+ const value = keyring.get(getMatch[1]);
1524
+ if (value === null) return send(`No such key: ${getMatch[1]}`);
1525
+ return send(`${getMatch[1]}: ${value}\n\n(Plaintext — delete this message when done.)`);
1526
+ }
1527
+
1528
+ const removeMatch = tail.match(/^remove\s+(\S+)$/);
1529
+ if (removeMatch) {
1530
+ if (!ownerEnv(env)) return send("Owner only.");
1531
+ return send(keyring.remove(removeMatch[1]) ? `Removed ${removeMatch[1]}.` : `No such key: ${removeMatch[1]}`);
1532
+ }
1533
+
1534
+ send("Usage: /keyring | /keyring set <name> <value> | /keyring get <name> | /keyring remove <name>");
1535
+ },
1536
+ });
1537
+
1409
1538
  // ── Cron ───────────────────────────────────────────────────────────
1410
1539
 
1411
1540
  register({
package/core/ideas.js ADDED
@@ -0,0 +1,114 @@
1
+ // ideas.md — a self-improvement backlog. While the nightly dream reviews the
2
+ // day's seeds and introspects on its own current self, it jots ideas here:
3
+ // things that could improve Open Claudia itself, or follow-ups for the work
4
+ // done that day. Unlike LESSONS (binding rules, always injected) ideas are
5
+ // suggestions — a backlog the user and future dreams can act on. So ideas are
6
+ // NOT injected into every turn; they surface via /ideas and the dream summary.
7
+ //
8
+ // Newest first. User-editable (/ideas or the file directly). No secrets.
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const crypto = require("crypto");
13
+ const CONFIG_DIR = require("../config-dir");
14
+ const { dayStamp } = require("./day-seeds");
15
+
16
+ const IDEAS_FILE = process.env.IDEAS_FILE ? path.resolve(process.env.IDEAS_FILE) : path.join(CONFIG_DIR, "ideas.md");
17
+ const MAX_IDEAS = Number(process.env.IDEAS_MAX || 100);
18
+ const MAX_IDEA_CHARS = Number(process.env.IDEA_MAX_CHARS || 400);
19
+
20
+ const FILE_HEADER = `Ideas — self-improvement backlog.
21
+ Captured by the nightly dream from the day's work, plus anything you add. One idea per bullet.
22
+ Not binding (those are lessons); these are suggestions to act on later. Scope tag: (oc) = Open Claudia itself, (work) = the projects we work on. Edit or prune freely.
23
+ `;
24
+
25
+ function normalize(t) { return String(t || "").toLowerCase().replace(/\s+/g, " ").trim(); }
26
+ function ideaId(t) { return crypto.createHash("sha1").update(normalize(t)).digest("hex").slice(0, 8); }
27
+
28
+ // Parse "- [YYYY-MM-DD] (scope) text" bullets; date + scope optional so
29
+ // hand-added bullets still parse. Dedupe by normalized text.
30
+ function parseIdeas(raw) {
31
+ const out = [];
32
+ const seen = new Set();
33
+ for (const line of String(raw || "").split("\n")) {
34
+ const m = line.match(/^\s*[-*]\s+(.*\S)\s*$/);
35
+ if (!m) continue;
36
+ let full = m[1].trim();
37
+ let date = "", scope = "";
38
+ const dm = full.match(/^\[(\d{4}-\d{2}-\d{2})\]\s*/);
39
+ if (dm) { date = dm[1]; full = full.slice(dm[0].length); }
40
+ const sm = full.match(/^\(([\w-]+)\)\s*/);
41
+ if (sm) { scope = sm[1].toLowerCase(); full = full.slice(sm[0].length); }
42
+ const text = full.trim();
43
+ if (!text) continue;
44
+ const id = ideaId(text);
45
+ if (seen.has(id)) continue;
46
+ seen.add(id);
47
+ out.push({ id, date, scope, text });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function readIdeas() {
53
+ try { return parseIdeas(fs.readFileSync(IDEAS_FILE, "utf-8")); }
54
+ catch (e) { return []; }
55
+ }
56
+
57
+ function ideaLine(i) {
58
+ const parts = [];
59
+ if (i.date) parts.push(`[${i.date}]`);
60
+ if (i.scope) parts.push(`(${i.scope})`);
61
+ parts.push(i.text);
62
+ return `- ${parts.join(" ")}`;
63
+ }
64
+
65
+ function writeIdeas(ideas) {
66
+ const body = ideas.map(ideaLine).join("\n");
67
+ const content = FILE_HEADER + "\n" + body + (body ? "\n" : "");
68
+ fs.mkdirSync(path.dirname(IDEAS_FILE), { recursive: true, mode: 0o700 });
69
+ fs.writeFileSync(IDEAS_FILE, content, { mode: 0o600 });
70
+ return IDEAS_FILE;
71
+ }
72
+
73
+ // File order is the source of truth; we keep newest-first on write.
74
+ function listIdeas() { return readIdeas(); }
75
+
76
+ function addIdea({ text, scope = "", date = "" } = {}) {
77
+ const body = String(text || "").trim();
78
+ if (!body) return { added: false, reason: "empty" };
79
+ if (body.length > MAX_IDEA_CHARS) return { added: false, reason: `too long (>${MAX_IDEA_CHARS} chars)` };
80
+ const ideas = readIdeas();
81
+ const id = ideaId(body);
82
+ if (ideas.some((i) => i.id === id)) return { added: false, duplicate: true, id };
83
+ ideas.unshift({ id, date: date || dayStamp(), scope: String(scope || "").toLowerCase(), text: body });
84
+ const trimmed = ideas.slice(0, MAX_IDEAS); // cap: keep newest
85
+ writeIdeas(trimmed);
86
+ return { added: true, id, count: trimmed.length, dropped: ideas.length - trimmed.length };
87
+ }
88
+
89
+ function removeIdea(idOrText) {
90
+ const key = String(idOrText || "").trim();
91
+ if (!key) return false;
92
+ const ideas = readIdeas();
93
+ const id = ideas.some((i) => i.id === key) ? key : ideaId(key);
94
+ const next = ideas.filter((i) => i.id !== id);
95
+ if (next.length === ideas.length) return false;
96
+ writeIdeas(next);
97
+ return true;
98
+ }
99
+
100
+ function saveIdeasRaw(raw) {
101
+ const text = String(raw || "");
102
+ if (text.length > MAX_IDEA_CHARS * MAX_IDEAS * 2) throw new Error(`ideas file too large (${text.length} chars); trim it`);
103
+ fs.mkdirSync(path.dirname(IDEAS_FILE), { recursive: true, mode: 0o700 });
104
+ writeIdeas(parseIdeas(text)); // canonicalize through the parser
105
+ return IDEAS_FILE;
106
+ }
107
+
108
+ function ideasExist() { return readIdeas().length > 0; }
109
+
110
+ module.exports = {
111
+ IDEAS_FILE, MAX_IDEAS, MAX_IDEA_CHARS,
112
+ normalize, ideaId, parseIdeas,
113
+ readIdeas, listIdeas, addIdea, removeIdea, saveIdeasRaw, ideasExist,
114
+ };
@@ -0,0 +1,79 @@
1
+ // Operational keyring: a plaintext, permissioned credential store for the
2
+ // agent's day-to-day work — the API keys, base URLs, account IDs and tokens
3
+ // it needs to *repeat* a task you've already shown it once.
4
+ //
5
+ // Why plaintext (and not the vault)? The vault (vault.js) uses real AES-GCM
6
+ // crypto because it holds personal secrets the bot must NOT use unattended —
7
+ // it stays locked until you type a password, and clears on restart. That is
8
+ // exactly the wrong shape for operational creds: the agent needs them
9
+ // available every turn, with no human in the loop. An encrypted store whose
10
+ // key lives on the same box would only be obfuscation, not security. So the
11
+ // honest model here is "a managed .env": the protection is filesystem perms
12
+ // (chmod 600) + log redaction, not encryption. Keep genuine personal secrets
13
+ // in the vault; keep "creds the agent is allowed to use on its own" here.
14
+ //
15
+ // Values set here flow to every agent subprocess via botSubprocessEnv()
16
+ // (config.js), so a credential stored once is present as an env var on the
17
+ // next turn — no restart needed.
18
+
19
+ const fs = require("fs");
20
+ const path = require("path");
21
+ const CONFIG_DIR = require("../config-dir");
22
+
23
+ const KEYRING_FILE = process.env.KEYRING_FILE || path.join(CONFIG_DIR, "keyring.json");
24
+
25
+ function load() {
26
+ try {
27
+ const data = JSON.parse(fs.readFileSync(KEYRING_FILE, "utf-8"));
28
+ return data && typeof data === "object" ? data : {};
29
+ } catch (e) {
30
+ return {}; // missing or corrupt → empty keyring
31
+ }
32
+ }
33
+
34
+ function save(data) {
35
+ fs.writeFileSync(KEYRING_FILE, JSON.stringify(data, null, 2) + "\n");
36
+ try { fs.chmodSync(KEYRING_FILE, 0o600); } catch (e) { /* best effort on odd filesystems */ }
37
+ }
38
+
39
+ // Full {key: value} map — used to inject creds into subprocess env.
40
+ function all() {
41
+ return load();
42
+ }
43
+
44
+ function keys() {
45
+ return Object.keys(load());
46
+ }
47
+
48
+ function get(key) {
49
+ const v = load()[key];
50
+ return v === undefined ? null : v;
51
+ }
52
+
53
+ function set(key, value) {
54
+ const data = load();
55
+ data[String(key)] = String(value);
56
+ save(data);
57
+ return true;
58
+ }
59
+
60
+ function remove(key) {
61
+ const data = load();
62
+ if (!(key in data)) return false;
63
+ delete data[key];
64
+ save(data);
65
+ return true;
66
+ }
67
+
68
+ // Masked view for display — last 4 chars only.
69
+ function list() {
70
+ const data = load();
71
+ const out = {};
72
+ for (const [k, v] of Object.entries(data)) {
73
+ const s = String(v);
74
+ out[k] = s.length >= 4 ? "****" + s.slice(-4) : "****";
75
+ }
76
+ return out;
77
+ }
78
+
79
+ module.exports = { KEYRING_FILE, load, all, keys, get, set, remove, list };
@@ -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
+ };