@inetafrica/open-claudia 2.6.37 → 2.6.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/dream.js CHANGED
@@ -14,9 +14,12 @@ const path = require("path");
14
14
  const cron = require("node-cron");
15
15
 
16
16
  const CONFIG_DIR = require("../config-dir");
17
- const { config } = require("./config");
17
+ const { config, DEFAULT_CLAUDE_MODEL } = require("./config");
18
18
  const packs = require("./packs");
19
19
  const entities = require("./entities");
20
+ const lessons = require("./lessons");
21
+ const ideas = require("./ideas");
22
+ const daySeeds = require("./day-seeds");
20
23
  const packGuard = require("./pack-guard");
21
24
  const persona = require("./persona");
22
25
  const { spawnSubagent } = require("./subagent");
@@ -31,13 +34,26 @@ function pickDreamModel() {
31
34
  const tier = String(process.env.DREAM_TIER || "high").toLowerCase();
32
35
  if (tier === "low") return "haiku";
33
36
  if (tier === "medium") return "sonnet";
34
- return "opus";
37
+ return DEFAULT_CLAUDE_MODEL; // high → the bot's top model (opus 4.8); tracks future upgrades
35
38
  }
36
39
  const DREAM_MODEL = pickDreamModel();
40
+ // The dream is the foundation of long-term memory quality, so it runs at max
41
+ // reasoning effort by default. Overridable via DREAM_EFFORT (low|medium|high|xhigh|max).
42
+ const DREAM_EFFORT = process.env.DREAM_EFFORT || "max";
37
43
  const DREAM_CRON = process.env.DREAM_CRON || "0 4 * * *";
38
44
  const MAX_PACK_CHARS = 2500;
39
45
  const MAX_ENTITY_CHARS = 900;
40
- const LIMITS = { merges: 3, umbrellas: 2, parents: 8, retag: 8, entity_merges: 3, entity_notes: 4, archive: 5 };
46
+ const LIMITS = {
47
+ merges: 3, umbrellas: 2, parents: 8, retag: 8, entity_merges: 3, entity_notes: 4, archive: 5, lessons: 6,
48
+ // introspection phase (self-improvement) caps
49
+ lessons_add: 4, doc_edits: 5, entity_edits: 4, ideas: 8,
50
+ // ability tier management (deterministic, reuse-breadth based)
51
+ promote: 3, demote: 3,
52
+ };
53
+
54
+ // An ability that has demonstrably transferred to at least this many distinct
55
+ // projects (via applied_on) has earned an always-on slot.
56
+ const PROMOTE_MIN_PROJECTS = Number(process.env.DREAM_PROMOTE_MIN_PROJECTS || 3);
41
57
 
42
58
  // Retirement guard: even if the model proposes archiving, only act when a pack
43
59
  // is genuinely cold — old enough, idle long enough, and rarely used. Protects
@@ -60,6 +76,22 @@ function summaryEnabled() {
60
76
  return String(v).toLowerCase() !== "off";
61
77
  }
62
78
 
79
+ // The self-improvement introspection phase (reviews the day's seeds, reads its
80
+ // own code read-only, mines missed corrections into lessons, captures ideas).
81
+ function introspectEnabled() {
82
+ const v = process.env.DREAM_INTROSPECT ?? config.DREAM_INTROSPECT ?? "on";
83
+ return String(v).toLowerCase() !== "off";
84
+ }
85
+
86
+ // Whether introspection auto-applies its memory-doc changes (default) or only
87
+ // proposes them in the report for morning approval (DREAM_SELF_APPLY=off).
88
+ // Either way it NEVER touches soul.md or code — that boundary is structural
89
+ // (read-only tools) and not configurable.
90
+ function selfApplyEnabled() {
91
+ const v = process.env.DREAM_SELF_APPLY ?? config.DREAM_SELF_APPLY ?? "on";
92
+ return String(v).toLowerCase() !== "off";
93
+ }
94
+
63
95
  function clip(s, n) {
64
96
  const t = String(s || "");
65
97
  return t.length > n ? t.slice(0, n) + "\n…[truncated]" : t;
@@ -70,7 +102,7 @@ function buildDreamPrompt() {
70
102
  const allEntities = entities.listEntities();
71
103
 
72
104
  const packDump = allPacks.map((p) => clip([
73
- `=== pack: ${p.dir}${p.parent ? ` (parent: ${p.parent})` : ""}`,
105
+ `=== pack: ${p.dir}${p.parent ? ` (parent: ${p.parent})` : ""}${p.kind === "ability" ? " [ABILITY]" : ""}`,
74
106
  `name: ${p.name}`,
75
107
  `description: ${p.description}`,
76
108
  `tags: ${p.tags.join(", ")}`,
@@ -88,7 +120,12 @@ function buildDreamPrompt() {
88
120
  `Log:\n${e.sections.Log}`,
89
121
  ].join("\n"), MAX_ENTITY_CHARS)).join("\n\n") || "(none)";
90
122
 
91
- 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.
123
+ const allLessons = lessons.listLessons();
124
+ const lessonDump = allLessons.map((l) =>
125
+ `- [${l.id}] "${l.text}"${l.src ? ` (src: ${l.src})` : ""} — reinforced ${l.reinforced || 0}×, since ${(l.created || "").slice(0, 10)}, origin ${l.origin || "user"}`
126
+ ).join("\n") || "(none)";
127
+
128
+ 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), entity notes (people/places/projects/orgs/systems), and her always-loaded lessons, plus her current persona.
92
129
 
93
130
  Today: ${new Date().toISOString().slice(0, 10)}
94
131
 
@@ -100,26 +137,31 @@ ENTITIES:
100
137
 
101
138
  ${entityDump}
102
139
 
140
+ ALWAYS-LOADED LESSONS (cap ${lessons.MAX_LESSONS}; these inject on EVERY turn, so they must stay few and sharp):
141
+
142
+ ${lessonDump}
143
+
103
144
  CURRENT PERSONA:
104
145
 
105
146
  ${persona.loadPersona()}
106
147
 
107
148
  Your job — decide what consolidation, if any, is warranted:
108
149
 
109
- 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. A Stance marked USER-AUTHORED is off-limits — leave its stance null; the system preserves it verbatim regardless.
150
+ 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. A Stance marked USER-AUTHORED is off-limits — leave its stance null; the system preserves it verbatim regardless. Packs marked [ABILITY] are reusable cross-project how-tos, NOT project trackers: only ever merge an [ABILITY] into another [ABILITY] (the merged result must stay an ability), and never merge a project pack into an [ABILITY] or an [ABILITY] into a project pack — that would lose the reusable how-to or pollute it with one project's specifics.
110
151
  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.
111
152
  3. parents: assign an existing pack as parent of another (sub-topic relationship) without creating anything.
112
153
  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.
113
154
  5. entity_merges: the same real-world entity recorded twice gets merged (the better slug wins).
114
155
  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.
115
156
  7. archive: retire packs that have gone cold — long unused AND rarely used over their life (consult last_used and the usage count). Archiving moves a pack out of the live index (reversibly, backed up) so it stops adding recall noise. ONLY propose packs idle ≥${ARCHIVE_IDLE_DAYS} days and used ≤${ARCHIVE_MAX_USAGE}× total; never an umbrella/parent pack that still has children; when in doubt, leave it. Give a one-line reason.
116
- 8. persona: evolve the persona GENTLYkeep 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.
117
- 9. 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.
157
+ 8. lessons: keep the always-loaded lessons few and sharp they cost context on EVERY turn. dedupe near-identical lessons (op "remove" the weaker, keep the better wording; or op "edit" to merge two into one tight line); tighten a clumsy lesson with op "edit". If the count exceeds the cap of ${lessons.MAX_LESSONS}, demote the weakest down to the cap — prefer lessons that are low-reinforced AND whose fact is safely captured in the pack named in their (src) (they will still surface by topic-match), and NEVER remove a frequently-reinforced lesson (those are actively preventing a repeat mistake). Even under the cap you may remove a lesson clearly redundant with its source pack that has stayed at 0 reinforcements for a long time, but be conservative — when in doubt, keep it. Reference lessons by their exact current text.
158
+ 9. 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.
159
+ 10. 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.
118
160
 
119
161
  Hard rules:
120
162
  - Never invent packs/entities not listed above; reference them by exact dir/slug.
121
163
  - Never store secrets, tokens, passwords, or credentials anywhere.
122
- - Limits: ≤${LIMITS.merges} merges, ≤${LIMITS.umbrellas} umbrellas, ≤${LIMITS.parents} parents, ≤${LIMITS.retag} retags, ≤${LIMITS.entity_merges} entity merges, ≤${LIMITS.entity_notes} entity note rewrites, ≤${LIMITS.archive} archives.
164
+ - Limits: ≤${LIMITS.merges} merges, ≤${LIMITS.umbrellas} umbrellas, ≤${LIMITS.parents} parents, ≤${LIMITS.retag} retags, ≤${LIMITS.entity_merges} entity merges, ≤${LIMITS.entity_notes} entity note rewrites, ≤${LIMITS.archive} archives, ≤${LIMITS.lessons} lesson ops.
123
165
  - An empty decision is a perfectly good decision.
124
166
 
125
167
  Reply with ONLY a JSON object, no prose, no code fences:
@@ -130,8 +172,10 @@ Reply with ONLY a JSON object, no prose, no code fences:
130
172
  "entity_merges": [{"into": "<slug>", "from": ["<slug>"], "notes": "<merged Notes or null>"}],
131
173
  "entity_notes": [{"entity": "<slug>", "notes": "<rewritten Notes>"}],
132
174
  "archive": [{"pack": "<dir>", "reason": "<one line: why it's cold>"}],
175
+ "lessons": [{"op": "remove", "text": "<exact current lesson text>", "reason": "<duplicate of … / safely in pack … / cold>"}],
133
176
  "persona": null,
134
- "report": "<chat message>"}`;
177
+ "report": "<chat message>"}
178
+ (a lesson "edit" looks like {"op": "edit", "text": "<exact current lesson text>", "to": "<tightened wording>"})`;
135
179
  }
136
180
 
137
181
  function parseDream(text) {
@@ -150,6 +194,7 @@ function parseDream(text) {
150
194
  entity_merges: arr("entity_merges"),
151
195
  entity_notes: arr("entity_notes"),
152
196
  archive: arr("archive"),
197
+ lessons: arr("lessons"),
153
198
  persona: typeof obj.persona === "string" ? obj.persona : null,
154
199
  report: typeof obj.report === "string" ? obj.report.trim() : "",
155
200
  };
@@ -173,6 +218,14 @@ function backupEntity(slug, root) {
173
218
  fs.copyFileSync(path.join(entities.ENTITIES_DIR, slug + ".md"), path.join(dest, slug + ".md"));
174
219
  }
175
220
 
221
+ function backupLessons(root) {
222
+ const dest = path.join(root, "lessons");
223
+ fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
224
+ for (const f of [lessons.LESSONS_FILE, lessons.LESSONS_META_FILE]) {
225
+ try { if (fs.existsSync(f)) fs.copyFileSync(f, path.join(dest, path.basename(f))); } catch (e) {}
226
+ }
227
+ }
228
+
176
229
  function wouldCycle(packDir, parentDir) {
177
230
  let cur = parentDir;
178
231
  for (let i = 0; i < 10 && cur; i++) {
@@ -182,6 +235,42 @@ function wouldCycle(packDir, parentDir) {
182
235
  return false;
183
236
  }
184
237
 
238
+ // Ability tier management — deterministic, by reuse breadth (no model call).
239
+ // An ability that has transferred across several distinct projects (applied_on)
240
+ // graduates to the always-on skill index; a once-promoted ability that never
241
+ // spread and has gone cold eases back to the match-gated pool (still an ability,
242
+ // just not injected every turn). Both are announced and reversible. `gone` is
243
+ // the set of dirs already removed this run (skip those). Returns chat lines.
244
+ function manageAbilityTiers(gone = new Set()) {
245
+ const lines = [];
246
+ let allPacks, abilities;
247
+ try { allPacks = packs.listPacks(); abilities = packs.listAbilities(); }
248
+ catch (e) { return lines; }
249
+ let promoted = 0, demoted = 0;
250
+ for (const ab of abilities) {
251
+ try {
252
+ if (gone.has(ab.dir)) continue;
253
+ const reach = (ab.applied_on || []).length;
254
+ if (!ab.skill && reach >= PROMOTE_MIN_PROJECTS && promoted < LIMITS.promote) {
255
+ packs.setSkill(ab.dir, true);
256
+ promoted++;
257
+ lines.push(`🧩 Promoted "${ab.name}" to always-on — reused across ${reach} projects (${(ab.applied_on || []).join(", ")}).`);
258
+ } else if (ab.skill && reach <= 1 && demoted < LIMITS.demote) {
259
+ if (allPacks.some((p) => p.parent === ab.dir)) continue; // never demote an umbrella with children
260
+ const ageDays = ab.created ? (Date.now() - Date.parse(ab.created)) / 86400000 : Infinity;
261
+ const idleDays = ab.last_used ? (Date.now() - Date.parse(ab.last_used)) / 86400000 : ageDays;
262
+ const uses = Number(ab.usage_count) || 0;
263
+ if (ageDays >= ARCHIVE_IDLE_DAYS && idleDays >= ARCHIVE_IDLE_DAYS && uses <= ARCHIVE_MAX_USAGE) {
264
+ packs.setSkill(ab.dir, false);
265
+ demoted++;
266
+ lines.push(`🧩 Eased "${ab.name}" out of the always-on set — gone quiet (idle ${Math.round(idleDays)}d, used ${uses}×). Still surfaces when relevant.`);
267
+ }
268
+ }
269
+ } catch (e) { console.warn(`[dream] ability tier op failed: ${e.message}`); }
270
+ }
271
+ return lines;
272
+ }
273
+
185
274
  // Applies a parsed dream decision. Returns one human line per applied
186
275
  // change; invalid items are skipped, never thrown.
187
276
  function applyDream(decision, backupRoot) {
@@ -193,6 +282,14 @@ function applyDream(decision, backupRoot) {
193
282
  const into = m?.into && packs.readPack(m.into);
194
283
  const from = [].concat(m?.from || []).filter((d) => d && d !== m.into && !gone.has(d) && packs.readPack(d));
195
284
  if (!into || from.length === 0) continue;
285
+ // Structural guard: never cross the ability/context boundary in a merge —
286
+ // it would lose a reusable how-to or pollute it with one project's
287
+ // specifics. (The prompt says this too; this enforces it regardless.)
288
+ const intoIsAbility = into.kind === "ability";
289
+ if (from.some((d) => (packs.readPack(d).kind === "ability") !== intoIsAbility)) {
290
+ lines.push(`🧩 Skipped merge into ${m.into} — won't mix a reusable ability with project packs.`);
291
+ continue;
292
+ }
196
293
  const mGuard = packGuard.scanSections({ stance: m.stance, procedure: m.procedure, state: m.state, journal: m.journal });
197
294
  if (mGuard.flagged) {
198
295
  console.warn(`[dream] guard blocked merge into ${m.into} (${mGuard.kind} in ${mGuard.section})`);
@@ -330,6 +427,27 @@ function applyDream(decision, backupRoot) {
330
427
  }
331
428
  }
332
429
 
430
+ for (const line of manageAbilityTiers(gone)) lines.push(line);
431
+
432
+ if (decision.lessons && decision.lessons.length) {
433
+ let backedUp = false;
434
+ for (const op of decision.lessons) {
435
+ try {
436
+ const text = String(op?.text || "").trim();
437
+ if (!text) continue;
438
+ if (!backedUp) { backupLessons(backupRoot); backedUp = true; }
439
+ if (op.op === "edit" && String(op.to || "").trim()) {
440
+ const guard = packGuard.scanForInjection(op.to, { strict: true });
441
+ if (guard.flagged) { console.warn(`[dream] guard blocked lesson edit (${guard.kind})`); continue; }
442
+ const r = lessons.editLesson(text, op.to);
443
+ if (r.ok) lines.push(`📌 Tightened a lesson${r.merged ? " (merged a duplicate)" : ""}`);
444
+ } else if (lessons.removeLesson(text)) {
445
+ lines.push(`📌 Retired a lesson — ${(op.reason || "consolidated").slice(0, 120)}`);
446
+ }
447
+ } catch (e) { console.warn(`[dream] lesson op failed: ${e.message}`); }
448
+ }
449
+ }
450
+
333
451
  if (decision.persona) {
334
452
  try {
335
453
  if (persona.personaExists()) {
@@ -344,6 +462,225 @@ function applyDream(decision, backupRoot) {
344
462
  return lines;
345
463
  }
346
464
 
465
+ // ── Self-improvement introspection phase (requests [1], [3], [5]) ────
466
+ // A SECOND dream pass that runs with read-only tools. It reviews the day's
467
+ // compaction seeds (what was actually worked on), reflects on its current self
468
+ // with that self injected, may read its own code to understand how it works
469
+ // (read-only — it has no tools to edit code or soul.md, enforced by the
470
+ // --allowedTools whitelist in subagent.js), then proposes improvements to its
471
+ // LEARNED MEMORY only: lessons promoted from missed corrections, sharpened
472
+ // pack/entity notes, and self-improvement ideas. All writes happen here in
473
+ // Node, bounded to memory docs, guard-scanned, and backed up first.
474
+
475
+ const CODE_ROOT = path.resolve(__dirname, "..");
476
+ const DREAMS_DIR = path.join(CONFIG_DIR, "dreams");
477
+
478
+ function buildIntrospectionPrompt() {
479
+ const seeds = daySeeds.recentDaySeeds({ days: 2 });
480
+ const seedText = seeds.text.trim() || "(no compaction digests captured in the last couple of days — introspect on your current self and recent memory instead)";
481
+
482
+ const packIndex = packs.listPacks().map((p) =>
483
+ `- ${p.dir}${p.parent ? ` (sub of ${p.parent})` : ""}: ${p.description}`).join("\n") || "(none)";
484
+ const entityIndex = entities.listEntities().map((e) =>
485
+ `- ${e.slug} (${e.type}): ${e.description}`).join("\n") || "(none)";
486
+ const lessonIndex = lessons.listLessons().map((l) =>
487
+ `- "${l.text}"${l.src ? ` (src: ${l.src})` : ""}`).join("\n") || "(none)";
488
+ const ideaIndex = ideas.listIdeas().slice(0, 30).map((i) => `- ${i.text}`).join("\n") || "(none)";
489
+
490
+ return `You are Open Claudia, reflecting on yourself in the quiet hours — the introspection pass of your nightly dream. This is YOU thinking about how to get better, not a detached tool.
491
+
492
+ Today: ${new Date().toISOString().slice(0, 10)}
493
+
494
+ ═══ WHAT YOU WORKED ON (compaction digests of the day's conversations) ═══
495
+ ${clip(seedText, 14000)}
496
+
497
+ ═══ YOUR CURRENT SELF ═══
498
+ Persona (your voice):
499
+ ${persona.loadPersona()}
500
+
501
+ Always-loaded lessons (rules you got wrong before and must not repeat):
502
+ ${lessonIndex}
503
+
504
+ Context packs (topic memory) — index only; read the files for detail:
505
+ ${packIndex}
506
+
507
+ Entities (people/places/projects/systems) — index only:
508
+ ${entityIndex}
509
+
510
+ Ideas backlog so far:
511
+ ${ideaIndex}
512
+
513
+ ═══ WHERE YOUR SELF LIVES (READ any of these with Read/Grep/Glob) ═══
514
+ - Hard rules / identity: ${path.join(CONFIG_DIR, "soul.md")} (READ-ONLY, OFF-LIMITS — the user's rules, never change them)
515
+ - Persona: ${persona.PERSONA_FILE}
516
+ - Lessons: ${lessons.LESSONS_FILE}
517
+ - Ideas: ${ideas.IDEAS_FILE}
518
+ - Packs: ${packs.PACKS_DIR}/<dir>/PACK.md
519
+ - Entities: ${entities.ENTITIES_DIR}/<slug>.md
520
+ - Day seeds: ${daySeeds.SEEDS_DIR}
521
+ - Your own source code: ${CODE_ROOT} (READ it to understand how you actually work — recall engine, dream, reviewer, etc.)
522
+
523
+ ═══ YOUR TASK ═══
524
+ Investigate where useful with your read-only tools — read the day's full briefs, packs/entities in detail, and your own code to understand your current behaviour. Then reflect and propose improvements to your LEARNED MEMORY only:
525
+
526
+ 1. lessons_add — Mine the day's work for moments where the user CORRECTED you or REPEATED something you should already have known ("I've told you before", "no, it's actually…", "again"). Each such miss becomes ONE always-loaded lesson: a single sharp line, the exact trigger quote that proves it was a real miss, and the (src) pack holding fuller context. NO trigger → do not add it. This is how a fact that topic-gated recall missed becomes always-on.
527
+ 2. doc_edits — Where the day's work proved a pack's Stance/Procedure/State stale or wrong, supply the corrected section text. Leave any Stance marked user-authored. Tight and factual.
528
+ 3. entity_edits — Refresh an entity's Notes if you learned something durable about a person/system today.
529
+ 4. ideas — Self-improvement ideas from the day: scope "oc" = improving Open Claudia itself (something you noticed about your own code/behaviour worth changing — PROPOSE it, never implement code), scope "work" = follow-ups for the projects worked on.
530
+ 5. persona — Only if recent work justifies a gentle nudge to your voice; usually null.
531
+ 6. report — First-person, warm, mobile-friendly: what you reflected on, what you changed and why, any standout idea. This is how the user understands what you did while dreaming.
532
+
533
+ HARD RULES:
534
+ - You may READ your code and soul to understand yourself; you must NEVER edit code or soul.md (you have no write/exec tools — keep it that way in spirit too). Propose code/behaviour changes as ideas (scope "oc"), never as edits.
535
+ - Reference packs/entities by exact dir/slug; never invent them.
536
+ - Never store secrets, tokens, passwords, or credentials anywhere.
537
+ - Limits: ≤${LIMITS.lessons_add} lessons_add, ≤${LIMITS.doc_edits} doc_edits, ≤${LIMITS.entity_edits} entity_edits, ≤${LIMITS.ideas} ideas. An empty proposal is fine.
538
+
539
+ When done investigating, reply with ONLY this JSON (no prose, no code fences):
540
+ {"lessons_add": [{"text": "<one-line rule>", "src": "<pack-dir or empty>", "trigger": "<exact correction/repetition you saw>"}],
541
+ "doc_edits": [{"pack": "<dir>", "section": "Stance|Procedure|State", "to": "<new section text>", "why": "<one line>"}],
542
+ "entity_edits": [{"entity": "<slug>", "notes": "<rewritten Notes>", "why": "<one line>"}],
543
+ "ideas": [{"scope": "oc|work", "text": "<idea>"}],
544
+ "persona": null,
545
+ "report": "<first-person chat message>"}`;
546
+ }
547
+
548
+ function parseIntrospection(text) {
549
+ const raw = String(text || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
550
+ const start = raw.indexOf("{");
551
+ const end = raw.lastIndexOf("}");
552
+ if (start === -1 || end <= start) return null;
553
+ try {
554
+ const obj = JSON.parse(raw.slice(start, end + 1));
555
+ const arr = (k) => (Array.isArray(obj[k]) ? obj[k].slice(0, LIMITS[k] || 8) : []);
556
+ return {
557
+ lessons_add: arr("lessons_add"),
558
+ doc_edits: arr("doc_edits"),
559
+ entity_edits: arr("entity_edits"),
560
+ ideas: arr("ideas"),
561
+ persona: typeof obj.persona === "string" ? obj.persona : null,
562
+ report: typeof obj.report === "string" ? obj.report.trim() : "",
563
+ };
564
+ } catch (e) { return null; }
565
+ }
566
+
567
+ const DOC_FIELD = { stance: "stance", procedure: "procedure", state: "state" };
568
+
569
+ function applyIntrospection(decision, backupRoot, opts = {}) {
570
+ const lines = [];
571
+ let lessonsBackedUp = false;
572
+
573
+ // 1. lessons promoted from the day's misses — promote-after-a-miss: a
574
+ // non-empty trigger is structurally required, so nothing graduates without
575
+ // proof topic-gated recall actually missed it.
576
+ for (const l of decision.lessons_add) {
577
+ try {
578
+ const text = String(l?.text || "").trim();
579
+ const trigger = String(l?.trigger || "").trim();
580
+ if (!text || !trigger) continue;
581
+ const guard = packGuard.scanForInjection(text, { strict: true });
582
+ if (guard.flagged) { lines.push(`🛡️ Skipped a proposed lesson — injection-like (${guard.kind})`); continue; }
583
+ if (!lessonsBackedUp) { backupLessons(backupRoot); lessonsBackedUp = true; }
584
+ const r = lessons.addLesson({ text, src: String(l.src || "").trim(), origin: "dream" });
585
+ if (r.added) lines.push(`📌 Promoted a lesson from today's miss — "${clip(text, 110)}"${r.overCap ? ` (over cap, will tidy)` : ""}`);
586
+ else if (r.reinforced) lines.push(`📌 Reinforced a lesson I keep needing — "${clip(text, 110)}"`);
587
+ } catch (e) { console.warn(`[dream] lesson_add failed: ${e.message}`); }
588
+ }
589
+
590
+ // 2. pack doc edits (Stance/Procedure/State); user-authored Stance protected.
591
+ for (const d of decision.doc_edits) {
592
+ try {
593
+ const dir = String(d?.pack || "").trim();
594
+ const section = String(d?.section || "").trim().toLowerCase();
595
+ const to = String(d?.to || "").trim();
596
+ if (!dir || !to || !DOC_FIELD[section] || !packs.readPack(dir)) continue;
597
+ const guard = packGuard.scanForInjection(to, { strict: true });
598
+ if (guard.flagged) { lines.push(`🛡️ Skipped a doc edit to ${dir} — injection-like (${guard.kind})`); continue; }
599
+ if (section === "stance" && packs.provenanceOf(dir, "Stance") === "user") {
600
+ lines.push(`🛡️ Left ${dir}'s user-authored Stance untouched`);
601
+ continue;
602
+ }
603
+ packs.updatePack(dir, { [DOC_FIELD[section]]: to, journal: `introspection: ${(d.why || "refreshed " + section).slice(0, 100)}` }, "dream");
604
+ lines.push(`📝 Updated ${dir} ${section}${d.why ? ` — ${clip(String(d.why), 70)}` : ""}`);
605
+ } catch (e) { console.warn(`[dream] doc_edit failed: ${e.message}`); }
606
+ }
607
+
608
+ // 3. entity note refreshes.
609
+ for (const en of decision.entity_edits) {
610
+ try {
611
+ const slug = String(en?.entity || "").trim();
612
+ const notes = String(en?.notes || "").trim();
613
+ if (!slug || !notes) continue;
614
+ const ent = entities.readEntity(slug);
615
+ if (!ent) continue;
616
+ const guard = packGuard.scanForInjection(notes, { strict: true });
617
+ if (guard.flagged) { lines.push(`🛡️ Skipped entity note on ${slug} — injection-like (${guard.kind})`); continue; }
618
+ entities.upsertEntity({ name: ent.name, notes });
619
+ lines.push(`🔗 Refreshed notes on ${ent.name}`);
620
+ } catch (e) { console.warn(`[dream] entity_edit failed: ${e.message}`); }
621
+ }
622
+
623
+ // 4. ideas captured (a backlog; not injected, so low-risk — still guarded).
624
+ for (const i of decision.ideas) {
625
+ try {
626
+ const text = String(i?.text || "").trim();
627
+ if (!text) continue;
628
+ const guard = packGuard.scanForInjection(text, { strict: true });
629
+ if (guard.flagged) continue;
630
+ const r = ideas.addIdea({ text, scope: String(i.scope || "").toLowerCase() });
631
+ if (r.added) lines.push(`💡 Captured an idea — "${clip(text, 90)}"`);
632
+ } catch (e) { console.warn(`[dream] idea failed: ${e.message}`); }
633
+ }
634
+
635
+ // 5. persona — only if the consolidation phase didn't already evolve it.
636
+ if (decision.persona && !opts.personaAlreadyChanged) {
637
+ try {
638
+ const guard = packGuard.scanForInjection(decision.persona, { strict: true });
639
+ if (!guard.flagged) {
640
+ if (persona.personaExists()) {
641
+ fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
642
+ try { fs.copyFileSync(persona.PERSONA_FILE, path.join(backupRoot, "persona.md")); } catch (e) {}
643
+ }
644
+ persona.savePersona(decision.persona);
645
+ lines.push(`💫 Adjusted my voice a little`);
646
+ }
647
+ } catch (e) { console.warn(`[dream] introspection persona failed: ${e.message}`); }
648
+ }
649
+
650
+ return lines;
651
+ }
652
+
653
+ // Full dream log on disk so the chat summary can stay short while the user can
654
+ // still see everything the dream did (request [5]). Appended per run.
655
+ function writeDreamReport(data) {
656
+ try {
657
+ fs.mkdirSync(DREAMS_DIR, { recursive: true, mode: 0o700 });
658
+ const stamp = new Date().toISOString();
659
+ const file = path.join(DREAMS_DIR, `${stamp.slice(0, 10)}.md`);
660
+ const sec = [
661
+ `# Dream — ${stamp}`,
662
+ `Model: ${data.model} · effort: ${data.effort} · trigger: ${data.trigger || "?"}`,
663
+ "",
664
+ "## Consolidation",
665
+ data.consolidation.report || "(no report)",
666
+ ];
667
+ if (data.consolidation.lines?.length) sec.push("", data.consolidation.lines.map((l) => `- ${l}`).join("\n"));
668
+ sec.push("", "## Introspection", data.introspection.report || "(no introspection report)");
669
+ if (data.introspection.lines?.length) sec.push("", data.introspection.lines.map((l) => `- ${l}`).join("\n"));
670
+ if (data.introspection.proposed) {
671
+ sec.push("", "### Proposed (not applied — DREAM_SELF_APPLY=off)", "```json", JSON.stringify(data.introspection.proposed, null, 2), "```");
672
+ }
673
+ if (data.graphNote) sec.push("", data.graphNote);
674
+ if (data.staleNote) sec.push("", data.staleNote);
675
+ if (data.backupRoot) sec.push("", `Backups: ${data.backupRoot}`);
676
+ const block = sec.join("\n") + "\n\n---\n\n";
677
+ let existing = "";
678
+ try { existing = fs.readFileSync(file, "utf-8"); } catch (e) {}
679
+ fs.writeFileSync(file, existing + block, { mode: 0o600 });
680
+ return file;
681
+ } catch (e) { return null; }
682
+ }
683
+
347
684
  // Task-hygiene pass: surface tasks gone cold for the owner to decide on.
348
685
  // Flag-and-ask only — tasks are user intent, so dream NEVER edits or deletes
349
686
  // them; it just lists the stalest few in the morning report. Reports against
@@ -374,6 +711,7 @@ async function runDream({ trigger = "manual" } = {}) {
374
711
  try {
375
712
  const { text } = await spawnSubagent(buildDreamPrompt(), {
376
713
  model: DREAM_MODEL,
714
+ effort: DREAM_EFFORT,
377
715
  timeoutMs: 8 * 60 * 1000,
378
716
  systemPrompt: "You are a background memory consolidation process. Reply with ONLY the requested JSON object. No prose, no markdown, no tool use.",
379
717
  });
@@ -384,6 +722,40 @@ async function runDream({ trigger = "manual" } = {}) {
384
722
  const applied = applyDream(decision, backupRoot);
385
723
  const report = decision.report || (applied.length > 0 ? "Tidied up my memory overnight." : "");
386
724
 
725
+ // Phase 2: self-improvement introspection. Reviews the day's seeds, reads
726
+ // its own code/memory READ-ONLY (allowedTools whitelist), promotes lessons
727
+ // from missed corrections, refreshes docs, captures ideas. Bounded to memory
728
+ // docs; never code or soul. Applied unless DREAM_SELF_APPLY=off, in which
729
+ // case the proposal is reported for morning approval instead.
730
+ let introReport = "";
731
+ let introApplied = [];
732
+ let introProposed = null;
733
+ if (introspectEnabled()) {
734
+ try {
735
+ const introRun = await spawnSubagent(buildIntrospectionPrompt(), {
736
+ model: DREAM_MODEL,
737
+ effort: DREAM_EFFORT,
738
+ cwd: CODE_ROOT,
739
+ timeoutMs: 12 * 60 * 1000,
740
+ // Plan mode is the load-bearing read-only guarantee. Verified: a tool
741
+ // whitelist alone does NOT restrict, because the bot's default
742
+ // --dangerously-skip-permissions overrides --allowedTools. Plan mode
743
+ // (without skip-permissions) is the only setting that actually blocks
744
+ // writes/Bash while still allowing Read/Glob/Grep — so introspection
745
+ // can read its own code + memory but cannot edit code or soul.
746
+ permissionMode: "plan",
747
+ allowedTools: ["Read", "Glob", "Grep"],
748
+ systemPrompt: "You are Open Claudia's nightly self-introspection. You may READ files (your code, memory, soul) to understand yourself, but you have no tools to write or run anything. After investigating, reply with ONLY the requested JSON object — no prose, no code fences.",
749
+ });
750
+ const intro = parseIntrospection(introRun.text);
751
+ if (intro) {
752
+ introReport = intro.report || "";
753
+ if (selfApplyEnabled()) introApplied = applyIntrospection(intro, backupRoot, { personaAlreadyChanged: !!decision.persona });
754
+ else introProposed = intro;
755
+ }
756
+ } catch (e) { console.warn(`[dream] introspection phase failed: ${e.message}`); }
757
+ }
758
+
387
759
  // Recall-graph maintenance: refresh structural edges, decay reinforced
388
760
  // weights, prune orphans. Deterministic + safe, runs every dream.
389
761
  let graphNote = "";
@@ -395,14 +767,32 @@ async function runDream({ trigger = "manual" } = {}) {
395
767
  } catch (e) { /* graph is best-effort */ }
396
768
 
397
769
  const staleNote = staleTaskReport();
770
+ const dreamLines = applied.concat(introApplied);
398
771
 
399
- let message = applied.length > 0
400
- ? `💤 ${report}\n\n${applied.join("\n")}\n\n🗄 Anything merged away is backed up under ${backupRoot}`
401
- : (report ? `💤 ${report}` : "");
402
- if (graphNote) message = message ? `${message}\n\n${graphNote}` : `💤 ${graphNote}`;
403
- if (staleNote) message = message ? `${message}\n\n${staleNote}` : `💤 ${staleNote}`;
772
+ const reportPath = writeDreamReport({
773
+ model: DREAM_MODEL, effort: DREAM_EFFORT, trigger,
774
+ consolidation: { report: decision.report, lines: applied },
775
+ introspection: { report: introReport, lines: introApplied, proposed: introProposed },
776
+ graphNote, staleNote, backupRoot,
777
+ });
778
+
779
+ // Richer chat summary: consolidation + introspection narrative + what
780
+ // changed + pointers, so the user can see what the dream did (request [5]).
781
+ const parts = [];
782
+ if (report) parts.push(`💤 ${report}`);
783
+ if (introReport) parts.push(`🪞 ${introReport}`);
784
+ if (dreamLines.length) parts.push(dreamLines.join("\n"));
785
+ if (introProposed) {
786
+ const n = (introProposed.lessons_add?.length || 0) + (introProposed.doc_edits?.length || 0) + (introProposed.entity_edits?.length || 0) + (introProposed.ideas?.length || 0);
787
+ if (n) parts.push(`📋 ${n} self-improvement change(s) staged for your OK (DREAM_SELF_APPLY=off) — see the log below.`);
788
+ }
789
+ if (graphNote) parts.push(graphNote);
790
+ if (staleNote) parts.push(staleNote);
791
+ if (dreamLines.length) parts.push(`🗄 Anything changed or merged away is backed up under ${backupRoot}`);
792
+ if (reportPath) parts.push(`📔 Full dream log: ${reportPath}`);
793
+ const message = parts.length ? parts.join("\n\n") : "";
404
794
 
405
- return { applied, report, message, staleNote, graphNote, trigger };
795
+ return { applied: dreamLines, report, introReport, message, staleNote, graphNote, reportPath, trigger };
406
796
  } finally {
407
797
  _dreaming = false;
408
798
  }
@@ -432,7 +822,12 @@ function initDream(adapters) {
432
822
  console.error("dream: run failed:", e.message);
433
823
  }
434
824
  });
435
- console.log(`dream: scheduled (${DREAM_CRON}, model ${DREAM_MODEL})`);
825
+ console.log(`dream: scheduled (${DREAM_CRON}, model ${DREAM_MODEL}, effort ${DREAM_EFFORT}, introspect ${introspectEnabled() ? "on" : "off"})`);
436
826
  }
437
827
 
438
- module.exports = { runDream, initDream, buildDreamPrompt, parseDream, applyDream, enabled, summaryEnabled, DREAM_CRON, DREAM_MODEL };
828
+ module.exports = {
829
+ runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers,
830
+ buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
831
+ enabled, summaryEnabled, introspectEnabled, selfApplyEnabled,
832
+ DREAM_CRON, DREAM_MODEL, DREAM_EFFORT, PROMOTE_MIN_PROJECTS,
833
+ };