@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/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");
|
|
@@ -122,7 +123,7 @@ register({
|
|
|
122
123
|
if (!authorized(env)) return;
|
|
123
124
|
send([
|
|
124
125
|
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
125
|
-
"Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine",
|
|
126
|
+
"Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall",
|
|
126
127
|
"Identity: /whoami /link",
|
|
127
128
|
"Team: /people /intros /auth (owner)",
|
|
128
129
|
"Automation: /cron /vault /soul /dreamsummary",
|
|
@@ -702,18 +703,41 @@ 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" }],
|
|
718
|
+
] } },
|
|
719
|
+
);
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
register({
|
|
724
|
+
name: "recall", description: "Show what memory recall surfaced each turn (debug)", args: "[on|off]",
|
|
725
|
+
handler: async (env, { tail }) => {
|
|
726
|
+
if (!authorized(env)) return;
|
|
727
|
+
const { settings } = currentState();
|
|
728
|
+
if (tail) {
|
|
729
|
+
const v = tail.trim().toLowerCase();
|
|
730
|
+
if (v === "on" || v === "true") settings.showRecall = true;
|
|
731
|
+
else if (v === "off" || v === "false") settings.showRecall = false;
|
|
732
|
+
else return send(`Usage: /recall [on|off]. Currently ${settings.showRecall ? "on" : "off"}.`);
|
|
733
|
+
saveState();
|
|
734
|
+
return send(`Recall debug: ${settings.showRecall ? "on" : "off"}`);
|
|
735
|
+
}
|
|
736
|
+
send(
|
|
737
|
+
`Recall debug: ${settings.showRecall ? "on" : "off"}\n\n` +
|
|
738
|
+
"When on, I post a short \"🧠 Recall this turn\" line before each reply, showing which packs/entities surfaced (and, on the discoverer engine, why). Lets you watch recall work.",
|
|
739
|
+
{ keyboard: { inline_keyboard: [
|
|
740
|
+
[{ text: "On", callback_data: "rcl:on" }, { text: "Off", callback_data: "rcl:off" }],
|
|
717
741
|
] } },
|
|
718
742
|
);
|
|
719
743
|
},
|
|
@@ -965,7 +989,7 @@ register({
|
|
|
965
989
|
const { settings } = state;
|
|
966
990
|
const backendLabel = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
|
|
967
991
|
const cronCount = jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => j.kind === "cron").length;
|
|
968
|
-
const activeEngine = settings.recallEngine
|
|
992
|
+
const activeEngine = require("./recall").activeEngineName(settings) + (settings.recallEngine ? "" : " (default)");
|
|
969
993
|
send([
|
|
970
994
|
`Project: ${state.currentSession.name}`,
|
|
971
995
|
`Backend: ${backendLabel}`,
|
|
@@ -1034,6 +1058,77 @@ register({
|
|
|
1034
1058
|
},
|
|
1035
1059
|
});
|
|
1036
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
|
+
|
|
1037
1132
|
register({
|
|
1038
1133
|
name: "dreamsummary", description: "Toggle the post-dream memory summary in chat", args: "[on|off]",
|
|
1039
1134
|
handler: async (env, { tail }) => {
|
|
@@ -1112,7 +1207,8 @@ register({
|
|
|
1112
1207
|
`The user typed /learn: capture the most recent substantial piece of work in this conversation as a reusable skill.${hint} ` +
|
|
1113
1208
|
`Skills live in context packs (~/.open-claudia/packs/<dir>/PACK.md), NOT the legacy ~/.claude/skills dir. ` +
|
|
1114
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. ` +
|
|
1115
|
-
`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. ` +
|
|
1116
1212
|
`No secrets or tokens. When done, reply with one short line naming the pack you created or updated and what it covers. ` +
|
|
1117
1213
|
`If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
|
|
1118
1214
|
await runClaude(prompt, currentState().currentSession.dir, env.messageId);
|
|
@@ -1383,6 +1479,54 @@ register({
|
|
|
1383
1479
|
|
|
1384
1480
|
module.exports.PENDING_VAULT_TTL_MS = PENDING_VAULT_TTL_MS;
|
|
1385
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
|
+
|
|
1386
1530
|
// ── Cron ───────────────────────────────────────────────────────────
|
|
1387
1531
|
|
|
1388
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 };
|