@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.
- 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 +4 -2
- package/core/config.js +10 -1
- package/core/day-seeds.js +98 -0
- package/core/dream.js +413 -18
- package/core/handlers.js +129 -8
- 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/graph.js +17 -0
- package/core/recall/index.js +12 -5
- package/core/redact.js +25 -3
- package/core/runner.js +33 -1
- package/core/subagent.js +20 -4
- package/core/system-prompt.js +39 -0
- 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-engine.js +7 -5
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
|
|
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 === "
|
|
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
|
|
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
|
-
"•
|
|
714
|
-
"•
|
|
714
|
+
"• discoverer — typed-edge graph + spreading activation + why-bullets (default)\n" +
|
|
715
|
+
"• classic — keyword FTS + relevance judge (opt-out fallback)",
|
|
715
716
|
{ keyboard: { inline_keyboard: [
|
|
716
|
-
[{ text: "
|
|
717
|
+
[{ text: "Discoverer (default)", callback_data: "eng:discoverer" }, { text: "Classic", callback_data: "eng:classic" }],
|
|
717
718
|
] } },
|
|
718
719
|
);
|
|
719
720
|
},
|
|
@@ -988,7 +989,7 @@ register({
|
|
|
988
989
|
const { settings } = state;
|
|
989
990
|
const backendLabel = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
|
|
990
991
|
const cronCount = jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => j.kind === "cron").length;
|
|
991
|
-
const activeEngine = settings.recallEngine
|
|
992
|
+
const activeEngine = require("./recall").activeEngineName(settings) + (settings.recallEngine ? "" : " (default)");
|
|
992
993
|
send([
|
|
993
994
|
`Project: ${state.currentSession.name}`,
|
|
994
995
|
`Backend: ${backendLabel}`,
|
|
@@ -1057,6 +1058,77 @@ register({
|
|
|
1057
1058
|
},
|
|
1058
1059
|
});
|
|
1059
1060
|
|
|
1061
|
+
register({
|
|
1062
|
+
name: "lessons", description: "View/manage always-loaded learned rules", args: "[add <rule> | remove <id>]",
|
|
1063
|
+
handler: async (env, { tail }) => {
|
|
1064
|
+
if (!authorized(env)) return;
|
|
1065
|
+
const lessonsLib = require("./lessons");
|
|
1066
|
+
const trimmed = (tail || "").trim();
|
|
1067
|
+
const [sub, ...restArr] = trimmed ? trimmed.split(/\s+/) : [];
|
|
1068
|
+
const subCmd = (sub || "").toLowerCase();
|
|
1069
|
+
|
|
1070
|
+
if (subCmd === "add") {
|
|
1071
|
+
const text = restArr.join(" ").trim();
|
|
1072
|
+
if (!text) return send('Usage: /lessons add <rule>');
|
|
1073
|
+
const r = lessonsLib.addLesson({ text, origin: "user" });
|
|
1074
|
+
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.` : ""}`);
|
|
1075
|
+
if (r.reinforced) return send(`Already knew that one — reinforced [${r.id}].`);
|
|
1076
|
+
return send(`Couldn't add it: ${r.reason}.`);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (subCmd === "remove" || subCmd === "rm") {
|
|
1080
|
+
const key = restArr.join(" ").trim();
|
|
1081
|
+
if (!key) return send("Usage: /lessons remove <id>");
|
|
1082
|
+
return send(lessonsLib.removeLesson(key) ? `Removed ${key}.` : `No matching lesson: ${key}`);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const all = lessonsLib.listLessons();
|
|
1086
|
+
if (all.length === 0) {
|
|
1087
|
+
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}`);
|
|
1088
|
+
}
|
|
1089
|
+
const lines = all.map((l) => `📌 [${l.id}] ${l.text}${l.src ? `\n src: ${l.src}` : ""}${l.reinforced ? ` · reinforced ${l.reinforced}×` : ""}`);
|
|
1090
|
+
let msg = `Lessons I always load (${all.length}/${lessonsLib.MAX_LESSONS}):\n\n${lines.join("\n")}`;
|
|
1091
|
+
msg += `\n\n/lessons add <rule> · /lessons remove <id>\nFile: ${lessonsLib.LESSONS_FILE}`;
|
|
1092
|
+
return send(msg);
|
|
1093
|
+
},
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
register({
|
|
1097
|
+
name: "ideas", description: "View/manage the self-improvement backlog", args: "[add <idea> | remove <id>]",
|
|
1098
|
+
handler: async (env, { tail }) => {
|
|
1099
|
+
if (!authorized(env)) return;
|
|
1100
|
+
const ideasLib = require("./ideas");
|
|
1101
|
+
const trimmed = (tail || "").trim();
|
|
1102
|
+
const [sub, ...restArr] = trimmed ? trimmed.split(/\s+/) : [];
|
|
1103
|
+
const subCmd = (sub || "").toLowerCase();
|
|
1104
|
+
|
|
1105
|
+
if (subCmd === "add") {
|
|
1106
|
+
const text = restArr.join(" ").trim();
|
|
1107
|
+
if (!text) return send("Usage: /ideas add <idea>");
|
|
1108
|
+
const r = ideasLib.addIdea({ text });
|
|
1109
|
+
if (r.added) return send(`💡 Added idea [${r.id}] (now ${r.count}).`);
|
|
1110
|
+
if (r.duplicate) return send(`Already had that one [${r.id}].`);
|
|
1111
|
+
return send(`Couldn't add it: ${r.reason}.`);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (subCmd === "remove" || subCmd === "rm") {
|
|
1115
|
+
const key = restArr.join(" ").trim();
|
|
1116
|
+
if (!key) return send("Usage: /ideas remove <id>");
|
|
1117
|
+
return send(ideasLib.removeIdea(key) ? `Removed ${key}.` : `No matching idea: ${key}`);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const all = ideasLib.listIdeas();
|
|
1121
|
+
if (all.length === 0) {
|
|
1122
|
+
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}`);
|
|
1123
|
+
}
|
|
1124
|
+
const lines = all.slice(0, 30).map((i) => `💡 [${i.id}] ${i.date ? i.date + " " : ""}${i.scope ? "(" + i.scope + ") " : ""}${i.text}`);
|
|
1125
|
+
let msg = `Ideas backlog (${all.length}/${ideasLib.MAX_IDEAS}):\n\n${lines.join("\n")}`;
|
|
1126
|
+
if (all.length > 30) msg += `\n\n… and ${all.length - 30} more — see ${ideasLib.IDEAS_FILE}`;
|
|
1127
|
+
msg += `\n\n/ideas add <idea> · /ideas remove <id>`;
|
|
1128
|
+
return send(msg);
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1060
1132
|
register({
|
|
1061
1133
|
name: "dreamsummary", description: "Toggle the post-dream memory summary in chat", args: "[on|off]",
|
|
1062
1134
|
handler: async (env, { tail }) => {
|
|
@@ -1135,7 +1207,8 @@ register({
|
|
|
1135
1207
|
`The user typed /learn: capture the most recent substantial piece of work in this conversation as a reusable skill.${hint} ` +
|
|
1136
1208
|
`Skills live in context packs (~/.open-claudia/packs/<dir>/PACK.md), NOT the legacy ~/.claude/skills dir. ` +
|
|
1137
1209
|
`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. ` +
|
|
1210
|
+
`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. ` +
|
|
1211
|
+
`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
1212
|
`No secrets or tokens. When done, reply with one short line naming the pack you created or updated and what it covers. ` +
|
|
1140
1213
|
`If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
|
|
1141
1214
|
await runClaude(prompt, currentState().currentSession.dir, env.messageId);
|
|
@@ -1406,6 +1479,54 @@ register({
|
|
|
1406
1479
|
|
|
1407
1480
|
module.exports.PENDING_VAULT_TTL_MS = PENDING_VAULT_TTL_MS;
|
|
1408
1481
|
|
|
1482
|
+
// ── Keyring (operational, plaintext) ───────────────────────────────
|
|
1483
|
+
// Unlike the vault, the keyring is always available to the agent (no
|
|
1484
|
+
// password, no lock) so it can repeat work you've shown it once. Use it for
|
|
1485
|
+
// creds the agent is allowed to use unattended; use /vault for personal
|
|
1486
|
+
// secrets it must not. Values are injected into the agent's env each turn.
|
|
1487
|
+
|
|
1488
|
+
register({
|
|
1489
|
+
name: "keyring", description: "Manage operational credentials (plaintext, agent-usable)", args: "[set|get|remove] ...",
|
|
1490
|
+
handler: async (env, { tail }) => {
|
|
1491
|
+
if (!authorized(env)) return;
|
|
1492
|
+
|
|
1493
|
+
if (!tail) {
|
|
1494
|
+
const entries = keyring.list();
|
|
1495
|
+
const names = Object.keys(entries);
|
|
1496
|
+
if (names.length === 0) {
|
|
1497
|
+
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.)");
|
|
1498
|
+
}
|
|
1499
|
+
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>");
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const setMatch = tail.match(/^set\s+(\S+)\s+([\s\S]+)$/);
|
|
1503
|
+
if (setMatch) {
|
|
1504
|
+
await deleteMessage(env.messageId);
|
|
1505
|
+
if (!ownerEnv(env)) return send("Owner only.");
|
|
1506
|
+
const value = setMatch[2].trim();
|
|
1507
|
+
keyring.set(setMatch[1], value);
|
|
1508
|
+
registerSecrets([value]);
|
|
1509
|
+
return send(`Stored ${setMatch[1]}. I deleted your message. It's available to the agent from the next turn.`);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const getMatch = tail.match(/^get\s+(\S+)$/);
|
|
1513
|
+
if (getMatch) {
|
|
1514
|
+
if (!ownerEnv(env)) return send("Owner only.");
|
|
1515
|
+
const value = keyring.get(getMatch[1]);
|
|
1516
|
+
if (value === null) return send(`No such key: ${getMatch[1]}`);
|
|
1517
|
+
return send(`${getMatch[1]}: ${value}\n\n(Plaintext — delete this message when done.)`);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const removeMatch = tail.match(/^remove\s+(\S+)$/);
|
|
1521
|
+
if (removeMatch) {
|
|
1522
|
+
if (!ownerEnv(env)) return send("Owner only.");
|
|
1523
|
+
return send(keyring.remove(removeMatch[1]) ? `Removed ${removeMatch[1]}.` : `No such key: ${removeMatch[1]}`);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
send("Usage: /keyring | /keyring set <name> <value> | /keyring get <name> | /keyring remove <name>");
|
|
1527
|
+
},
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1409
1530
|
// ── Cron ───────────────────────────────────────────────────────────
|
|
1410
1531
|
|
|
1411
1532
|
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
|
+
};
|
package/core/keyring.js
ADDED
|
@@ -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 };
|
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
|
+
};
|