@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/bin/cli.js
CHANGED
|
@@ -289,6 +289,21 @@ switch (command) {
|
|
|
289
289
|
break;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
case "lessons": {
|
|
293
|
+
require("./lessons").run(args.slice(1));
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
case "ideas": {
|
|
298
|
+
require("./ideas").run(args.slice(1));
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case "keyring": {
|
|
303
|
+
require("./keyring").run(args.slice(1));
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
|
|
292
307
|
case "agent": {
|
|
293
308
|
require("./agent").run(args.slice(1));
|
|
294
309
|
break;
|
|
@@ -351,6 +366,9 @@ Memory tools:
|
|
|
351
366
|
(alias: tw; --help for options)
|
|
352
367
|
open-claudia pack list|show|match|archive|restore|archived Context packs: living topic docs (skills + memory)
|
|
353
368
|
open-claudia entity list|show|match|note Entity notes: people/places/projects memory
|
|
369
|
+
open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
|
|
370
|
+
open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
|
|
371
|
+
open-claudia keyring list|get|set|remove Operational creds the agent can reuse (plaintext; vault for secrets)
|
|
354
372
|
open-claudia dream [--dry-run] Run the memory consolidation pass now
|
|
355
373
|
|
|
356
374
|
Background work (only inside an active bot-spawned task):
|
package/bin/ideas.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// CLI: inspect and manage the self-improvement ideas backlog (ideas.md).
|
|
2
|
+
// open-claudia ideas list — show all ideas (newest first)
|
|
3
|
+
// open-claudia ideas add "<idea>" [--scope oc|work] — add one
|
|
4
|
+
// open-claudia ideas remove <id|text> — remove one
|
|
5
|
+
// open-claudia ideas show — raw file
|
|
6
|
+
// open-claudia ideas path — print the file path
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const ideas = require("../core/ideas");
|
|
10
|
+
|
|
11
|
+
function run(args) {
|
|
12
|
+
const cmd = (args[0] || "list").toLowerCase();
|
|
13
|
+
const rest = args.slice(1);
|
|
14
|
+
|
|
15
|
+
switch (cmd) {
|
|
16
|
+
case "list": {
|
|
17
|
+
const all = ideas.listIdeas();
|
|
18
|
+
if (all.length === 0) return console.log(`No ideas yet (${ideas.IDEAS_FILE}).`);
|
|
19
|
+
console.log(`${all.length} idea(s) — self-improvement backlog (newest first):\n`);
|
|
20
|
+
for (const i of all) {
|
|
21
|
+
const date = i.date ? `${i.date} ` : "";
|
|
22
|
+
const scope = i.scope ? `(${i.scope}) ` : "";
|
|
23
|
+
console.log(`[${i.id}] ${date}${scope}${i.text}`);
|
|
24
|
+
}
|
|
25
|
+
console.log(`\nCap ${ideas.MAX_IDEAS}; edit: ${ideas.IDEAS_FILE}`);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
case "add": {
|
|
30
|
+
let scope = "";
|
|
31
|
+
const words = [];
|
|
32
|
+
for (let i = 0; i < rest.length; i++) {
|
|
33
|
+
if (rest[i] === "--scope") { scope = rest[++i] || ""; continue; }
|
|
34
|
+
words.push(rest[i]);
|
|
35
|
+
}
|
|
36
|
+
const text = words.join(" ").trim();
|
|
37
|
+
if (!text) { console.error('Usage: ideas add "<idea>" [--scope oc|work]'); process.exitCode = 1; return; }
|
|
38
|
+
const r = ideas.addIdea({ text, scope });
|
|
39
|
+
if (r.added) console.log(`Added idea [${r.id}] (now ${r.count}).`);
|
|
40
|
+
else if (r.duplicate) console.log(`Already had that one [${r.id}].`);
|
|
41
|
+
else { console.error(`Not added: ${r.reason}`); process.exitCode = 1; }
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
case "remove":
|
|
46
|
+
case "rm": {
|
|
47
|
+
const key = rest.join(" ").trim();
|
|
48
|
+
if (!key) { console.error("Usage: ideas remove <id|text>"); process.exitCode = 1; return; }
|
|
49
|
+
console.log(ideas.removeIdea(key) ? `Removed ${key}.` : `No matching idea: ${key}`);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "show": {
|
|
54
|
+
let raw = "";
|
|
55
|
+
try { raw = fs.readFileSync(ideas.IDEAS_FILE, "utf-8"); } catch (e) { raw = "(no ideas file yet)"; }
|
|
56
|
+
console.log(`=== ${ideas.IDEAS_FILE} ===\n${raw}`);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
case "path":
|
|
61
|
+
console.log(ideas.IDEAS_FILE);
|
|
62
|
+
break;
|
|
63
|
+
|
|
64
|
+
default:
|
|
65
|
+
console.log('Usage: open-claudia ideas [list|add "<idea>" [--scope oc|work]|remove <id|text>|show|path]');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { run };
|
package/bin/keyring.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// CLI: inspect and manage the operational keyring — plaintext creds the
|
|
2
|
+
// agent is allowed to use on its own (API keys, base URLs, account IDs).
|
|
3
|
+
// open-claudia keyring list — show all keys (values masked)
|
|
4
|
+
// open-claudia keyring get <name> — print the full value (local use)
|
|
5
|
+
// open-claudia keyring set <name> <value> — store/replace a credential
|
|
6
|
+
// open-claudia keyring remove <name> — delete one
|
|
7
|
+
// open-claudia keyring path — print the file path
|
|
8
|
+
//
|
|
9
|
+
// For genuine personal secrets the bot should NOT use unattended, use the
|
|
10
|
+
// vault (/vault) instead — it stays encrypted and locked.
|
|
11
|
+
|
|
12
|
+
const keyring = require("../core/keyring");
|
|
13
|
+
|
|
14
|
+
function run(args) {
|
|
15
|
+
const cmd = (args[0] || "list").toLowerCase();
|
|
16
|
+
const rest = args.slice(1);
|
|
17
|
+
|
|
18
|
+
switch (cmd) {
|
|
19
|
+
case "list": {
|
|
20
|
+
const entries = keyring.list();
|
|
21
|
+
const names = Object.keys(entries);
|
|
22
|
+
if (names.length === 0) return console.log(`Keyring is empty (${keyring.KEYRING_FILE}).`);
|
|
23
|
+
console.log(`${names.length} credential(s) — available to the agent as env vars:\n`);
|
|
24
|
+
for (const k of names) console.log(`${k} = ${entries[k]}`);
|
|
25
|
+
console.log(`\nFile: ${keyring.KEYRING_FILE}`);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
case "get": {
|
|
30
|
+
const key = rest[0];
|
|
31
|
+
if (!key) { console.error("Usage: keyring get <name>"); process.exitCode = 1; return; }
|
|
32
|
+
const v = keyring.get(key);
|
|
33
|
+
if (v === null) { console.error(`No such key: ${key}`); process.exitCode = 1; return; }
|
|
34
|
+
console.log(v);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
case "set": {
|
|
39
|
+
const key = rest[0];
|
|
40
|
+
const value = rest.slice(1).join(" ").trim();
|
|
41
|
+
if (!key || !value) { console.error('Usage: keyring set <name> <value>'); process.exitCode = 1; return; }
|
|
42
|
+
keyring.set(key, value);
|
|
43
|
+
console.log(`Stored ${key} (available to the agent next turn).`);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case "remove":
|
|
48
|
+
case "rm": {
|
|
49
|
+
const key = rest[0];
|
|
50
|
+
if (!key) { console.error("Usage: keyring remove <name>"); process.exitCode = 1; return; }
|
|
51
|
+
console.log(keyring.remove(key) ? `Removed ${key}.` : `No such key: ${key}`);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
case "path":
|
|
56
|
+
console.log(keyring.KEYRING_FILE);
|
|
57
|
+
break;
|
|
58
|
+
|
|
59
|
+
default:
|
|
60
|
+
console.log('Usage: open-claudia keyring [list|get <name>|set <name> <value>|remove <name>|path]');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { run };
|
package/bin/lessons.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// CLI: inspect and manage the always-loaded Lessons tier.
|
|
2
|
+
// open-claudia lessons list — show all lessons
|
|
3
|
+
// open-claudia lessons add "<rule>" [--src <pack>] — add one (origin=user)
|
|
4
|
+
// open-claudia lessons remove <id|text> — remove one
|
|
5
|
+
// open-claudia lessons show — raw file + injected block
|
|
6
|
+
// open-claudia lessons path — print the file path
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const lessons = require("../core/lessons");
|
|
10
|
+
|
|
11
|
+
function run(args) {
|
|
12
|
+
const cmd = (args[0] || "list").toLowerCase();
|
|
13
|
+
const rest = args.slice(1);
|
|
14
|
+
|
|
15
|
+
switch (cmd) {
|
|
16
|
+
case "list": {
|
|
17
|
+
const all = lessons.listLessons();
|
|
18
|
+
if (all.length === 0) return console.log(`No lessons yet (${lessons.LESSONS_FILE}).`);
|
|
19
|
+
console.log(`${all.length} lesson(s) — always loaded into every conversation:\n`);
|
|
20
|
+
for (const l of all) {
|
|
21
|
+
const r = l.reinforced ? ` · reinforced ${l.reinforced}×` : "";
|
|
22
|
+
const src = l.src ? ` · src: ${l.src}` : "";
|
|
23
|
+
console.log(`[${l.id}] ${l.text}\n origin: ${l.origin || "user"}${src}${r}`);
|
|
24
|
+
}
|
|
25
|
+
console.log(`\nCap ${lessons.MAX_LESSONS}; edit: ${lessons.LESSONS_FILE}`);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
case "add": {
|
|
30
|
+
// Pull optional --src <pack>, rest is the lesson text.
|
|
31
|
+
let src = "";
|
|
32
|
+
const words = [];
|
|
33
|
+
for (let i = 0; i < rest.length; i++) {
|
|
34
|
+
if (rest[i] === "--src") { src = rest[++i] || ""; continue; }
|
|
35
|
+
words.push(rest[i]);
|
|
36
|
+
}
|
|
37
|
+
const text = words.join(" ").trim();
|
|
38
|
+
if (!text) { console.error('Usage: lessons add "<rule>" [--src <pack>]'); process.exitCode = 1; return; }
|
|
39
|
+
const r = lessons.addLesson({ text, src, origin: "user" });
|
|
40
|
+
if (r.added) console.log(`Added lesson [${r.id}]${r.overCap ? ` (now ${r.count}, over cap ${lessons.MAX_LESSONS} — the nightly dream will tidy)` : ""}.`);
|
|
41
|
+
else if (r.reinforced) console.log(`Already had that one — reinforced [${r.id}].`);
|
|
42
|
+
else { console.error(`Not added: ${r.reason}`); process.exitCode = 1; }
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
case "remove":
|
|
47
|
+
case "rm": {
|
|
48
|
+
const key = rest.join(" ").trim();
|
|
49
|
+
if (!key) { console.error("Usage: lessons remove <id|text>"); process.exitCode = 1; return; }
|
|
50
|
+
console.log(lessons.removeLesson(key) ? `Removed ${key}.` : `No matching lesson: ${key}`);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
case "show": {
|
|
55
|
+
let raw = "";
|
|
56
|
+
try { raw = fs.readFileSync(lessons.LESSONS_FILE, "utf-8"); } catch (e) { raw = "(no lessons file yet)"; }
|
|
57
|
+
console.log(`=== ${lessons.LESSONS_FILE} ===\n${raw}`);
|
|
58
|
+
const block = lessons.loadLessonsBlock();
|
|
59
|
+
console.log(`\n=== injected block (${block.length} chars, budget ${lessons.MAX_LESSONS_CHARS}) ===\n${block || "(empty — nothing injected)"}`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case "path":
|
|
64
|
+
console.log(lessons.LESSONS_FILE);
|
|
65
|
+
break;
|
|
66
|
+
|
|
67
|
+
default:
|
|
68
|
+
console.log('Usage: open-claudia lessons [list|add "<rule>" [--src <pack>]|remove <id|text>|show|path]');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { run };
|
package/bin/pack.js
CHANGED
|
@@ -20,13 +20,56 @@ function run(args) {
|
|
|
20
20
|
if (all.length === 0) return console.log(`No packs yet (${packs.PACKS_DIR}).`);
|
|
21
21
|
for (const p of all) {
|
|
22
22
|
const used = p.last_used ? ` last-used ${p.last_used.slice(0, 10)} (${p.usage_count || 0}×)` : "";
|
|
23
|
-
|
|
23
|
+
const skill = p.skill ? " ★skill" : "";
|
|
24
|
+
const ability = p.kind === "ability" ? " ◆ability" : "";
|
|
25
|
+
console.log(`${p.dir} — ${p.name}${skill}${ability}${p.tags.length ? ` [${p.tags.join(", ")}]` : ""}${used}\n ${p.description}`);
|
|
24
26
|
}
|
|
25
27
|
const archived = packs.listArchived();
|
|
26
28
|
if (archived.length) console.log(`\n(${archived.length} archived — open-claudia pack archived)`);
|
|
27
29
|
break;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
case "skills": {
|
|
33
|
+
const all = packs.listSkillPacks();
|
|
34
|
+
if (all.length === 0) return console.log("No skill-flagged packs yet. Flag one: open-claudia pack skill <dir> on");
|
|
35
|
+
console.log(`${all.length} skill(s) — always surfaced in the system prompt:\n`);
|
|
36
|
+
for (const p of all) console.log(`${p.dir} — ${p.name}\n ${p.description}`);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
case "skill": {
|
|
41
|
+
const dir = rest[0];
|
|
42
|
+
const on = (rest[1] || "on").toLowerCase() !== "off";
|
|
43
|
+
if (!dir) { console.error("Usage: pack skill <dir> [on|off]"); process.exitCode = 1; return; }
|
|
44
|
+
const p = packs.setSkill(dir, on);
|
|
45
|
+
if (!p) { console.error(`No pack: ${dir}`); process.exitCode = 1; return; }
|
|
46
|
+
console.log(`${p.dir} skill flag → ${on ? "on (now always indexed)" : "off"}.`);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
case "abilities": {
|
|
51
|
+
const all = packs.listAbilities();
|
|
52
|
+
if (all.length === 0) return console.log("No abilities yet. Mark one: open-claudia pack kind <dir> ability");
|
|
53
|
+
console.log(`${all.length} ability(ies) — reusable how-tos that transfer across projects:\n`);
|
|
54
|
+
for (const p of all) {
|
|
55
|
+
const prov = p.learned_on
|
|
56
|
+
? `\n learned on ${p.learned_on}${p.applied_on.length ? `, applied on ${p.applied_on.join(", ")}` : ""}`
|
|
57
|
+
: "";
|
|
58
|
+
console.log(`${p.dir} — ${p.name}${p.skill ? " ★always-on" : ""}\n ${p.description}${prov}`);
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case "kind": {
|
|
64
|
+
const dir = rest[0];
|
|
65
|
+
const kind = rest[1];
|
|
66
|
+
if (!dir || !kind) { console.error("Usage: pack kind <dir> <ability|context>"); process.exitCode = 1; return; }
|
|
67
|
+
const p = packs.setKind(dir, kind);
|
|
68
|
+
if (!p) { console.error(`No pack: ${dir}`); process.exitCode = 1; return; }
|
|
69
|
+
console.log(`${p.dir} kind → ${p.kind}.`);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
30
73
|
case "archive": {
|
|
31
74
|
const p = packs.archivePack(rest[0]);
|
|
32
75
|
if (!p) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
|
|
@@ -123,7 +166,7 @@ function run(args) {
|
|
|
123
166
|
}
|
|
124
167
|
|
|
125
168
|
default:
|
|
126
|
-
console.log("Usage: open-claudia pack [list|show <dir>|match \"<text>\"|migrate|remove <dir>|archive <dir>|restore <dir>|archived|reindex]");
|
|
169
|
+
console.log("Usage: open-claudia pack [list|skills|abilities|skill <dir> [on|off]|kind <dir> <ability|context>|show <dir>|match \"<text>\"|migrate|remove <dir>|archive <dir>|restore <dir>|archived|reindex]");
|
|
127
170
|
}
|
|
128
171
|
}
|
|
129
172
|
|
package/bot.js
CHANGED
|
@@ -23,6 +23,14 @@ require("./core/handlers"); // side-effect: register slash commands
|
|
|
23
23
|
|
|
24
24
|
const CURRENT_VERSION = require(path.join(__dirname, "package.json")).version;
|
|
25
25
|
|
|
26
|
+
// Register existing operational-keyring values so the redactor scrubs them
|
|
27
|
+
// from any output we ship to chat. (New ones are registered on /keyring set.)
|
|
28
|
+
try {
|
|
29
|
+
const keyring = require("./core/keyring");
|
|
30
|
+
const { registerSecrets } = require("./core/redact");
|
|
31
|
+
registerSecrets(Object.values(keyring.all()));
|
|
32
|
+
} catch (e) { /* keyring optional — never block startup */ }
|
|
33
|
+
|
|
26
34
|
registry.setHandlers({ onMessage, onAction });
|
|
27
35
|
const adapters = registry.bootstrap();
|
|
28
36
|
|
package/core/actions.js
CHANGED
|
@@ -277,9 +277,11 @@ async function handleAction(envelope) {
|
|
|
277
277
|
if (d.startsWith("eng:")) {
|
|
278
278
|
const v = d.slice(4);
|
|
279
279
|
const recall = require("./recall");
|
|
280
|
-
|
|
280
|
+
// The default engine is stored as null (so it shows "(default)"); any other
|
|
281
|
+
// known engine is stored explicitly as an opt-out.
|
|
282
|
+
state.settings.recallEngine = (v === recall.DEFAULT_ENGINE || !recall.listEngines().includes(v)) ? null : v;
|
|
281
283
|
saveState();
|
|
282
|
-
await send(`Recall engine: ${state.settings.recallEngine
|
|
284
|
+
await send(`Recall engine: ${recall.activeEngineName(state.settings)}${state.settings.recallEngine ? "" : " (default)"}`);
|
|
283
285
|
return;
|
|
284
286
|
}
|
|
285
287
|
if (d.startsWith("rcl:")) {
|
package/core/config.js
CHANGED
|
@@ -200,7 +200,16 @@ function loadChannels() {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
function botSubprocessEnv() {
|
|
203
|
-
|
|
203
|
+
const base = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
|
|
204
|
+
// Merge operational keyring creds so the agent can *use* what it remembered.
|
|
205
|
+
// Read fresh each spawn so a cred set mid-session is live next turn without a
|
|
206
|
+
// restart. process.env wins on conflict — bot infra config is authoritative.
|
|
207
|
+
try {
|
|
208
|
+
const keyring = require("./keyring");
|
|
209
|
+
return { ...keyring.all(), ...base };
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return base;
|
|
212
|
+
}
|
|
204
213
|
}
|
|
205
214
|
|
|
206
215
|
module.exports = {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Per-day seed log. The compaction engine already archives each full brief to
|
|
2
|
+
// briefs/ (one file per compaction). This module keeps a per-DAY rollup — one
|
|
3
|
+
// file per calendar day, global across projects/channels — that appends the
|
|
4
|
+
// condensed "what seeds the next conversation" digest every time a compaction
|
|
5
|
+
// fires. It is the raw material the nightly dream reads to learn what was
|
|
6
|
+
// actually worked on that day: the input for self-introspection [3b] and for
|
|
7
|
+
// second-chance lesson promotion [1]. Detailed text stays in briefs/; this is
|
|
8
|
+
// the digest, labelled by project + time so the dream can reconstruct the day.
|
|
9
|
+
//
|
|
10
|
+
// Seeds are read-only for the dream (raw input, never edited by it) and never
|
|
11
|
+
// hold secrets beyond whatever the compaction summary already contains.
|
|
12
|
+
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const CONFIG_DIR = require("../config-dir");
|
|
16
|
+
|
|
17
|
+
const SEEDS_DIR = process.env.DAY_SEEDS_DIR ? path.resolve(process.env.DAY_SEEDS_DIR) : path.join(CONFIG_DIR, "seeds");
|
|
18
|
+
// Safety cap on a single day's file; if exceeded we trim from the front so the
|
|
19
|
+
// most recent compactions always survive.
|
|
20
|
+
const MAX_DAY_SEED_CHARS = Number(process.env.DAY_SEED_MAX_CHARS || 60000);
|
|
21
|
+
|
|
22
|
+
// Local calendar day (YYYY-MM-DD). Local, not UTC, so "what I worked on today"
|
|
23
|
+
// lines up with the user's day and the 4am dream reads the right file.
|
|
24
|
+
function dayStamp(d = new Date()) {
|
|
25
|
+
const y = d.getFullYear();
|
|
26
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
27
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
28
|
+
return `${y}-${m}-${day}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function daySeedPath(date) {
|
|
32
|
+
return path.join(SEEDS_DIR, `${date || dayStamp()}.md`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Append one compaction digest to today's seed file. Best-effort: never throws
|
|
36
|
+
// into the compaction path (a seed-log failure must not break compaction).
|
|
37
|
+
function appendDaySeed(entry = {}) {
|
|
38
|
+
try {
|
|
39
|
+
const body = String(entry.summary || "").trim();
|
|
40
|
+
if (!body) return null;
|
|
41
|
+
fs.mkdirSync(SEEDS_DIR, { recursive: true, mode: 0o700 });
|
|
42
|
+
const when = entry.when ? new Date(entry.when) : new Date();
|
|
43
|
+
const date = dayStamp(when);
|
|
44
|
+
const file = daySeedPath(date);
|
|
45
|
+
const head = [
|
|
46
|
+
`## ${when.toISOString()}`,
|
|
47
|
+
entry.project ? `Project: ${entry.project}` : null,
|
|
48
|
+
entry.channel ? `Channel: ${entry.channel}` : null,
|
|
49
|
+
entry.briefPath ? `Full brief: ${entry.briefPath}` : null,
|
|
50
|
+
"",
|
|
51
|
+
].filter((l) => l !== null).join("\n");
|
|
52
|
+
const block = `${head}${body}\n\n`;
|
|
53
|
+
|
|
54
|
+
let existing = "";
|
|
55
|
+
try { existing = fs.readFileSync(file, "utf-8"); } catch (e) {}
|
|
56
|
+
const fileHeader = `# Day seed — ${date}\nCondensed compaction digests captured this day. The nightly dream reviews these to learn what was worked on.\n\n`;
|
|
57
|
+
let next = existing ? existing + block : fileHeader + block;
|
|
58
|
+
if (next.length > MAX_DAY_SEED_CHARS) next = next.slice(next.length - MAX_DAY_SEED_CHARS);
|
|
59
|
+
fs.writeFileSync(file, next, { mode: 0o600 });
|
|
60
|
+
return file;
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function listDaySeedDates() {
|
|
67
|
+
try {
|
|
68
|
+
return fs.readdirSync(SEEDS_DIR)
|
|
69
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
|
|
70
|
+
.map((f) => f.replace(/\.md$/, ""))
|
|
71
|
+
.sort();
|
|
72
|
+
} catch (e) { return []; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readDaySeed(date) {
|
|
76
|
+
try { return fs.readFileSync(daySeedPath(date), "utf-8"); }
|
|
77
|
+
catch (e) { return ""; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The dream's view: the last `days` calendar days that actually have content.
|
|
81
|
+
// The dream fires at 04:00, so "today" is usually empty and the work lives in
|
|
82
|
+
// the prior day(s); pulling a small window catches work that straddles midnight.
|
|
83
|
+
function recentDaySeeds({ days = 2 } = {}) {
|
|
84
|
+
const all = listDaySeedDates();
|
|
85
|
+
const picked = [];
|
|
86
|
+
const texts = [];
|
|
87
|
+
for (let i = all.length - 1; i >= 0 && picked.length < days; i--) {
|
|
88
|
+
const t = readDaySeed(all[i]);
|
|
89
|
+
if (t.trim()) { picked.unshift(all[i]); texts.unshift(t); }
|
|
90
|
+
}
|
|
91
|
+
return { dates: picked, text: texts.join("\n\n") };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
SEEDS_DIR, MAX_DAY_SEED_CHARS,
|
|
96
|
+
dayStamp, daySeedPath,
|
|
97
|
+
appendDaySeed, readDaySeed, listDaySeedDates, recentDaySeeds,
|
|
98
|
+
};
|