@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/CHANGELOG.md +22 -0
- package/Dockerfile +49 -21
- package/README.md +1 -1
- package/bin/cli.js +7 -0
- package/bin/schedule.js +10 -0
- package/core/actions.js +1 -0
- package/core/config.js +11 -0
- package/core/day-seeds.js +23 -3
- package/core/dream.js +21 -3
- package/core/handlers.js +6 -2
- package/core/help/background.md +51 -0
- package/core/lessons.js +28 -1
- package/core/pack-guard.js +1 -1
- package/core/pack-review.js +24 -7
- package/core/packs.js +33 -5
- package/core/providers/claude.js +22 -0
- package/core/recall/discoverer.js +7 -3
- package/core/recall/external.js +113 -0
- package/core/recall/graph.js +1 -1
- package/core/recall/index.js +10 -2
- package/core/recall/metrics.js +6 -1
- package/core/recall/query.js +1 -1
- package/core/recall/tuning.js +13 -5
- package/core/session-core.js +118 -0
- package/core/system-prompt.js +38 -56
- package/core/transcript-index.js +56 -1
- package/core/utility-agent.js +6 -1
- package/package.json +2 -2
- package/test-provider-claude.js +14 -1
- package/test-provider-core-prompt.js +2 -1
- package/test-provider-language.js +6 -1
- package/test-recall-discoverer.js +16 -0
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
|
-
|
|
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, " ");
|
package/core/providers/claude.js
CHANGED
|
@@ -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) =>
|
|
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.
|
|
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 };
|
package/core/recall/graph.js
CHANGED
|
@@ -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;
|
package/core/recall/index.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/core/recall/metrics.js
CHANGED
|
@@ -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) {}
|
package/core/recall/query.js
CHANGED
|
@@ -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.
|
|
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({
|
package/core/recall/tuning.js
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
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:
|
|
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
|
|
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 };
|