@inetafrica/open-claudia 2.4.2 → 2.6.0
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 +24 -0
- package/README.md +217 -120
- package/bin/cli.js +18 -0
- package/bin/dream.js +51 -0
- package/bin/entity.js +77 -0
- package/bin/pack.js +100 -0
- package/bot.js +2 -0
- package/core/dream.js +332 -0
- package/core/entities.js +329 -0
- package/core/pack-review.js +224 -0
- package/core/packs.js +334 -0
- package/core/persona.js +51 -0
- package/core/runner.js +26 -0
- package/core/subagent.js +3 -2
- package/core/system-prompt.js +130 -15
- package/package.json +1 -1
package/bin/entity.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// CLI: inspect and manage entity notes (people/places/projects memory).
|
|
2
|
+
// open-claudia entity list
|
|
3
|
+
// open-claudia entity show <slug-or-name>
|
|
4
|
+
// open-claudia entity match "<text>" — debug the router
|
|
5
|
+
// open-claudia entity note <name> "<text>" [--type person|place|project|org|system]
|
|
6
|
+
// open-claudia entity remove <slug-or-name>
|
|
7
|
+
// open-claudia entity reindex
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const entities = require("../core/entities");
|
|
12
|
+
|
|
13
|
+
function run(args) {
|
|
14
|
+
const cmd = (args[0] || "list").toLowerCase();
|
|
15
|
+
const rest = args.slice(1);
|
|
16
|
+
|
|
17
|
+
switch (cmd) {
|
|
18
|
+
case "list": {
|
|
19
|
+
const all = entities.listEntities();
|
|
20
|
+
if (all.length === 0) return console.log(`No entities yet (${entities.ENTITIES_DIR}).`);
|
|
21
|
+
for (const e of all) {
|
|
22
|
+
const seen = e.last_seen ? ` last-seen ${e.last_seen.slice(0, 10)}` : "";
|
|
23
|
+
const aka = e.aliases.length ? ` aka ${e.aliases.join(", ")}` : "";
|
|
24
|
+
console.log(`${e.slug} (${e.type})${aka}${seen}\n ${e.description}`);
|
|
25
|
+
}
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
case "show": {
|
|
30
|
+
const e = entities.findEntity(rest.join(" "));
|
|
31
|
+
if (!e) { console.error(`No entity: ${rest.join(" ")}`); process.exitCode = 1; return; }
|
|
32
|
+
console.log(fs.readFileSync(path.join(entities.ENTITIES_DIR, e.slug + ".md"), "utf-8"));
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
case "match": {
|
|
37
|
+
const text = rest.join(" ");
|
|
38
|
+
if (!text) { console.error('Usage: entity match "<text>"'); process.exitCode = 1; return; }
|
|
39
|
+
const hits = entities.matchEntities(text, { limit: 5 });
|
|
40
|
+
if (hits.length === 0) return console.log("No matching entities.");
|
|
41
|
+
for (const h of hits) console.log(`${h.score.toFixed(2)} ${h.slug} — ${h.name}`);
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
case "note": {
|
|
46
|
+
const typeIdx = rest.indexOf("--type");
|
|
47
|
+
let type = null;
|
|
48
|
+
if (typeIdx !== -1) {
|
|
49
|
+
type = rest[typeIdx + 1] || null;
|
|
50
|
+
rest.splice(typeIdx, 2);
|
|
51
|
+
}
|
|
52
|
+
const [name, ...noteParts] = rest;
|
|
53
|
+
const note = noteParts.join(" ");
|
|
54
|
+
if (!name || !note) { console.error('Usage: entity note <name> "<text>" [--type <type>]'); process.exitCode = 1; return; }
|
|
55
|
+
const { entity, created } = entities.upsertEntity({ name, type, log: note });
|
|
56
|
+
console.log(`${created ? "Created" : "Updated"} entity ${entity.slug} (${entity.type}).`);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
case "remove": {
|
|
61
|
+
const removed = entities.removeEntity(rest.join(" "));
|
|
62
|
+
if (!removed) { console.error(`No entity: ${rest.join(" ")}`); process.exitCode = 1; return; }
|
|
63
|
+
console.log(`Removed entity ${removed.slug}.`);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case "reindex": {
|
|
68
|
+
console.log(entities.reindex() ? "Reindexed." : "Index unavailable (node:sqlite missing?).");
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
default:
|
|
73
|
+
console.log("Usage: open-claudia entity [list|show <slug>|match \"<text>\"|note <name> \"<text>\"|remove <slug>|reindex]");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { run };
|
package/bin/pack.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// CLI: inspect and manage context packs (living skills+memory docs).
|
|
2
|
+
// open-claudia pack list
|
|
3
|
+
// open-claudia pack show <dir>
|
|
4
|
+
// open-claudia pack match "<text>" — debug the router
|
|
5
|
+
// open-claudia pack migrate — fold ~/.claude/skills into packs
|
|
6
|
+
// open-claudia pack remove <dir>
|
|
7
|
+
// open-claudia pack reindex
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const packs = require("../core/packs");
|
|
12
|
+
|
|
13
|
+
function run(args) {
|
|
14
|
+
const cmd = (args[0] || "list").toLowerCase();
|
|
15
|
+
const rest = args.slice(1);
|
|
16
|
+
|
|
17
|
+
switch (cmd) {
|
|
18
|
+
case "list": {
|
|
19
|
+
const all = packs.listPacks();
|
|
20
|
+
if (all.length === 0) return console.log(`No packs yet (${packs.PACKS_DIR}).`);
|
|
21
|
+
for (const p of all) {
|
|
22
|
+
const used = p.last_used ? ` last-used ${p.last_used.slice(0, 10)}` : "";
|
|
23
|
+
console.log(`${p.dir} — ${p.name}${p.tags.length ? ` [${p.tags.join(", ")}]` : ""}${used}\n ${p.description}`);
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
case "show": {
|
|
29
|
+
const p = packs.findPack(rest[0]);
|
|
30
|
+
if (!p) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
|
|
31
|
+
console.log(fs.readFileSync(path.join(packs.PACKS_DIR, p.dir, "PACK.md"), "utf-8"));
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
case "match": {
|
|
36
|
+
const text = rest.join(" ");
|
|
37
|
+
if (!text) { console.error('Usage: pack match "<text>"'); process.exitCode = 1; return; }
|
|
38
|
+
const hits = packs.matchPacks(text, { limit: 5 });
|
|
39
|
+
if (hits.length === 0) return console.log("No matching packs.");
|
|
40
|
+
for (const h of hits) console.log(`${h.score.toFixed(2)} ${h.dir} — ${h.name}`);
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
case "remove": {
|
|
45
|
+
const removed = packs.removePack(rest[0]);
|
|
46
|
+
if (!removed) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
|
|
47
|
+
console.log(`Removed pack ${removed.dir}.`);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
case "reindex": {
|
|
52
|
+
console.log(packs.reindex() ? "Reindexed." : "Index unavailable (node:sqlite missing?).");
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case "migrate": {
|
|
57
|
+
const skills = require("../core/skills");
|
|
58
|
+
const backupDir = path.join(require("../config-dir"), "backup", "skills-pre-packs");
|
|
59
|
+
const all = skills.listSkills();
|
|
60
|
+
if (all.length === 0) return console.log("No skills to migrate.");
|
|
61
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
62
|
+
let migrated = 0, skipped = 0;
|
|
63
|
+
for (const s of all) {
|
|
64
|
+
const dir = packs.slugify(s.dir);
|
|
65
|
+
if (packs.readPack(dir)) {
|
|
66
|
+
console.log(`skip ${s.dir} (pack exists)`);
|
|
67
|
+
skipped++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const content = fs.readFileSync(s.file, "utf-8");
|
|
71
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
|
|
72
|
+
packs.createPack({
|
|
73
|
+
dir,
|
|
74
|
+
name: s.name,
|
|
75
|
+
description: s.description,
|
|
76
|
+
tags: [],
|
|
77
|
+
procedure: body,
|
|
78
|
+
journal: "Migrated from ~/.claude/skills.",
|
|
79
|
+
});
|
|
80
|
+
// Fold: move the original skill out of ~/.claude/skills so the
|
|
81
|
+
// pack is the single source of truth. Backup kept, never deleted.
|
|
82
|
+
const skillDir = path.join(skills.SKILLS_DIR, s.dir);
|
|
83
|
+
const dest = path.join(backupDir, s.dir);
|
|
84
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
85
|
+
fs.cpSync(skillDir, dest, { recursive: true });
|
|
86
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
87
|
+
console.log(`migrated ${s.dir} -> pack ${dir}`);
|
|
88
|
+
migrated++;
|
|
89
|
+
}
|
|
90
|
+
packs.reindex();
|
|
91
|
+
console.log(`Done: ${migrated} migrated, ${skipped} skipped. Originals backed up at ${backupDir}.`);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
default:
|
|
96
|
+
console.log("Usage: open-claudia pack [list|show <dir>|match \"<text>\"|migrate|remove <dir>|reindex]");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { run };
|
package/bot.js
CHANGED
|
@@ -161,6 +161,8 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
161
161
|
} catch (e) {}
|
|
162
162
|
|
|
163
163
|
initScheduler(adapters);
|
|
164
|
+
try { require("./core/dream").initDream(adapters); }
|
|
165
|
+
catch (e) { console.error("dream init failed:", e.message); }
|
|
164
166
|
|
|
165
167
|
try {
|
|
166
168
|
const lb = await loopback.start(registry);
|
package/core/dream.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Dream: scheduled memory consolidation (Phase 3). While the per-turn
|
|
2
|
+
// reviewer takes quick notes, dream is the slow pass that runs in the
|
|
3
|
+
// quiet hours: it merges packs that drifted into the same topic, builds
|
|
4
|
+
// parent/sub pack trees with umbrella summaries, tightens descriptions
|
|
5
|
+
// and tags (the FTS router matches on those, so tighter = less noise),
|
|
6
|
+
// deduplicates entities, cross-links entity notes to packs, and gently
|
|
7
|
+
// evolves the persona based on how recent work actually went. The model
|
|
8
|
+
// only RETURNS a JSON decision — all writes are applied here, every
|
|
9
|
+
// merged-away pack/entity is backed up first, and the result is
|
|
10
|
+
// reported in chat (no silent learning).
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const cron = require("node-cron");
|
|
15
|
+
|
|
16
|
+
const CONFIG_DIR = require("../config-dir");
|
|
17
|
+
const packs = require("./packs");
|
|
18
|
+
const entities = require("./entities");
|
|
19
|
+
const persona = require("./persona");
|
|
20
|
+
const { spawnSubagent } = require("./subagent");
|
|
21
|
+
|
|
22
|
+
const DREAM_MODEL = process.env.DREAM_MODEL || "sonnet";
|
|
23
|
+
const DREAM_CRON = process.env.DREAM_CRON || "0 4 * * *";
|
|
24
|
+
const MAX_PACK_CHARS = 2500;
|
|
25
|
+
const MAX_ENTITY_CHARS = 900;
|
|
26
|
+
const LIMITS = { merges: 3, umbrellas: 2, parents: 8, retag: 8, entity_merges: 3, entity_notes: 4 };
|
|
27
|
+
|
|
28
|
+
let _dreaming = false;
|
|
29
|
+
|
|
30
|
+
function enabled() {
|
|
31
|
+
return (process.env.DREAM || "on").toLowerCase() !== "off";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clip(s, n) {
|
|
35
|
+
const t = String(s || "");
|
|
36
|
+
return t.length > n ? t.slice(0, n) + "\n…[truncated]" : t;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildDreamPrompt() {
|
|
40
|
+
const allPacks = packs.listPacks();
|
|
41
|
+
const allEntities = entities.listEntities();
|
|
42
|
+
|
|
43
|
+
const packDump = allPacks.map((p) => clip([
|
|
44
|
+
`=== pack: ${p.dir}${p.parent ? ` (parent: ${p.parent})` : ""}`,
|
|
45
|
+
`name: ${p.name}`,
|
|
46
|
+
`description: ${p.description}`,
|
|
47
|
+
`tags: ${p.tags.join(", ")}`,
|
|
48
|
+
`last_used: ${p.last_used || "never"}`,
|
|
49
|
+
`Stance: ${p.sections.Stance}`,
|
|
50
|
+
`Procedure: ${p.sections.Procedure}`,
|
|
51
|
+
`State: ${p.sections.State}`,
|
|
52
|
+
`Journal:\n${p.sections.Journal}`,
|
|
53
|
+
].join("\n"), MAX_PACK_CHARS)).join("\n\n") || "(none)";
|
|
54
|
+
|
|
55
|
+
const entityDump = allEntities.map((e) => clip([
|
|
56
|
+
`=== entity: ${e.slug} (${e.type}${e.aliases.length ? `, aka ${e.aliases.join(", ")}` : ""})`,
|
|
57
|
+
`description: ${e.description}`,
|
|
58
|
+
`Notes: ${e.sections.Notes}`,
|
|
59
|
+
`Log:\n${e.sections.Log}`,
|
|
60
|
+
].join("\n"), MAX_ENTITY_CHARS)).join("\n\n") || "(none)";
|
|
61
|
+
|
|
62
|
+
return `You are the dream pass of a personal AI assistant called Open Claudia — the overnight consolidation of her long-term memory. Below is her entire memory: context packs (living topic documents) and entity notes (people/places/projects/orgs/systems), plus her current persona.
|
|
63
|
+
|
|
64
|
+
Today: ${new Date().toISOString().slice(0, 10)}
|
|
65
|
+
|
|
66
|
+
CONTEXT PACKS:
|
|
67
|
+
|
|
68
|
+
${packDump}
|
|
69
|
+
|
|
70
|
+
ENTITIES:
|
|
71
|
+
|
|
72
|
+
${entityDump}
|
|
73
|
+
|
|
74
|
+
CURRENT PERSONA:
|
|
75
|
+
|
|
76
|
+
${persona.loadPersona()}
|
|
77
|
+
|
|
78
|
+
Your job — decide what consolidation, if any, is warranted:
|
|
79
|
+
|
|
80
|
+
1. merges: packs that are clearly the SAME topic under different names get merged into one. Supply the merged Stance/Procedure/State (synthesised, not concatenated; null leaves the target's section alone) and a one-sentence journal note. Be conservative: when in doubt, do not merge.
|
|
81
|
+
2. umbrellas: when 3+ packs are siblings under one theme, create an umbrella pack whose State is a 3-6 line map of the family ("for X see pack Y"), and list its children. The umbrella is a router, not a duplicate.
|
|
82
|
+
3. parents: assign an existing pack as parent of another (sub-topic relationship) without creating anything.
|
|
83
|
+
4. retag: tighten descriptions and tags. The router FTS-matches incoming messages against name/description/tags, so generic words there cause false matches. Descriptions should be one specific line; tags specific nouns.
|
|
84
|
+
5. entity_merges: the same real-world entity recorded twice gets merged (the better slug wins).
|
|
85
|
+
6. entity_notes: rewrite an entity's Notes to be current and cross-linked — mention related packs as [[pack-dir]] and related entities by name.
|
|
86
|
+
7. persona: evolve the persona GENTLY — keep its structure and length (under 2200 chars), adjust only what recent work justifies (a new habit, a sharpened quirk). Most dreams should return null here.
|
|
87
|
+
8. report: a short chat message to the owner, written AS Open Claudia in first person — warm, a little playful, a few emojis, mobile-friendly (2-6 short lines). Say what you tidied and why it helps. If you changed nothing, say the memory is in good shape, charmingly.
|
|
88
|
+
|
|
89
|
+
Hard rules:
|
|
90
|
+
- Never invent packs/entities not listed above; reference them by exact dir/slug.
|
|
91
|
+
- Never store secrets, tokens, passwords, or credentials anywhere.
|
|
92
|
+
- Limits: ≤${LIMITS.merges} merges, ≤${LIMITS.umbrellas} umbrellas, ≤${LIMITS.parents} parents, ≤${LIMITS.retag} retags, ≤${LIMITS.entity_merges} entity merges, ≤${LIMITS.entity_notes} entity note rewrites.
|
|
93
|
+
- An empty decision is a perfectly good decision.
|
|
94
|
+
|
|
95
|
+
Reply with ONLY a JSON object, no prose, no code fences:
|
|
96
|
+
{"merges": [{"into": "<dir>", "from": ["<dir>"], "stance": null, "procedure": null, "state": null, "journal": "<one sentence>"}],
|
|
97
|
+
"umbrellas": [{"dir": "<new-kebab-slug>", "name": "<title>", "description": "<one specific line>", "tags": ["..."], "state": "<3-6 line family map>", "children": ["<dir>"]}],
|
|
98
|
+
"parents": [{"pack": "<dir>", "parent": "<dir>"}],
|
|
99
|
+
"retag": [{"pack": "<dir>", "description": "<one specific line>", "tags": ["..."]}],
|
|
100
|
+
"entity_merges": [{"into": "<slug>", "from": ["<slug>"], "notes": "<merged Notes or null>"}],
|
|
101
|
+
"entity_notes": [{"entity": "<slug>", "notes": "<rewritten Notes>"}],
|
|
102
|
+
"persona": null,
|
|
103
|
+
"report": "<chat message>"}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseDream(text) {
|
|
107
|
+
const raw = String(text || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
|
|
108
|
+
const start = raw.indexOf("{");
|
|
109
|
+
const end = raw.lastIndexOf("}");
|
|
110
|
+
if (start === -1 || end <= start) return null;
|
|
111
|
+
try {
|
|
112
|
+
const obj = JSON.parse(raw.slice(start, end + 1));
|
|
113
|
+
const arr = (k) => (Array.isArray(obj[k]) ? obj[k].slice(0, LIMITS[k] || 8) : []);
|
|
114
|
+
return {
|
|
115
|
+
merges: arr("merges"),
|
|
116
|
+
umbrellas: arr("umbrellas"),
|
|
117
|
+
parents: arr("parents"),
|
|
118
|
+
retag: arr("retag"),
|
|
119
|
+
entity_merges: arr("entity_merges"),
|
|
120
|
+
entity_notes: arr("entity_notes"),
|
|
121
|
+
persona: typeof obj.persona === "string" ? obj.persona : null,
|
|
122
|
+
report: typeof obj.report === "string" ? obj.report.trim() : "",
|
|
123
|
+
};
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function makeBackupRoot() {
|
|
130
|
+
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, "-");
|
|
131
|
+
return path.join(CONFIG_DIR, "backup", `dream-${stamp}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function backupPack(dir, root) {
|
|
135
|
+
fs.cpSync(path.join(packs.PACKS_DIR, dir), path.join(root, "packs", dir), { recursive: true });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function backupEntity(slug, root) {
|
|
139
|
+
const dest = path.join(root, "entities");
|
|
140
|
+
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
141
|
+
fs.copyFileSync(path.join(entities.ENTITIES_DIR, slug + ".md"), path.join(dest, slug + ".md"));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function wouldCycle(packDir, parentDir) {
|
|
145
|
+
let cur = parentDir;
|
|
146
|
+
for (let i = 0; i < 10 && cur; i++) {
|
|
147
|
+
if (cur === packDir) return true;
|
|
148
|
+
cur = packs.readPack(cur)?.parent || null;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Applies a parsed dream decision. Returns one human line per applied
|
|
154
|
+
// change; invalid items are skipped, never thrown.
|
|
155
|
+
function applyDream(decision, backupRoot) {
|
|
156
|
+
const lines = [];
|
|
157
|
+
const gone = new Set(); // dirs/slugs removed this run
|
|
158
|
+
|
|
159
|
+
for (const m of decision.merges) {
|
|
160
|
+
try {
|
|
161
|
+
const into = m?.into && packs.readPack(m.into);
|
|
162
|
+
const from = [].concat(m?.from || []).filter((d) => d && d !== m.into && !gone.has(d) && packs.readPack(d));
|
|
163
|
+
if (!into || from.length === 0) continue;
|
|
164
|
+
packs.updatePack(m.into, {
|
|
165
|
+
stance: typeof m.stance === "string" ? m.stance : "",
|
|
166
|
+
procedure: typeof m.procedure === "string" ? m.procedure : "",
|
|
167
|
+
state: typeof m.state === "string" ? m.state : "",
|
|
168
|
+
journal: m.journal || `absorbed ${from.join(", ")}`,
|
|
169
|
+
});
|
|
170
|
+
for (const d of from) {
|
|
171
|
+
backupPack(d, backupRoot);
|
|
172
|
+
packs.removePack(d);
|
|
173
|
+
gone.add(d);
|
|
174
|
+
}
|
|
175
|
+
lines.push(`📦 Merged ${from.join(" + ")} into ${m.into}`);
|
|
176
|
+
} catch (e) { console.warn(`[dream] merge failed: ${e.message}`); }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const u of decision.umbrellas) {
|
|
180
|
+
try {
|
|
181
|
+
const dir = packs.slugify(u?.dir || u?.name);
|
|
182
|
+
if (!dir || gone.has(dir)) continue;
|
|
183
|
+
const children = [].concat(u?.children || []).filter((d) => d && d !== dir && !gone.has(d) && packs.readPack(d));
|
|
184
|
+
if (children.length < 2) continue;
|
|
185
|
+
if (!packs.readPack(dir)) {
|
|
186
|
+
packs.createPack({
|
|
187
|
+
dir,
|
|
188
|
+
name: u.name || dir,
|
|
189
|
+
description: u.description || "",
|
|
190
|
+
tags: Array.isArray(u.tags) ? u.tags.slice(0, 6) : [],
|
|
191
|
+
state: u.state || "",
|
|
192
|
+
journal: `umbrella created over ${children.join(", ")}`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
for (const c of children) {
|
|
196
|
+
const child = packs.readPack(c);
|
|
197
|
+
if (!child || wouldCycle(c, dir)) continue;
|
|
198
|
+
child.parent = dir;
|
|
199
|
+
child.updated = new Date().toISOString();
|
|
200
|
+
packs.writePack(child);
|
|
201
|
+
}
|
|
202
|
+
lines.push(`🌂 Umbrella pack ${dir} now covers ${children.join(", ")}`);
|
|
203
|
+
} catch (e) { console.warn(`[dream] umbrella failed: ${e.message}`); }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const p of decision.parents) {
|
|
207
|
+
try {
|
|
208
|
+
const child = p?.pack && !gone.has(p.pack) && packs.readPack(p.pack);
|
|
209
|
+
if (!child || !p.parent || gone.has(p.parent) || !packs.readPack(p.parent)) continue;
|
|
210
|
+
if (p.pack === p.parent || wouldCycle(p.pack, p.parent)) continue;
|
|
211
|
+
if (child.parent === p.parent) continue;
|
|
212
|
+
child.parent = p.parent;
|
|
213
|
+
child.updated = new Date().toISOString();
|
|
214
|
+
packs.writePack(child);
|
|
215
|
+
lines.push(`🌳 Filed ${p.pack} under ${p.parent}`);
|
|
216
|
+
} catch (e) { console.warn(`[dream] parent failed: ${e.message}`); }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const r of decision.retag) {
|
|
220
|
+
try {
|
|
221
|
+
if (!r?.pack || gone.has(r.pack) || !packs.readPack(r.pack)) continue;
|
|
222
|
+
const desc = typeof r.description === "string" ? r.description.trim() : "";
|
|
223
|
+
const tags = Array.isArray(r.tags) ? r.tags.map((t) => String(t).trim()).filter(Boolean).slice(0, 6) : [];
|
|
224
|
+
if (!desc && tags.length === 0) continue;
|
|
225
|
+
packs.updatePack(r.pack, { description: desc, tags });
|
|
226
|
+
lines.push(`🏷️ Sharpened ${r.pack}'s description/tags`);
|
|
227
|
+
} catch (e) { console.warn(`[dream] retag failed: ${e.message}`); }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const em of decision.entity_merges) {
|
|
231
|
+
try {
|
|
232
|
+
const into = em?.into && entities.readEntity(em.into);
|
|
233
|
+
const from = [].concat(em?.from || []).filter((s) => s && s !== em.into && !gone.has(s) && entities.readEntity(s));
|
|
234
|
+
if (!into || from.length === 0) continue;
|
|
235
|
+
for (const slug of from) {
|
|
236
|
+
const f = entities.readEntity(slug);
|
|
237
|
+
backupEntity(slug, backupRoot);
|
|
238
|
+
entities.upsertEntity({
|
|
239
|
+
name: into.name,
|
|
240
|
+
aliases: [f.name, ...f.aliases],
|
|
241
|
+
log: `merged duplicate entity ${slug} into this one`,
|
|
242
|
+
});
|
|
243
|
+
entities.removeEntity(slug);
|
|
244
|
+
gone.add(slug);
|
|
245
|
+
}
|
|
246
|
+
if (typeof em.notes === "string" && em.notes.trim()) {
|
|
247
|
+
entities.upsertEntity({ name: into.name, notes: em.notes });
|
|
248
|
+
}
|
|
249
|
+
lines.push(`👥 Merged duplicate ${from.join(", ")} into ${em.into}`);
|
|
250
|
+
} catch (e) { console.warn(`[dream] entity merge failed: ${e.message}`); }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const en of decision.entity_notes) {
|
|
254
|
+
try {
|
|
255
|
+
const ent = en?.entity && !gone.has(en.entity) && entities.readEntity(en.entity);
|
|
256
|
+
if (!ent || typeof en.notes !== "string" || !en.notes.trim()) continue;
|
|
257
|
+
entities.upsertEntity({ name: ent.name, notes: en.notes });
|
|
258
|
+
lines.push(`🔗 Refreshed notes on ${ent.name}`);
|
|
259
|
+
} catch (e) { console.warn(`[dream] entity notes failed: ${e.message}`); }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (decision.persona) {
|
|
263
|
+
try {
|
|
264
|
+
if (persona.personaExists()) {
|
|
265
|
+
fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
|
|
266
|
+
fs.copyFileSync(persona.PERSONA_FILE, path.join(backupRoot, "persona.md"));
|
|
267
|
+
}
|
|
268
|
+
persona.savePersona(decision.persona);
|
|
269
|
+
lines.push(`💫 Persona evolved a little`);
|
|
270
|
+
} catch (e) { console.warn(`[dream] persona update rejected: ${e.message}`); }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return lines;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function runDream({ trigger = "manual" } = {}) {
|
|
277
|
+
if (!enabled()) return { skipped: "dream is disabled (DREAM=off)" };
|
|
278
|
+
if (_dreaming) return { skipped: "a dream is already in progress" };
|
|
279
|
+
const packCount = packs.listPacks().length;
|
|
280
|
+
const entityCount = entities.listEntities().length;
|
|
281
|
+
if (packCount === 0 && entityCount === 0) return { skipped: "no memory to consolidate yet" };
|
|
282
|
+
|
|
283
|
+
_dreaming = true;
|
|
284
|
+
try {
|
|
285
|
+
const { text } = await spawnSubagent(buildDreamPrompt(), {
|
|
286
|
+
model: DREAM_MODEL,
|
|
287
|
+
timeoutMs: 8 * 60 * 1000,
|
|
288
|
+
systemPrompt: "You are a background memory consolidation process. Reply with ONLY the requested JSON object. No prose, no markdown, no tool use.",
|
|
289
|
+
});
|
|
290
|
+
const decision = parseDream(text);
|
|
291
|
+
if (!decision) return { skipped: "dream model returned unparseable output" };
|
|
292
|
+
|
|
293
|
+
const backupRoot = makeBackupRoot();
|
|
294
|
+
const applied = applyDream(decision, backupRoot);
|
|
295
|
+
const report = decision.report || (applied.length > 0 ? "Tidied up my memory overnight." : "");
|
|
296
|
+
|
|
297
|
+
const message = applied.length > 0
|
|
298
|
+
? `💤 ${report}\n\n${applied.join("\n")}\n\n🗄 Anything merged away is backed up under ${backupRoot}`
|
|
299
|
+
: (report ? `💤 ${report}` : "");
|
|
300
|
+
|
|
301
|
+
return { applied, report, message, trigger };
|
|
302
|
+
} finally {
|
|
303
|
+
_dreaming = false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// In-bot scheduler: one quiet-hours cron, reporting to the owner's
|
|
308
|
+
// Telegram chat. Silent (console only) when the dream changed nothing —
|
|
309
|
+
// a nightly "all tidy" ping would be noise.
|
|
310
|
+
function initDream(adapters) {
|
|
311
|
+
if (!enabled()) return;
|
|
312
|
+
if (!cron.validate(DREAM_CRON)) {
|
|
313
|
+
console.error(`dream: invalid DREAM_CRON "${DREAM_CRON}" — dream disabled`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
cron.schedule(DREAM_CRON, async () => {
|
|
317
|
+
try {
|
|
318
|
+
const res = await runDream({ trigger: "cron" });
|
|
319
|
+
if (res.skipped) { console.log(`dream: skipped — ${res.skipped}`); return; }
|
|
320
|
+
console.log(`dream: applied ${res.applied.length} change(s)`);
|
|
321
|
+
if (res.applied.length === 0) return;
|
|
322
|
+
const { CHAT_ID } = require("./config");
|
|
323
|
+
const tg = (adapters || []).find((a) => a.type === "telegram");
|
|
324
|
+
if (tg && CHAT_ID) await tg.send(CHAT_ID, res.message);
|
|
325
|
+
} catch (e) {
|
|
326
|
+
console.error("dream: run failed:", e.message);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
console.log(`dream: scheduled (${DREAM_CRON}, model ${DREAM_MODEL})`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = { runDream, initDream, buildDreamPrompt, parseDream, applyDream, enabled, DREAM_CRON, DREAM_MODEL };
|