@inetafrica/open-claudia 3.5.71 → 3.5.73

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/packs.js CHANGED
@@ -19,7 +19,11 @@ try { ({ DatabaseSync } = require("node:sqlite")); } catch (e) { /* old node —
19
19
  const CONFIG_DIR = require("../config-dir");
20
20
  const PACKS_DIR = process.env.PACKS_DIR ? path.resolve(process.env.PACKS_DIR) : path.join(CONFIG_DIR, "packs");
21
21
 
22
- const SECTIONS = ["Stance", "Procedure", "State", "Journal"];
22
+ // Decisions: settled facts (dates, prices, choices, owners) as dated lines,
23
+ // append-only. State is replaced every turn and Journal is capped, so a
24
+ // decision kept only there falls out of memory (22 Oct: the launch dates and
25
+ // seat price did).
26
+ const SECTIONS = ["Stance", "Procedure", "State", "Decisions", "Journal"];
23
27
  const MAX_JOURNAL_ENTRIES = 30;
24
28
  // Enforced budget on LLM-edited State (the prompt's ~150-word target stays the
25
29
  // aspiration; this generous ceiling is the mechanical stop against drift — a
@@ -57,11 +61,11 @@ function parseFrontmatter(content) {
57
61
  }
58
62
 
59
63
  function parseSections(body) {
60
- const out = { Stance: "", Procedure: "", State: "", Journal: "" };
64
+ const out = { Stance: "", Procedure: "", State: "", Decisions: "", Journal: "" };
61
65
  let current = null;
62
66
  const pre = [];
63
67
  for (const line of String(body || "").split("\n")) {
64
- const h = line.match(/^##\s+(Stance|Procedure|State|Journal)\s*$/i);
68
+ const h = line.match(/^##\s+(Stance|Procedure|State|Decisions|Journal)\s*$/i);
65
69
  if (h) {
66
70
  current = SECTIONS.find((s) => s.toLowerCase() === h[1].toLowerCase());
67
71
  continue;
@@ -94,6 +98,7 @@ function serialize(pack) {
94
98
  if (pack.archived) fmLines.push(`archived: ${pack.archived}`);
95
99
  fmLines.push("---");
96
100
  const sections = SECTIONS
101
+ .filter((s) => s !== "Decisions" || (pack.sections?.Decisions || "").trim())
97
102
  .map((s) => `## ${s}\n\n${(pack.sections?.[s] || "").trim()}`)
98
103
  .join("\n\n");
99
104
  return fmLines.join("\n") + "\n\n" + sections + "\n";
@@ -266,7 +271,7 @@ function writePack(pack) {
266
271
  return pack.dir;
267
272
  }
268
273
 
269
- function createPack({ dir, name, description, tags, stance, procedure, state, journal, parent, skill, kind, learned_on, applied_on }, origin = "user") {
274
+ function createPack({ dir, name, description, tags, stance, procedure, state, decisions, journal, parent, skill, kind, learned_on, applied_on }, origin = "user") {
270
275
  const d = slugify(dir || name);
271
276
  if (!d) throw new Error("pack needs a name");
272
277
  if (readPack(d)) throw new Error(`pack ${d} already exists`);
@@ -287,6 +292,7 @@ function createPack({ dir, name, description, tags, stance, procedure, state, jo
287
292
  State: origin !== "user" && (state || "").length > MAX_STATE_CHARS
288
293
  ? trimToBudget(state, MAX_STATE_CHARS)
289
294
  : (state || ""),
295
+ Decisions: appendDecisions("", decisions).text,
290
296
  Journal: journal ? `- [${today()}] ${journal}` : "",
291
297
  },
292
298
  };
@@ -295,6 +301,21 @@ function createPack({ dir, name, description, tags, stance, procedure, state, jo
295
301
  return pack;
296
302
  }
297
303
 
304
+ // Append settled-decision lines (string or array) as dated bullets, skipping
305
+ // any that restate a decision already stored. Never drops old lines.
306
+ function appendDecisions(existing, decisions) {
307
+ const entries = String(existing || "").split("\n").filter((l) => l.trim());
308
+ let added = 0;
309
+ for (const raw of [].concat(decisions || [])) {
310
+ const m = /^-?\s*(?:\[(\d{4}-\d{2}-\d{2})\]\s*)?([\s\S]*)$/.exec(String(raw || "").trim().replace(/\n+/g, " "));
311
+ const line = m[2].trim();
312
+ if (!line || isNearDupJournal(line, entries)) continue;
313
+ entries.push(`- [${m[1] || today()}] ${line}`);
314
+ added++;
315
+ }
316
+ return { text: entries.join("\n"), added };
317
+ }
318
+
298
319
  function today() {
299
320
  return new Date().toISOString().slice(0, 10);
300
321
  }
@@ -409,7 +430,7 @@ function isNearDupJournal(line, recentEntries) {
409
430
 
410
431
  // Apply a reviewer mutation. Only supplied fields change; journal entries
411
432
  // append (capped + deduped); state replaces (it represents "current truth").
412
- function updatePack(dir, { description, tags, stance, procedure, state, journal, skill, kind, learned_on } = {}, origin = "user") {
433
+ function updatePack(dir, { description, tags, stance, procedure, state, decisions, journal, skill, kind, learned_on } = {}, origin = "user") {
413
434
  const pack = readPack(dir);
414
435
  if (!pack) throw new Error(`no pack: ${dir}`);
415
436
  pack.updated = new Date().toISOString();
@@ -456,6 +477,13 @@ function updatePack(dir, { description, tags, stance, procedure, state, journal,
456
477
  pack.sections.State = next;
457
478
  applied.push("State");
458
479
  }
480
+ if (decisions && [].concat(decisions).some((d) => String(d || "").trim())) {
481
+ const d = appendDecisions(pack.sections.Decisions, decisions);
482
+ if (d.added) {
483
+ pack.sections.Decisions = d.text;
484
+ applied.push("Decisions");
485
+ }
486
+ }
459
487
  if (typeof journal === "string" && journal.trim()) {
460
488
  const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
461
489
  const line = journal.trim().replace(/\n+/g, " ");
@@ -174,6 +174,21 @@ function appendToolLists(args, context) {
174
174
  }
175
175
  }
176
176
 
177
+ // Claude Code's own timers, monitors and workflow runner die with the per-turn
178
+ // process, so a foreground turn never sees them: the bot's scheduler (open-claudia
179
+ // schedule-wakeup / watch-add / cron-add) is the only one that survives.
180
+ // Hiding them also drops their long descriptions from every turn's prompt.
181
+ const FOREGROUND_DISALLOWED_TOOLS = Object.freeze([
182
+ "ScheduleWakeup", "CronCreate", "CronDelete", "CronList", "Monitor",
183
+ "RemoteTrigger", "Workflow", "PushNotification", "mcp__claude_ai_Claude_Docs",
184
+ ]);
185
+
186
+ function appendForegroundToolBlocks(args, context) {
187
+ const extra = Array.isArray(context.disallowedTools) ? context.disallowedTools : [];
188
+ const list = [...new Set([...FOREGROUND_DISALLOWED_TOOLS, ...extra])];
189
+ args.push("--disallowedTools", list.join(","));
190
+ }
191
+
177
192
  function appendReadOnlyDirectories(args, context) {
178
193
  if (context.additionalReadOnlyDirectories === undefined || context.additionalReadOnlyDirectories === null) return;
179
194
  if (!context.readOnly) {
@@ -219,6 +234,12 @@ function createClaudeProvider(options = {}) {
219
234
 
220
235
  const binary = requireExecutable();
221
236
  const env = { ...buildEnv() };
237
+ // Claude Code's own auto-memory (MEMORY.md under ~/.claude/projects) loads
238
+ // on every turn beside the bot's lessons and packs. CLAUDE_AUTO_MEMORY=off
239
+ // turns it off once its rules live in the bot's memory.
240
+ if (!utility && /^(off|false|0)$/i.test(String(config.settingValue("CLAUDE_AUTO_MEMORY") || ""))) {
241
+ env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
242
+ }
222
243
  let toolHookSettings = context.toolHookSettings || null;
223
244
  if (hookTransport) {
224
245
  try {
@@ -235,6 +256,7 @@ function createClaudeProvider(options = {}) {
235
256
 
236
257
  const args = ["-p"];
237
258
  if (utility) appendToolLists(args, context);
259
+ else appendForegroundToolBlocks(args, context);
238
260
  args.push(
239
261
  "--verbose",
240
262
  "--output-format", "stream-json",
@@ -65,8 +65,12 @@ function scoutToolGaps(userText, keptToolNames, allTools) {
65
65
  for (const d of OPERATIONAL_DOMAINS) {
66
66
  if (!d.test.test(text)) continue;
67
67
  if (d.covers && kept.some((n) => d.covers.test(n))) continue;
68
+ // The tool named for the domain first, then any name match, and only then
69
+ // a description match — first-hit-wins pointed kubectl at books-loader.
68
70
  const hit = d.covers
69
- ? all.find((t) => d.covers.test(t.name) || d.covers.test(String(t.description || "")))
71
+ ? (d.slug && all.find((t) => t.name === d.slug))
72
+ || all.find((t) => d.covers.test(t.name))
73
+ || all.find((t) => d.covers.test(String(t.description || "")))
70
74
  : null;
71
75
  if (hit && kept.includes(hit.name.toLowerCase())) continue;
72
76
  const g = hit
@@ -211,7 +215,7 @@ function excerptFor(id, packsLib, entitiesLib) {
211
215
  if (id.startsWith("pack:")) {
212
216
  const p = packsLib.readPack(id.slice(5));
213
217
  if (!p) return null;
214
- const body = [p.sections.Stance, p.sections.State].filter(Boolean).join("\n").trim();
218
+ const body = [p.sections.Stance, p.sections.State, p.sections.Decisions].filter(Boolean).join("\n").trim();
215
219
  return { id, name: p.name, description: p.description, excerpt: clip(body, EXCERPT_CHARS) };
216
220
  }
217
221
  if (id.startsWith("tool:")) {
@@ -641,7 +645,7 @@ async function run(ctx) {
641
645
  let episodeCands = [];
642
646
  if (tier === "full" && EPISODES_ENABLED && episodesAllowedForSpeaker() && transcriptIndex && transcriptIndex.available()) {
643
647
  try {
644
- const { hits } = transcriptIndex.search(userText, { limit: tuning.get("episodeLimit") });
648
+ const { hits } = transcriptIndex.searchRelated(userText, { limit: tuning.get("episodeLimit") });
645
649
  episodeCands = (hits || []).map((h) => {
646
650
  const terms = [...String(h.snip || "").matchAll(/>>(.+?)<</g)].map((m) => m[1]).slice(0, 4);
647
651
  return {
@@ -0,0 +1,113 @@
1
+ // External recall engines: `x` and `mem0` (Sumeet, 25 Oct: swap the
2
+ // per-turn auto-loader between the discoverer and two experimental memory
3
+ // searches to feel which gives better replies).
4
+ //
5
+ // Each runs an outside command with the cleaned user message as the final
6
+ // argument (execFile, no shell) and injects its stdout as one recall section
7
+ // where the discoverer's packs would go. Timeout, error or empty output falls
8
+ // back to the discoverer for that turn (classic on a live call, where the
9
+ // discoverer's walk is dead air), and the reason is logged.
10
+
11
+ const { execFile } = require("child_process");
12
+ const discoverer = require("./discoverer");
13
+ const classic = require("./classic");
14
+ const metrics = require("./metrics");
15
+
16
+ const PYTHON = "/Users/sumeetrajpal/Workspace/memory-pyramid/.venv312/bin/python";
17
+ const MAX_CHARS = 8000;
18
+
19
+ const SPECS = {
20
+ x: {
21
+ label: "X",
22
+ cmdEnv: "RECALL_X_CMD",
23
+ defaultCmd: `${PYTHON} /Users/sumeetrajpal/Workspace/memory-pyramid/facts/mem.py recall`,
24
+ env: { MEM_N: "10", MEM_CUTOFF: "2099-01-01T00:00:00Z" },
25
+ dropLine: null,
26
+ },
27
+ mem0: {
28
+ label: "Mem0",
29
+ cmdEnv: "RECALL_MEM0_CMD",
30
+ defaultCmd: `${PYTHON} /Users/sumeetrajpal/Workspace/memory-pyramid/mem0lab/lab.py search`,
31
+ env: {},
32
+ dropLine: /posthog|spacy|warn/i,
33
+ },
34
+ };
35
+
36
+ // From the process env or, failing that, the bot's .env file: the bot never
37
+ // copies .env into process.env, so a .env-only RECALL_X_CMD was silently
38
+ // ignored and the default command ran instead (25 Oct).
39
+ function setting(key) {
40
+ try { return require("../config").settingValue(key); } catch (e) { return process.env[key]; }
41
+ }
42
+
43
+ function timeoutMs() {
44
+ const n = Number(setting("RECALL_EXTERNAL_TIMEOUT_MS"));
45
+ return Number.isFinite(n) && n > 0 ? n : 8000;
46
+ }
47
+
48
+ // Run the command; resolves { text } or { error } (never rejects).
49
+ function runCommand(spec, query) {
50
+ // Split on whitespace: no shell, so a path with spaces is not supported.
51
+ const parts = String(setting(spec.cmdEnv) || spec.defaultCmd).trim().split(/\s+/).filter(Boolean);
52
+ if (!parts.length) return Promise.resolve({ error: "no-command" });
53
+ return new Promise((resolve) => {
54
+ try {
55
+ execFile(parts[0], [...parts.slice(1), query], {
56
+ timeout: timeoutMs(),
57
+ killSignal: "SIGKILL",
58
+ maxBuffer: 4 * 1024 * 1024,
59
+ env: { ...spec.env, ...process.env },
60
+ windowsHide: true,
61
+ }, (err, stdout, stderr) => {
62
+ if (err) {
63
+ if (err.killed || err.signal === "SIGKILL") return resolve({ error: "timeout" });
64
+ // The last stderr line is usually the actual exception.
65
+ const lastErr = String(stderr || "").trim().split("\n").pop();
66
+ return resolve({ error: `error: ${err.code || ""} ${lastErr || String(err.message || "").split("\n")[0]}`.trim() });
67
+ }
68
+ let lines = String(stdout || "").split("\n");
69
+ if (spec.dropLine) lines = lines.filter((line) => !spec.dropLine.test(line));
70
+ const text = lines.join("\n").trim();
71
+ if (!text) return resolve({ error: "empty" });
72
+ resolve({ text: text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n…(truncated)` : text });
73
+ });
74
+ } catch (e) {
75
+ resolve({ error: `error: ${e.message}` });
76
+ }
77
+ });
78
+ }
79
+
80
+ function makeEngine(name) {
81
+ const spec = SPECS[name];
82
+ async function run(ctx) {
83
+ const started = Date.now();
84
+ const query = String(ctx?.userText || "").trim();
85
+ const out = query ? await runCommand(spec, query) : { error: "no-query" };
86
+ const row = {
87
+ engine: name,
88
+ provider: ctx?.provider || "",
89
+ query,
90
+ tier: name,
91
+ latencyMs: Date.now() - started,
92
+ walker: "off",
93
+ };
94
+ if (out.text) {
95
+ metrics.logTurn(row);
96
+ return {
97
+ engine: name,
98
+ packBlock: `## Memory facts (${spec.label})\n${out.text}\n\n`,
99
+ entityBlock: "",
100
+ packMatches: [],
101
+ entityMatches: [],
102
+ };
103
+ }
104
+ const fallback = ctx?.liveCall === true ? classic : discoverer;
105
+ console.error(`[recall] ${name} engine fell back to ${fallback.name}: ${out.error}`);
106
+ metrics.logTurn({ ...row, fallback: `${out.error} -> ${fallback.name}` });
107
+ const result = await fallback.run(ctx);
108
+ return { ...result, engine: `${name}->${fallback.name}` };
109
+ }
110
+ return { name, run };
111
+ }
112
+
113
+ module.exports = { x: makeEngine("x"), mem0: makeEngine("mem0"), SPECS, MAX_CHARS };
@@ -355,7 +355,7 @@ function syncFromCorpus(packsLib, entitiesLib) {
355
355
  const parentId = resolve(String(p.parent).toLowerCase());
356
356
  if (parentId && addEdge(id, parentId, "parent")) count++;
357
357
  }
358
- const body = [p.sections.Stance, p.sections.Procedure, p.sections.State, p.sections.Journal].join("\n");
358
+ const body = [p.sections.Stance, p.sections.Procedure, p.sections.State, p.sections.Decisions, p.sections.Journal].join("\n");
359
359
  for (const link of parseLinks(body)) {
360
360
  const target = resolve(link);
361
361
  if (!target || target === id) continue;
@@ -9,16 +9,21 @@
9
9
  // the default: it's fail-open (falls back to the classic keyword baseline on
10
10
  // any error), so it never recalls worse than classic, only better. `ask`
11
11
  // injects nothing and points the model at on-demand recall instead
12
- // (`open-claudia recall query`) — pull, not push.
12
+ // (`open-claudia recall query`) — pull, not push. `x` and `mem0` run an external
13
+ // memory search per turn (core/recall/external.js), falling back to the
14
+ // discoverer when it times out, fails or finds nothing.
13
15
 
14
16
  const classic = require("./classic");
15
17
  const discoverer = require("./discoverer");
16
18
  const ask = require("./ask");
19
+ const external = require("./external");
17
20
 
18
21
  const ENGINES = {
19
22
  classic,
20
23
  discoverer,
21
24
  ask,
25
+ x: external.x,
26
+ mem0: external.mem0,
22
27
  };
23
28
 
24
29
  // The default recall engine for channels that haven't explicitly chosen one
@@ -41,4 +46,7 @@ function getEngine(name) {
41
46
  return ENGINES[name] || classic;
42
47
  }
43
48
 
44
- module.exports = { ENGINES, listEngines, activeEngineName, getEngine, DEFAULT_ENGINE };
49
+ // Engines that spawn a local command: only the owner may switch to them.
50
+ const OWNER_ONLY_ENGINES = ["x", "mem0"];
51
+
52
+ module.exports = { ENGINES, listEngines, activeEngineName, getEngine, DEFAULT_ENGINE, OWNER_ONLY_ENGINES };
@@ -26,7 +26,8 @@ function enabled() {
26
26
  // { engine, query, tier, seeds:[{id,score}], activated:[{id,activation,hop}],
27
27
  // cand:[{id,act}], kept:[{id,why}], auto:{kept:[],dropped:[],shadow:{checks,agree}},
28
28
  // gated (bool), latencyMs, seedMs, expandMs, walkerMs,
29
- // walker ("warm"|"cold"|"off"|"failed"), costUsd }
29
+ // walker ("warm"|"cold"|"off"|"failed"), costUsd,
30
+ // fallback (x/mem0 only: why that turn fell back) }
30
31
  function logTurn(entry) {
31
32
  if (!enabled()) return;
32
33
  try {
@@ -50,8 +51,12 @@ function logTurn(entry) {
50
51
  walkerMs: entry.walkerMs || 0,
51
52
  walker: entry.walker || "",
52
53
  costUsd: entry.costUsd || 0,
54
+ ...(entry.fallback ? { fallback: String(entry.fallback).slice(0, 200) } : {}),
53
55
  };
54
56
  fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
57
+ // A fallback row (x/mem0 failed) is followed by the fallback engine's own
58
+ // row for the same turn; only that one counts in the rollups.
59
+ if (rec.fallback) return;
55
60
  bumpSummary(rec);
56
61
  bumpNodeStats(rec);
57
62
  } catch (e) {}
@@ -143,7 +143,7 @@ async function query(question, opts = {}) {
143
143
  // own box); bot-side callers still pass through the per-turn engine's gate.
144
144
  if (opts.episodes !== false && transcriptIndex && transcriptIndex.available()) {
145
145
  try {
146
- const { hits } = transcriptIndex.search(q, { limit: tuning.get("episodeLimit") });
146
+ const { hits } = transcriptIndex.searchRelated(q, { limit: tuning.get("episodeLimit") });
147
147
  for (const h of (hits || [])) {
148
148
  const terms = [...String(h.snip || "").matchAll(/>>(.+?)<</g)].map((m) => m[1]).slice(0, 4);
149
149
  candidates.push({
@@ -69,13 +69,21 @@ function clampKnob(name, value) {
69
69
  return Math.min(spec.max, Math.max(spec.min, n));
70
70
  }
71
71
 
72
+ // Operator pin for a knob: its env var, from the process env or the bot's
73
+ // .env file (the bot never copies .env into process.env).
74
+ function pinOf(spec) {
75
+ if (!spec.env) return undefined;
76
+ const live = process.env[spec.env];
77
+ if (live !== undefined && live !== "") return live;
78
+ try { return require("../config").settingValue(spec.env); } catch (e) { return undefined; }
79
+ }
80
+
72
81
  // Effective value of a knob, honouring env pin > tuning file > default.
73
82
  function get(name) {
74
83
  const spec = KNOBS[name];
75
84
  if (!spec) return undefined;
76
- if (spec.env && process.env[spec.env] !== undefined && process.env[spec.env] !== "") {
77
- return clampKnob(name, process.env[spec.env]);
78
- }
85
+ const pin = pinOf(spec);
86
+ if (pin !== undefined) return clampKnob(name, pin);
79
87
  const data = readFile();
80
88
  if (data.knobs && data.knobs[name] !== undefined) return clampKnob(name, data.knobs[name]);
81
89
  return spec.def;
@@ -89,7 +97,7 @@ function knobTable() {
89
97
  min: spec.min,
90
98
  max: spec.max,
91
99
  values: spec.values,
92
- pinned: !!(spec.env && process.env[spec.env]),
100
+ pinned: pinOf(spec) !== undefined,
93
101
  }));
94
102
  }
95
103
 
@@ -98,7 +106,7 @@ function knobTable() {
98
106
  function set(name, value, { reason = "", evidence = "" } = {}) {
99
107
  const spec = KNOBS[name];
100
108
  if (!spec) return null;
101
- if (spec.env && process.env[spec.env]) return null; // operator pin wins — don't fight it
109
+ if (pinOf(spec) !== undefined) return null; // operator pin wins — don't fight it
102
110
  const from = get(name);
103
111
  const to = clampKnob(name, value);
104
112
  if (to === from) return null;
@@ -0,0 +1,118 @@
1
+ // A resumed Claude Code session keeps the system prompt it started with: the
2
+ // --append-system-prompt on a --resume is ignored (proven 22 Oct 2026 — a
3
+ // resumed session still answered from its first prompt and never saw the new
4
+ // one). So an edit to the soul, persona, lessons or any core section never
5
+ // reached a running conversation until it was compacted or restarted fresh.
6
+ //
7
+ // This remembers, per provider session, the core instructions it has been
8
+ // told (one small file per session), and on the next turn hands over only the
9
+ // lines that changed since, once. The baseline then moves to the new core, so
10
+ // each change rides exactly one turn of each conversation.
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const crypto = require("crypto");
14
+ const CONFIG_DIR = require("../config-dir");
15
+
16
+ const DIR = path.join(CONFIG_DIR, "cache", "session-core");
17
+ const MAX_FILES = 300;
18
+ const MAX_CHARS = 6000;
19
+ // Lessons rotate in and out of the always-loaded block by budget, so a lesson
20
+ // leaving it has NOT stopped applying; new lessons reach a running session
21
+ // through system-prompt.js newLessonsSection instead.
22
+ const SKIP_SECTIONS = [/^Lessons learned/i];
23
+
24
+ function sectionsOf(core) {
25
+ const sections = {};
26
+ let heading = "(top)";
27
+ for (const line of String(core || "").split("\n")) {
28
+ if (/^## /.test(line)) { heading = line.slice(3).trim(); sections[heading] ||= []; continue; }
29
+ if (SKIP_SECTIONS.some((re) => re.test(heading))) continue;
30
+ if (!line.trim()) continue;
31
+ (sections[heading] ||= []).push(line);
32
+ }
33
+ return sections;
34
+ }
35
+
36
+ function fileFor(sessionId) {
37
+ const name = crypto.createHash("sha1").update(String(sessionId)).digest("hex").slice(0, 24);
38
+ return path.join(DIR, `${name}.json`);
39
+ }
40
+
41
+ function prune() {
42
+ try {
43
+ const files = fs.readdirSync(DIR)
44
+ .map((f) => ({ f, t: fs.statSync(path.join(DIR, f)).mtimeMs }))
45
+ .sort((a, b) => b.t - a.t);
46
+ for (const { f } of files.slice(MAX_FILES)) fs.rmSync(path.join(DIR, f), { force: true });
47
+ } catch (_) { /* best effort */ }
48
+ }
49
+
50
+ function save(sessionId, sections) {
51
+ fs.mkdirSync(DIR, { recursive: true });
52
+ const file = fileFor(sessionId);
53
+ const tmp = `${file}.${process.pid}.tmp`;
54
+ fs.writeFileSync(tmp, JSON.stringify({ sessionId, at: new Date().toISOString(), sections }));
55
+ fs.renameSync(tmp, file);
56
+ }
57
+
58
+ function diffSections(before, after) {
59
+ const out = [];
60
+ const headings = [...new Set([...Object.keys(after), ...Object.keys(before)])];
61
+ for (const heading of headings) {
62
+ const old = before[heading];
63
+ const now = after[heading];
64
+ if (!old) { out.push({ heading, added: now, removed: [], isNew: true }); continue; }
65
+ if (!now) { out.push({ heading, added: [], removed: [], isGone: true }); continue; }
66
+ const oldSet = new Set(old);
67
+ const nowSet = new Set(now);
68
+ const added = now.filter((line) => !oldSet.has(line));
69
+ const removed = old.filter((line) => !nowSet.has(line));
70
+ if (added.length || removed.length) out.push({ heading, added, removed });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ function render(changes) {
76
+ const parts = [];
77
+ let used = 0;
78
+ let cut = 0;
79
+ for (const change of changes) {
80
+ const lines = [`### ${change.heading}${change.isNew ? " (new section)" : ""}`];
81
+ if (change.isGone) lines.push("This whole section was removed and no longer applies.");
82
+ for (const line of change.added) lines.push(`+ ${line}`);
83
+ for (const line of change.removed) lines.push(`- (no longer applies) ${line}`);
84
+ const block = lines.join("\n");
85
+ if (used + block.length > MAX_CHARS) { cut += 1; continue; }
86
+ parts.push(block);
87
+ used += block.length;
88
+ }
89
+ if (!parts.length && !cut) return "";
90
+ return [
91
+ "## Instruction changes since this conversation started",
92
+ "Your instructions were updated after this conversation began. Lines marked + are now part of them; lines marked - no longer apply. Follow the new wording over the old.",
93
+ ...parts,
94
+ ...(cut ? [`(${cut} more changed section(s) did not fit; they apply in full from the next new conversation.)`] : []),
95
+ ].join("\n\n");
96
+ }
97
+
98
+ // sessionId: the provider session being resumed (null on a fresh run: that
99
+ // run is given the current core directly). currentCore: freshly built core.
100
+ function changesSince(sessionId, currentCore) {
101
+ if (!sessionId || !currentCore) return "";
102
+ try {
103
+ const now = sectionsOf(currentCore);
104
+ const file = fileFor(sessionId);
105
+ if (!fs.existsSync(file)) {
106
+ // First sight of this session: it was started with (about) this core.
107
+ save(sessionId, now);
108
+ prune();
109
+ return "";
110
+ }
111
+ const before = JSON.parse(fs.readFileSync(file, "utf8")).sections || {};
112
+ const text = render(diffSections(before, now));
113
+ if (text) save(sessionId, now);
114
+ return text;
115
+ } catch (_) { return ""; }
116
+ }
117
+
118
+ module.exports = { changesSince, sectionsOf, diffSections, render, DIR };