@inetafrica/open-claudia 2.6.58 → 2.6.59
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/README.md +8 -3
- package/bin/cli.js +4 -0
- package/bin/kpi.js +55 -0
- package/core/dream.js +384 -11
- package/core/pack-review.js +32 -4
- package/core/recall/discoverer.js +92 -24
- package/core/recall/graph.js +29 -2
- package/core/recall/kpi.js +227 -0
- package/core/recall/metrics.js +198 -4
- package/core/recall/tuning.js +125 -0
- package/core/runner.js +4 -0
- package/core/system-prompt.js +1 -0
- package/core/transcript-index.js +21 -1
- package/package.json +2 -2
- package/test-recall-discoverer.js +21 -6
package/core/dream.js
CHANGED
|
@@ -23,6 +23,9 @@ const daySeeds = require("./day-seeds");
|
|
|
23
23
|
const packGuard = require("./pack-guard");
|
|
24
24
|
const persona = require("./persona");
|
|
25
25
|
const { spawnSubagent } = require("./subagent");
|
|
26
|
+
const recallMetrics = require("./recall/metrics");
|
|
27
|
+
const recallTuning = require("./recall/tuning");
|
|
28
|
+
const recallGraphLib = require("./recall/graph");
|
|
26
29
|
|
|
27
30
|
// Graph surgery + threshold tuning is the highest-judgment work in the recall
|
|
28
31
|
// system, so the dream runs on the best available high tier. Honour an explicit
|
|
@@ -107,11 +110,134 @@ function clip(s, n) {
|
|
|
107
110
|
return t.length > n ? t.slice(0, n) + "\n…[truncated]" : t;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
|
|
113
|
+
// --- Dream state: last run / last full sweep / metrics snapshot for windowed
|
|
114
|
+
// health deltas / pending knob-change awaiting its rollback check.
|
|
115
|
+
const DREAM_STATE_FILE = path.join(CONFIG_DIR, "dream-state.json");
|
|
116
|
+
const FULL_SWEEP_DAYS = Number(process.env.DREAM_FULL_SWEEP_DAYS || 30);
|
|
117
|
+
|
|
118
|
+
function readDreamState() {
|
|
119
|
+
try { return JSON.parse(fs.readFileSync(DREAM_STATE_FILE, "utf8")) || {}; } catch (e) { return {}; }
|
|
120
|
+
}
|
|
121
|
+
function writeDreamState(s) {
|
|
122
|
+
try { fs.writeFileSync(DREAM_STATE_FILE, JSON.stringify(s, null, 2)); } catch (e) {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Windowed metric deltas between two summary snapshots (counters only grow).
|
|
126
|
+
const DELTA_KEYS = ["turns", "gatedTurns", "seedsOnlyTurns", "seedsTotal", "activatedTotal", "keptTotal",
|
|
127
|
+
"rescueTurns", "rescues", "opens", "reviewerUses", "reviewerSkipped", "autoKept", "autoDropped",
|
|
128
|
+
"shadowChecks", "shadowAgree", "latencyMsTotal", "costUsd"];
|
|
129
|
+
function summaryDelta(cur, prev) {
|
|
130
|
+
const c = cur || {}, p = prev || {};
|
|
131
|
+
const d = {};
|
|
132
|
+
for (const k of DELTA_KEYS) d[k] = Math.max(0, (c[k] || 0) - (p[k] || 0));
|
|
133
|
+
d.byTier = {};
|
|
134
|
+
for (const [t, v] of Object.entries(c.byTier || {})) {
|
|
135
|
+
const pv = (p.byTier || {})[t] || { n: 0, ms: 0 };
|
|
136
|
+
d.byTier[t] = { n: Math.max(0, (v.n || 0) - pv.n), ms: Math.max(0, (v.ms || 0) - pv.ms) };
|
|
137
|
+
}
|
|
138
|
+
return d;
|
|
139
|
+
}
|
|
140
|
+
function healthOf(delta) {
|
|
141
|
+
const turns = delta.turns || 0;
|
|
142
|
+
const used = (delta.opens || 0) + (delta.reviewerUses || 0);
|
|
143
|
+
return {
|
|
144
|
+
turns,
|
|
145
|
+
rescueRate: turns ? (delta.rescueTurns || 0) / turns : 0,
|
|
146
|
+
noiseRate: delta.keptTotal ? Math.max(0, 1 - used / delta.keptTotal) : 0,
|
|
147
|
+
avgLatencyMs: turns ? Math.round((delta.latencyMsTotal || 0) / turns) : 0,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Algorithmic pre-computation for the consolidation prompt. Instead of
|
|
152
|
+
// asking the model to discover duplicates in a wall of ~130 full pack bodies,
|
|
153
|
+
// deterministic code picks (a) which packs deserve a full dump this night and
|
|
154
|
+
// (b) which look like merge candidates — the model judges pre-computed
|
|
155
|
+
// clusters, everything else rides along as a one-line evidence row. Cuts the
|
|
156
|
+
// nightly prompt by ~70% with no accuracy loss: an untouched pack was already
|
|
157
|
+
// judged by a previous dream, and merge candidates are chosen by content
|
|
158
|
+
// similarity over the WHOLE corpus regardless of touch date.
|
|
159
|
+
function dreamTokens(s) {
|
|
160
|
+
return new Set(String(s || "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2));
|
|
161
|
+
}
|
|
162
|
+
function packSim(a, b) {
|
|
163
|
+
const ta = dreamTokens(`${a.name} ${a.description} ${a.tags.join(" ")}`);
|
|
164
|
+
const tb = dreamTokens(`${b.name} ${b.description} ${b.tags.join(" ")}`);
|
|
165
|
+
if (!ta.size || !tb.size) return 0;
|
|
166
|
+
let inter = 0;
|
|
167
|
+
for (const w of ta) if (tb.has(w)) inter++;
|
|
168
|
+
return inter / (ta.size + tb.size - inter);
|
|
169
|
+
}
|
|
170
|
+
function mergeClusters(allPacks, { threshold = 0.5, maxClusters = 6, maxMembers = 4 } = {}) {
|
|
171
|
+
const parent = new Map(allPacks.map((p) => [p.dir, p.dir]));
|
|
172
|
+
const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
|
|
173
|
+
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); };
|
|
174
|
+
for (let i = 0; i < allPacks.length; i++) {
|
|
175
|
+
for (let j = i + 1; j < allPacks.length; j++) {
|
|
176
|
+
const a = allPacks[i], b = allPacks[j];
|
|
177
|
+
if ((a.kind === "ability") !== (b.kind === "ability")) continue; // ability/context never merge
|
|
178
|
+
if (packSim(a, b) >= threshold) union(a.dir, b.dir);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const groups = new Map();
|
|
182
|
+
for (const p of allPacks) {
|
|
183
|
+
const r = find(p.dir);
|
|
184
|
+
if (!groups.has(r)) groups.set(r, []);
|
|
185
|
+
groups.get(r).push(p.dir);
|
|
186
|
+
}
|
|
187
|
+
return [...groups.values()]
|
|
188
|
+
.filter((g) => g.length >= 2)
|
|
189
|
+
.map((g) => g.slice(0, maxMembers))
|
|
190
|
+
.slice(0, maxClusters);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Which packs get their FULL body in tonight's prompt: touched since the last
|
|
194
|
+
// dream, their 1-hop graph neighbours, every shared-concern pack (few, and
|
|
195
|
+
// they govern), and all merge-cluster members. On a full sweep: everything.
|
|
196
|
+
function selectDumpDirs(allPacks, { sinceTs, fullSweep, clusters }) {
|
|
197
|
+
if (fullSweep || !sinceTs) return new Set(allPacks.map((p) => p.dir));
|
|
198
|
+
const dump = new Set();
|
|
199
|
+
for (const p of allPacks) {
|
|
200
|
+
if ((p.updated && p.updated > sinceTs) || (p.last_used && p.last_used > sinceTs)) dump.add(p.dir);
|
|
201
|
+
if (recallGraphLib.isSharedConcern(p)) dump.add(p.dir);
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
if (recallGraphLib.available()) {
|
|
205
|
+
for (const dir of [...dump]) {
|
|
206
|
+
for (const nb of recallGraphLib.neighbors(`pack:${dir}`)) {
|
|
207
|
+
if (nb.node.startsWith("pack:")) dump.add(nb.node.slice(5));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (e) {}
|
|
212
|
+
for (const g of clusters || []) for (const dir of g) dump.add(dir);
|
|
213
|
+
return dump;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function evidenceLines(ev) {
|
|
217
|
+
if (!ev || !ev.summary || !(ev.summary.turns > 0)) return "(no recall telemetry yet)";
|
|
218
|
+
const out = [];
|
|
219
|
+
if (ev.neverKept.length) {
|
|
220
|
+
out.push("Surfaced repeatedly but NEVER kept or used (recall noise — prime retag/archive candidates):");
|
|
221
|
+
for (const n of ev.neverKept.slice(0, 12)) out.push(`- ${n.id} (surfaced ${n.seen}×${n.act ? `, ${n.act}× via graph` : ""})`);
|
|
222
|
+
}
|
|
223
|
+
if (ev.topRescued.length) {
|
|
224
|
+
out.push("Frequently RESCUED by the graph (keyword match misses them — their name/description/tags don't match how they're asked about; retag these):");
|
|
225
|
+
for (const n of ev.topRescued.slice(0, 8)) out.push(`- ${n.id} (rescued ${n.rescues}×)`);
|
|
226
|
+
}
|
|
227
|
+
return out.join("\n") || "(no strong signals yet)";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildDreamPrompt({ sinceTs = null, fullSweep = true, clusters = [], evidence = null, windowDelta = null } = {}) {
|
|
111
231
|
const allPacks = packs.listPacks();
|
|
112
232
|
const allEntities = entities.listEntities();
|
|
113
233
|
|
|
114
|
-
const
|
|
234
|
+
const dumpDirs = selectDumpDirs(allPacks, { sinceTs, fullSweep, clusters });
|
|
235
|
+
const nodeHist = (() => { try { return recallMetrics.nodeStats().nodes || {}; } catch (e) { return {}; } })();
|
|
236
|
+
|
|
237
|
+
const fullPacks = allPacks.filter((p) => dumpDirs.has(p.dir));
|
|
238
|
+
const rowPacks = allPacks.filter((p) => !dumpDirs.has(p.dir));
|
|
239
|
+
|
|
240
|
+
const packDump = fullPacks.map((p) => clip([
|
|
115
241
|
`=== pack: ${p.dir}${p.parent ? ` (parent: ${p.parent})` : ""}${p.kind === "ability" ? " [ABILITY]" : ""}`,
|
|
116
242
|
`name: ${p.name}`,
|
|
117
243
|
`description: ${p.description}`,
|
|
@@ -123,6 +249,24 @@ function buildDreamPrompt() {
|
|
|
123
249
|
`Journal:\n${p.sections.Journal}`,
|
|
124
250
|
].join("\n"), MAX_PACK_CHARS)).join("\n\n") || "(none)";
|
|
125
251
|
|
|
252
|
+
// One evidence row per untouched pack — enough for retag/parents/archive
|
|
253
|
+
// judgement (name, tags, usage, recall stats) without the body cost.
|
|
254
|
+
const packIndex = rowPacks.map((p) => {
|
|
255
|
+
const h = nodeHist[`pack:${p.dir}`] || {};
|
|
256
|
+
const idleDays = p.last_used ? Math.round((Date.now() - Date.parse(p.last_used)) / 86400000) : null;
|
|
257
|
+
return `- ${p.dir}${p.kind === "ability" ? " [ABILITY]" : ""}${p.parent ? ` (parent: ${p.parent})` : ""} | ${p.name} | tags: ${p.tags.join(", ") || "—"} | used ${p.usage_count || 0}×${idleDays !== null ? `, idle ${idleDays}d` : ", never used"} | recall: surfaced ${h.seen || 0}× kept ${h.kept || 0}×`;
|
|
258
|
+
}).join("\n");
|
|
259
|
+
|
|
260
|
+
const clusterBlock = (clusters || []).length
|
|
261
|
+
? clusters.map((g, i) => `${i + 1}. ${g.join(" + ")}`).join("\n")
|
|
262
|
+
: "(none found)";
|
|
263
|
+
|
|
264
|
+
const win = windowDelta ? healthOf(windowDelta) : null;
|
|
265
|
+
const evidenceBlock = [
|
|
266
|
+
win ? `Window since last dream: ${win.turns} turns, rescue rate ${(win.rescueRate * 100).toFixed(0)}% (kept nodes no keyword seed found — the graph saved them), noise ${(win.noiseRate * 100).toFixed(0)}% (kept but never opened/used), avg recall latency ${win.avgLatencyMs}ms.` : "",
|
|
267
|
+
evidenceLines(evidence),
|
|
268
|
+
].filter(Boolean).join("\n");
|
|
269
|
+
|
|
126
270
|
const entityDump = allEntities.map((e) => clip([
|
|
127
271
|
`=== entity: ${e.slug} (${e.type}${e.aliases.length ? `, aka ${e.aliases.join(", ")}` : ""})`,
|
|
128
272
|
`description: ${e.description}`,
|
|
@@ -135,13 +279,27 @@ function buildDreamPrompt() {
|
|
|
135
279
|
`- [${l.id}] "${l.text}"${l.src ? ` (src: ${l.src})` : ""} — reinforced ${l.reinforced || 0}×, since ${(l.created || "").slice(0, 10)}, origin ${l.origin || "user"}`
|
|
136
280
|
).join("\n") || "(none)";
|
|
137
281
|
|
|
138
|
-
|
|
282
|
+
const knobBlock = recallTuning.knobTable().map((k) =>
|
|
283
|
+
`- ${k.name}: current ${k.current}${k.values ? ` (one of ${k.values.join("/")})` : ` (bounds ${k.min}–${k.max}, default ${k.default})`}${k.pinned ? " [PINNED]" : ""}`
|
|
284
|
+
).join("\n");
|
|
139
285
|
|
|
140
|
-
|
|
286
|
+
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 memory: context packs (living topic documents), entity notes (people/places/projects/orgs/systems), and her always-loaded lessons, plus her current persona.
|
|
287
|
+
|
|
288
|
+
Today: ${new Date().toISOString().slice(0, 10)}${fullSweep ? " (FULL SWEEP — every pack shown in full)" : ` (delta night — packs touched since ${String(sinceTs).slice(0, 10)}, their graph neighbours, shared concerns, and merge candidates are shown in full; the rest as index rows)`}
|
|
141
289
|
|
|
142
|
-
|
|
290
|
+
Some packs are SHARED CONCERNS (tagged concern/shared/cross-cutting): standing cross-cutting rules — change control, credential handling, release hygiene — that govern many projects via [[links]] and governed-by graph edges. They are rarely opened directly, so LOW USAGE ON A CONCERN IS NORMAL: never archive one for coldness, and never merge one into a project pack (or vice versa) — that would silently detach every governed project.
|
|
291
|
+
|
|
292
|
+
CONTEXT PACKS (full):
|
|
143
293
|
|
|
144
294
|
${packDump}
|
|
295
|
+
${packIndex ? `\nCONTEXT PACK INDEX (untouched since last dream — summaries only; valid targets for retag/parents/archive, but merges ONLY among the fully-shown packs above):\n\n${packIndex}\n` : ""}
|
|
296
|
+
MERGE CANDIDATES (pre-computed by content similarity — judge these; do not hunt for others):
|
|
297
|
+
|
|
298
|
+
${clusterBlock}
|
|
299
|
+
|
|
300
|
+
RECALL EVIDENCE (live usage telemetry — ground consolidation decisions in these numbers):
|
|
301
|
+
|
|
302
|
+
${evidenceBlock}
|
|
145
303
|
|
|
146
304
|
ENTITIES:
|
|
147
305
|
|
|
@@ -167,6 +325,9 @@ Your job — decide what consolidation, if any, is warranted:
|
|
|
167
325
|
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.
|
|
168
326
|
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.
|
|
169
327
|
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.
|
|
328
|
+
11. tuning: OPTIONALLY propose ONE bounded recall-knob change, justified ONLY by the RECALL EVIDENCE numbers (e.g. rescue rate persistently low → raise walkerMaxCandidates; latency high while rescue is flat → lower it; seeds tier missing context on short asks → raise seedsTierMaxWords). Knobs and bounds:
|
|
329
|
+
${knobBlock}
|
|
330
|
+
A knob marked PINNED is operator-controlled — never propose it. Most nights the right answer is null: change nothing without clear evidence, never more than one knob, and prefer small steps (the system auto-rolls-back a change that worsens rescue/noise).
|
|
170
331
|
|
|
171
332
|
Hard rules:
|
|
172
333
|
- Never invent packs/entities not listed above; reference them by exact dir/slug.
|
|
@@ -184,7 +345,9 @@ Reply with ONLY a JSON object, no prose, no code fences:
|
|
|
184
345
|
"archive": [{"pack": "<dir>", "reason": "<one line: why it's cold>"}],
|
|
185
346
|
"lessons": [{"op": "remove", "text": "<exact current lesson text>", "reason": "<duplicate of … / safely in pack … / cold>"}],
|
|
186
347
|
"persona": null,
|
|
348
|
+
"tuning": null,
|
|
187
349
|
"report": "<chat message>"}
|
|
350
|
+
(a tuning proposal looks like {"knob": "<name>", "to": <value>, "reason": "<one line grounded in the evidence>"})
|
|
188
351
|
(a lesson "edit" looks like {"op": "edit", "text": "<exact current lesson text>", "to": "<tightened wording>"})`;
|
|
189
352
|
}
|
|
190
353
|
|
|
@@ -206,6 +369,7 @@ function parseDream(text) {
|
|
|
206
369
|
archive: arr("archive"),
|
|
207
370
|
lessons: arr("lessons"),
|
|
208
371
|
persona: typeof obj.persona === "string" ? obj.persona : null,
|
|
372
|
+
tuning: obj.tuning && typeof obj.tuning === "object" && obj.tuning.knob ? obj.tuning : null,
|
|
209
373
|
report: typeof obj.report === "string" ? obj.report.trim() : "",
|
|
210
374
|
};
|
|
211
375
|
} catch (e) {
|
|
@@ -300,6 +464,14 @@ function applyDream(decision, backupRoot) {
|
|
|
300
464
|
lines.push(`🧩 Skipped merge into ${m.into} — won't mix a reusable ability with project packs.`);
|
|
301
465
|
continue;
|
|
302
466
|
}
|
|
467
|
+
// Shared concerns govern many projects via edges; merging one into a
|
|
468
|
+
// project pack (or vice versa) would silently detach every governed
|
|
469
|
+
// project. Concern↔concern merges remain legal.
|
|
470
|
+
const intoIsConcern = recallGraphLib.isSharedConcern(into);
|
|
471
|
+
if (from.some((d) => recallGraphLib.isSharedConcern(packs.readPack(d)) !== intoIsConcern)) {
|
|
472
|
+
lines.push(`🛡️ Skipped merge into ${m.into} — won't mix a shared concern with a project pack.`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
303
475
|
const mGuard = packGuard.scanSections({ stance: m.stance, procedure: m.procedure, state: m.state, journal: m.journal });
|
|
304
476
|
if (mGuard.flagged) {
|
|
305
477
|
console.warn(`[dream] guard blocked merge into ${m.into} (${mGuard.kind} in ${mGuard.section})`);
|
|
@@ -425,6 +597,10 @@ function applyDream(decision, backupRoot) {
|
|
|
425
597
|
const pack = packs.readPack(dir);
|
|
426
598
|
if (!pack) continue;
|
|
427
599
|
if (allPacks.some((p) => p.parent === dir)) continue; // never orphan children
|
|
600
|
+
if (recallGraphLib.isSharedConcern(pack)) {
|
|
601
|
+
lines.push(`🛡️ Left ${dir} alone — shared concerns are rarely opened directly; coldness there is normal.`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
428
604
|
const ageDays = pack.created ? (Date.now() - Date.parse(pack.created)) / 86400000 : Infinity;
|
|
429
605
|
const idleDays = pack.last_used ? (Date.now() - Date.parse(pack.last_used)) / 86400000 : ageDays;
|
|
430
606
|
const uses = Number(pack.usage_count) || 0;
|
|
@@ -680,7 +856,9 @@ function writeDreamReport(data) {
|
|
|
680
856
|
if (data.introspection.proposed) {
|
|
681
857
|
sec.push("", "### Proposed (not applied — DREAM_SELF_APPLY=off)", "```json", JSON.stringify(data.introspection.proposed, null, 2), "```");
|
|
682
858
|
}
|
|
859
|
+
if (data.healthNote) sec.push("", "## Memory health", data.healthNote);
|
|
683
860
|
if (data.graphNote) sec.push("", data.graphNote);
|
|
861
|
+
if (data.episodicNote) sec.push("", data.episodicNote);
|
|
684
862
|
if (data.toolNote) sec.push("", data.toolNote);
|
|
685
863
|
if (data.staleNote) sec.push("", data.staleNote);
|
|
686
864
|
if (data.backupRoot) sec.push("", `Backups: ${data.backupRoot}`);
|
|
@@ -738,6 +916,72 @@ function nestPrefixFamilies(backupRoot) {
|
|
|
738
916
|
return { lines, filed };
|
|
739
917
|
}
|
|
740
918
|
|
|
919
|
+
// Journal backfill dedupe. The runtime guard (packs.isNearDupJournal) stops
|
|
920
|
+
// NEW near-duplicate journal lines, but entries stacked before it shipped are
|
|
921
|
+
// still crowding real history out of capped Journals. Same similarity test,
|
|
922
|
+
// applied retroactively: walk each Journal oldest→newest keeping a line only
|
|
923
|
+
// if it isn't a near-dup of the last 5 kept. Backed up, capped per night.
|
|
924
|
+
function journalBackfillDedupe(backupRoot, { maxPacks = 12 } = {}) {
|
|
925
|
+
const lines = [];
|
|
926
|
+
let cleaned = 0, removed = 0;
|
|
927
|
+
for (const meta of packs.listPacks()) {
|
|
928
|
+
if (cleaned >= maxPacks) break;
|
|
929
|
+
try {
|
|
930
|
+
const pack = packs.readPack(meta.dir);
|
|
931
|
+
const entries = (pack?.sections?.Journal || "").split("\n").filter((l) => l.trim());
|
|
932
|
+
if (entries.length < 3) continue;
|
|
933
|
+
const keep = [];
|
|
934
|
+
for (const e of entries) {
|
|
935
|
+
if (packs.isNearDupJournal(e, keep.slice(-5))) continue;
|
|
936
|
+
keep.push(e);
|
|
937
|
+
}
|
|
938
|
+
if (keep.length === entries.length) continue;
|
|
939
|
+
if (backupRoot) backupPack(meta.dir, backupRoot);
|
|
940
|
+
pack.sections.Journal = keep.join("\n");
|
|
941
|
+
pack.updated = new Date().toISOString();
|
|
942
|
+
packs.writePack(pack);
|
|
943
|
+
cleaned++;
|
|
944
|
+
removed += entries.length - keep.length;
|
|
945
|
+
} catch (e) { console.warn(`[dream] journal dedupe failed for ${meta.dir}: ${e.message}`); }
|
|
946
|
+
}
|
|
947
|
+
if (removed) lines.push(`🧽 Collapsed ${removed} near-duplicate journal line${removed === 1 ? "" : "s"} across ${cleaned} pack${cleaned === 1 ? "" : "s"}`);
|
|
948
|
+
return { lines, cleaned, removed };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Co-rescue edges: two nodes repeatedly rescued TOGETHER by graph expansion
|
|
952
|
+
// are missing a direct edge — wire one deterministically so future traversal
|
|
953
|
+
// reaches them in one hop instead of relying on repeated rescues.
|
|
954
|
+
function coRescueEdgesPhase(ev, { cap = 10 } = {}) {
|
|
955
|
+
const lines = [];
|
|
956
|
+
let wired = 0;
|
|
957
|
+
if (!recallGraphLib.available()) return { lines, wired };
|
|
958
|
+
for (const p of (ev?.coRescuePairs || []).slice(0, cap)) {
|
|
959
|
+
try { recallGraphLib.reinforce(p.a, p.b, 1); wired++; } catch (e) {}
|
|
960
|
+
}
|
|
961
|
+
if (wired) lines.push(`🕸 Wired ${wired} co-rescue edge${wired === 1 ? "" : "s"} — nodes that kept getting rescued together are now directly linked`);
|
|
962
|
+
return { lines, wired };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Negative Hebbian: a node that keeps arriving via graph expansion but is
|
|
966
|
+
// never kept and never used is firing wrongly — ease every edge pulling it
|
|
967
|
+
// in. Only targets nodes with repeated graph arrivals (act ≥ 3): pure seed
|
|
968
|
+
// noise is a retag/archive problem the model handles with the evidence lines.
|
|
969
|
+
function negativeHebbianPhase(ev, { cap = 12 } = {}) {
|
|
970
|
+
const lines = [];
|
|
971
|
+
let nodes = 0, weakened = 0, pruned = 0;
|
|
972
|
+
if (!recallGraphLib.available()) return { lines, nodes, weakened, pruned };
|
|
973
|
+
for (const cand of ev?.neverKept || []) {
|
|
974
|
+
if (nodes >= cap) break;
|
|
975
|
+
if ((cand.act || 0) < 3) continue;
|
|
976
|
+
try {
|
|
977
|
+
const r = recallGraphLib.weakenNode(cand.id);
|
|
978
|
+
if (r.weakened || r.pruned) { nodes++; weakened += r.weakened; pruned += r.pruned; }
|
|
979
|
+
} catch (e) {}
|
|
980
|
+
}
|
|
981
|
+
if (nodes) lines.push(`🪶 Eased graph pull on ${nodes} surfaced-but-never-used node${nodes === 1 ? "" : "s"} (${weakened} edges weakened, ${pruned} pruned)`);
|
|
982
|
+
return { lines, nodes, weakened, pruned };
|
|
983
|
+
}
|
|
984
|
+
|
|
741
985
|
async function runDream({ trigger = "manual" } = {}) {
|
|
742
986
|
if (!enabled()) return { skipped: "dream is disabled (DREAM=off)" };
|
|
743
987
|
if (_dreaming) return { skipped: "a dream is already in progress" };
|
|
@@ -747,6 +991,50 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
747
991
|
|
|
748
992
|
_dreaming = true;
|
|
749
993
|
try {
|
|
994
|
+
const nowISO = new Date().toISOString();
|
|
995
|
+
const dreamState = readDreamState();
|
|
996
|
+
const fullSweep = !dreamState.lastFullSweep ||
|
|
997
|
+
(Date.now() - Date.parse(dreamState.lastFullSweep)) / 86400000 >= FULL_SWEEP_DAYS;
|
|
998
|
+
let ev = null, windowDelta = null, clusters = [];
|
|
999
|
+
try { ev = recallMetrics.evidence(); } catch (e) {}
|
|
1000
|
+
try { windowDelta = summaryDelta(ev?.summary, dreamState.summarySnapshot); } catch (e) {}
|
|
1001
|
+
try { clusters = mergeClusters(packs.listPacks()); } catch (e) {}
|
|
1002
|
+
|
|
1003
|
+
// Closed-loop knob governance — deterministic, BEFORE the model pass so a
|
|
1004
|
+
// bad change gets rolled back even on nights the model call fails.
|
|
1005
|
+
const knobLines = [];
|
|
1006
|
+
try {
|
|
1007
|
+
const pending = dreamState.pendingKnobCheck;
|
|
1008
|
+
if (pending && windowDelta && (windowDelta.turns || 0) >= 30) {
|
|
1009
|
+
const h = healthOf(windowDelta);
|
|
1010
|
+
const base = pending.baseline || {};
|
|
1011
|
+
const rescueDown = base.rescueRate != null && h.rescueRate < base.rescueRate - Math.max(0.02, base.rescueRate * 0.2);
|
|
1012
|
+
const noiseUp = base.noiseRate != null && h.noiseRate > base.noiseRate + Math.max(0.02, base.noiseRate * 0.2);
|
|
1013
|
+
if (rescueDown || noiseUp) {
|
|
1014
|
+
const r = recallTuning.revert(pending.knob, {
|
|
1015
|
+
reason: `${rescueDown ? "rescue rate fell" : "noise rose"} over ${windowDelta.turns} turns (rescue ${(base.rescueRate * 100).toFixed(0)}%→${(h.rescueRate * 100).toFixed(0)}%, noise ${(base.noiseRate * 100).toFixed(0)}%→${(h.noiseRate * 100).toFixed(0)}%)`,
|
|
1016
|
+
});
|
|
1017
|
+
if (r) knobLines.push(`🎛 Rolled back ${pending.knob} to ${r.to} — the change made things worse (${rescueDown ? "rescue rate fell" : "noise rose"} over ${windowDelta.turns} turns).`);
|
|
1018
|
+
} else {
|
|
1019
|
+
knobLines.push(`🎛 Keeping ${pending.knob} at ${recallTuning.get(pending.knob)} — window healthy after ${windowDelta.turns} turns.`);
|
|
1020
|
+
}
|
|
1021
|
+
dreamState.pendingKnobCheck = null;
|
|
1022
|
+
}
|
|
1023
|
+
// Cascade trust check: sampled shadow judgements disagreeing with the
|
|
1024
|
+
// judge means the deterministic verdicts are drifting — switch it off
|
|
1025
|
+
// and let the dream re-enable it later if evidence improves.
|
|
1026
|
+
if (windowDelta && (windowDelta.shadowChecks || 0) >= 20 && recallTuning.get("cascade") !== "off") {
|
|
1027
|
+
const agree = (windowDelta.shadowAgree || 0) / windowDelta.shadowChecks;
|
|
1028
|
+
if (agree < 0.8) {
|
|
1029
|
+
const r = recallTuning.set("cascade", "off", {
|
|
1030
|
+
reason: `shadow agreement ${Math.round(agree * 100)}% over ${windowDelta.shadowChecks} checks (<80%)`,
|
|
1031
|
+
evidence: "dream cascade trust check",
|
|
1032
|
+
});
|
|
1033
|
+
if (r) knobLines.push(`🎛 Paused the walker cascade — shadow samples agreed with the judge only ${Math.round(agree * 100)}% of the time.`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
} catch (e) { console.warn(`[dream] knob governance failed: ${e.message}`); }
|
|
1037
|
+
|
|
750
1038
|
// Best-effort consolidation: on a large corpus this call can time out or
|
|
751
1039
|
// return unreadable JSON. That must NOT abort the dream — the deterministic
|
|
752
1040
|
// phases below (prefix nesting, graph tend) need no model and are the
|
|
@@ -755,7 +1043,7 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
755
1043
|
let decision = null;
|
|
756
1044
|
let modelFailNote = "";
|
|
757
1045
|
try {
|
|
758
|
-
const { text } = await spawnSubagent(buildDreamPrompt(), {
|
|
1046
|
+
const { text } = await spawnSubagent(buildDreamPrompt({ sinceTs: dreamState.lastRun || null, fullSweep, clusters, evidence: ev, windowDelta }), {
|
|
759
1047
|
model: DREAM_MODEL,
|
|
760
1048
|
effort: DREAM_EFFORT,
|
|
761
1049
|
timeoutMs: dreamTimeoutMs(packCount),
|
|
@@ -770,13 +1058,36 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
770
1058
|
|
|
771
1059
|
const backupRoot = makeBackupRoot();
|
|
772
1060
|
const applied = applyDream(decision, backupRoot);
|
|
1061
|
+
|
|
1062
|
+
// Model-proposed knob change: bounded by tuning.js, one per night, and a
|
|
1063
|
+
// baseline snapshot is kept so the next dream can auto-roll-back.
|
|
1064
|
+
if (decision.tuning && decision.tuning.knob) {
|
|
1065
|
+
try {
|
|
1066
|
+
const t = decision.tuning;
|
|
1067
|
+
const r = recallTuning.set(t.knob, t.to, { reason: String(t.reason || "dream proposal").slice(0, 200), evidence: "dream consolidation" });
|
|
1068
|
+
if (r) {
|
|
1069
|
+
knobLines.push(`🎛 Tuned ${r.knob}: ${r.from} → ${r.to} — ${clip(String(t.reason || ""), 140)}`);
|
|
1070
|
+
dreamState.pendingKnobCheck = { ts: nowISO, knob: r.knob, from: r.from, to: r.to, baseline: healthOf(windowDelta || {}) };
|
|
1071
|
+
}
|
|
1072
|
+
} catch (e) { console.warn(`[dream] tuning apply failed: ${e.message}`); }
|
|
1073
|
+
}
|
|
1074
|
+
|
|
773
1075
|
// Deterministic hierarchy backfill (kazee-people-* → kazee-people), after the
|
|
774
1076
|
// model's parents/umbrellas decision so it only fills the obvious prefix
|
|
775
1077
|
// families left flat. Backed up + announced like everything else.
|
|
776
1078
|
let nestLines = [];
|
|
777
1079
|
try { nestLines = nestPrefixFamilies(backupRoot).lines; }
|
|
778
1080
|
catch (e) { console.warn(`[dream] nesting pass failed: ${e.message}`); }
|
|
779
|
-
|
|
1081
|
+
// Evidence-driven deterministic phases — no model involved, so they run
|
|
1082
|
+
// (and matter most) even on nights the consolidation call fails.
|
|
1083
|
+
let journalLines = [], coRescueLines = [], hebbianLines = [];
|
|
1084
|
+
try { journalLines = journalBackfillDedupe(backupRoot).lines; }
|
|
1085
|
+
catch (e) { console.warn(`[dream] journal dedupe failed: ${e.message}`); }
|
|
1086
|
+
try { coRescueLines = coRescueEdgesPhase(ev).lines; }
|
|
1087
|
+
catch (e) { console.warn(`[dream] co-rescue pass failed: ${e.message}`); }
|
|
1088
|
+
try { hebbianLines = negativeHebbianPhase(ev).lines; }
|
|
1089
|
+
catch (e) { console.warn(`[dream] negative-hebbian pass failed: ${e.message}`); }
|
|
1090
|
+
const consolidationLines = applied.concat(nestLines, journalLines, coRescueLines, hebbianLines, knobLines);
|
|
780
1091
|
let report = decision.report || (consolidationLines.length > 0 ? "Tidied up my memory overnight." : "");
|
|
781
1092
|
if (modelFailNote) {
|
|
782
1093
|
report = `Heads up — ${modelFailNote}, so I skipped the AI-led merges this round but still ran the automatic filing + graph upkeep.${report ? " " + report : ""}`;
|
|
@@ -820,12 +1131,22 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
820
1131
|
// weights, prune orphans. Deterministic + safe, runs every dream.
|
|
821
1132
|
let graphNote = "";
|
|
822
1133
|
try {
|
|
823
|
-
const g =
|
|
1134
|
+
const g = recallGraphLib.tend(packs, entities, { halfLifeDays: recallTuning.get("decayHalfLifeDays") });
|
|
824
1135
|
if (g.synced || g.decayed || g.pruned) {
|
|
825
1136
|
graphNote = `🕸 Recall graph: ${g.edges} edges / ${g.nodes} nodes (synced ${g.synced}, decayed ${g.decayed}, pruned ${g.pruned}).`;
|
|
826
1137
|
}
|
|
827
1138
|
} catch (e) { /* graph is best-effort */ }
|
|
828
1139
|
|
|
1140
|
+
// Episodic index upkeep: index any transcript turns written since the last
|
|
1141
|
+
// sweep and drop rows for transcript files deleted from disk.
|
|
1142
|
+
let episodicNote = "";
|
|
1143
|
+
try {
|
|
1144
|
+
const t = require("./transcript-index").tend();
|
|
1145
|
+
if (t.available && (t.added || t.prunedFiles)) {
|
|
1146
|
+
episodicNote = `🎞 Episodic index: ${t.added} new turn${t.added === 1 ? "" : "s"} indexed${t.prunedFiles ? `, ${t.prunedFiles} stale transcript${t.prunedFiles === 1 ? "" : "s"} pruned` : ""}.`;
|
|
1147
|
+
}
|
|
1148
|
+
} catch (e) { /* episodic index is best-effort */ }
|
|
1149
|
+
|
|
829
1150
|
// Tool hygiene: tend the directed tool-graph (decay reinforced follows-edges,
|
|
830
1151
|
// prune faded/orphaned ones) and flag tools whose --pack link now dangles.
|
|
831
1152
|
// Read-only toward the tools themselves — a tool is never auto-deleted (that's
|
|
@@ -877,11 +1198,59 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
877
1198
|
const staleNote = staleTaskReport();
|
|
878
1199
|
const dreamLines = consolidationLines.concat(introApplied);
|
|
879
1200
|
|
|
1201
|
+
// Memory-health line: the window's KPIs since the previous dream, so every
|
|
1202
|
+
// morning report doubles as a harness performance readout.
|
|
1203
|
+
let healthNote = "";
|
|
1204
|
+
try {
|
|
1205
|
+
if (windowDelta && windowDelta.turns > 0) {
|
|
1206
|
+
const h = healthOf(windowDelta);
|
|
1207
|
+
const tiers = Object.entries(windowDelta.byTier || {})
|
|
1208
|
+
.filter(([, v]) => v.n > 0)
|
|
1209
|
+
.map(([t, v]) => `${t} ${v.n}× (${(v.ms / v.n / 1000).toFixed(1)}s)`).join(", ");
|
|
1210
|
+
const bits = [
|
|
1211
|
+
`${windowDelta.turns} recall turns`,
|
|
1212
|
+
`rescue ${(h.rescueRate * 100).toFixed(0)}%`,
|
|
1213
|
+
`noise ${(h.noiseRate * 100).toFixed(0)}%`,
|
|
1214
|
+
`avg ${(h.avgLatencyMs / 1000).toFixed(1)}s`,
|
|
1215
|
+
];
|
|
1216
|
+
if (tiers) bits.push(`tiers: ${tiers}`);
|
|
1217
|
+
if (windowDelta.autoKept || windowDelta.autoDropped) bits.push(`cascade kept ${windowDelta.autoKept || 0}/dropped ${windowDelta.autoDropped || 0} without the judge`);
|
|
1218
|
+
if (windowDelta.shadowChecks) bits.push(`shadow agreement ${Math.round(100 * (windowDelta.shadowAgree || 0) / windowDelta.shadowChecks)}%`);
|
|
1219
|
+
if (windowDelta.reviewerSkipped) bits.push(`reviewer skipped ${windowDelta.reviewerSkipped} empty turn${windowDelta.reviewerSkipped === 1 ? "" : "s"}`);
|
|
1220
|
+
if (windowDelta.costUsd) bits.push(`recall spend $${windowDelta.costUsd.toFixed(2)}`);
|
|
1221
|
+
healthNote = `📊 Memory health since last dream: ${bits.join(" · ")}.`;
|
|
1222
|
+
}
|
|
1223
|
+
} catch (e) {}
|
|
1224
|
+
|
|
1225
|
+
// Version-stamped KPI snapshot — one line per dream night, the series the
|
|
1226
|
+
// `open-claudia kpi` report charts across versions/models.
|
|
1227
|
+
try {
|
|
1228
|
+
recallMetrics.appendKpi({
|
|
1229
|
+
trigger, fullSweep,
|
|
1230
|
+
models: {
|
|
1231
|
+
walker: process.env.RECALL_DISCOVERER_MODEL || "haiku",
|
|
1232
|
+
reviewer: process.env.PACK_REVIEW_MODEL || "claude-sonnet-4-6",
|
|
1233
|
+
dream: DREAM_MODEL,
|
|
1234
|
+
},
|
|
1235
|
+
window: windowDelta || null,
|
|
1236
|
+
health: windowDelta ? healthOf(windowDelta) : null,
|
|
1237
|
+
knobs: Object.fromEntries(recallTuning.knobTable().map((k) => [k.name, k.current])),
|
|
1238
|
+
});
|
|
1239
|
+
} catch (e) {}
|
|
1240
|
+
|
|
1241
|
+
// Persist dream state: windowing snapshot, sweep stamp, pending knob check.
|
|
1242
|
+
try {
|
|
1243
|
+
dreamState.lastRun = nowISO;
|
|
1244
|
+
if (fullSweep) dreamState.lastFullSweep = nowISO;
|
|
1245
|
+
if (ev?.summary) dreamState.summarySnapshot = ev.summary;
|
|
1246
|
+
writeDreamState(dreamState);
|
|
1247
|
+
} catch (e) { console.warn(`[dream] state write failed: ${e.message}`); }
|
|
1248
|
+
|
|
880
1249
|
const reportPath = writeDreamReport({
|
|
881
1250
|
model: DREAM_MODEL, effort: DREAM_EFFORT, trigger,
|
|
882
1251
|
consolidation: { report: decision.report, lines: consolidationLines },
|
|
883
1252
|
introspection: { report: introReport, lines: introApplied, proposed: introProposed },
|
|
884
|
-
graphNote, toolNote, staleNote, backupRoot,
|
|
1253
|
+
healthNote, graphNote, episodicNote, toolNote, staleNote, backupRoot,
|
|
885
1254
|
});
|
|
886
1255
|
|
|
887
1256
|
// Richer chat summary: consolidation + introspection narrative + what
|
|
@@ -889,19 +1258,21 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
889
1258
|
const parts = [];
|
|
890
1259
|
if (report) parts.push(`💤 ${report}`);
|
|
891
1260
|
if (introReport) parts.push(`🪞 ${introReport}`);
|
|
1261
|
+
if (healthNote) parts.push(healthNote);
|
|
892
1262
|
if (dreamLines.length) parts.push(dreamLines.join("\n"));
|
|
893
1263
|
if (introProposed) {
|
|
894
1264
|
const n = (introProposed.lessons_add?.length || 0) + (introProposed.doc_edits?.length || 0) + (introProposed.entity_edits?.length || 0) + (introProposed.ideas?.length || 0);
|
|
895
1265
|
if (n) parts.push(`📋 ${n} self-improvement change(s) staged for your OK (DREAM_SELF_APPLY=off) — see the log below.`);
|
|
896
1266
|
}
|
|
897
1267
|
if (graphNote) parts.push(graphNote);
|
|
1268
|
+
if (episodicNote) parts.push(episodicNote);
|
|
898
1269
|
if (toolNote) parts.push(toolNote);
|
|
899
1270
|
if (staleNote) parts.push(staleNote);
|
|
900
1271
|
if (dreamLines.length) parts.push(`🗄 Anything changed or merged away is backed up under ${backupRoot}`);
|
|
901
1272
|
if (reportPath) parts.push(`📔 Full dream log: ${reportPath}`);
|
|
902
1273
|
const message = parts.length ? parts.join("\n\n") : "";
|
|
903
1274
|
|
|
904
|
-
return { applied: dreamLines, report, introReport, message, staleNote, graphNote, toolNote, reportPath, trigger };
|
|
1275
|
+
return { applied: dreamLines, report, introReport, message, healthNote, staleNote, graphNote, episodicNote, toolNote, reportPath, trigger, fullSweep };
|
|
905
1276
|
} finally {
|
|
906
1277
|
_dreaming = false;
|
|
907
1278
|
}
|
|
@@ -937,6 +1308,8 @@ function initDream(adapters) {
|
|
|
937
1308
|
module.exports = {
|
|
938
1309
|
runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers, nestPrefixFamilies, dreamTimeoutMs,
|
|
939
1310
|
buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
|
|
1311
|
+
summaryDelta, healthOf, mergeClusters, selectDumpDirs, journalBackfillDedupe,
|
|
1312
|
+
coRescueEdgesPhase, negativeHebbianPhase, readDreamState, writeDreamState,
|
|
940
1313
|
enabled, summaryEnabled, introspectEnabled, selfApplyEnabled,
|
|
941
|
-
DREAM_CRON, DREAM_MODEL, DREAM_EFFORT, PROMOTE_MIN_PROJECTS,
|
|
1314
|
+
DREAM_CRON, DREAM_MODEL, DREAM_EFFORT, PROMOTE_MIN_PROJECTS, DREAM_STATE_FILE, FULL_SWEEP_DAYS,
|
|
942
1315
|
};
|
package/core/pack-review.js
CHANGED
|
@@ -294,12 +294,40 @@ function applyLessonAction(l) {
|
|
|
294
294
|
return { kind: "skipped", reason: r.reason || "not added" };
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
// Correction/preference language: a short turn carrying any of these may hold
|
|
298
|
+
// a Stance-worthy signal, so it always goes to the reviewer regardless of the
|
|
299
|
+
// deterministic skip below.
|
|
300
|
+
const PREFERENCE_RE = /\b(no|not|wrong|actually|instead|don'?t|never|always|prefer|remember|rule|stop|from now on|should)\b/i;
|
|
301
|
+
|
|
302
|
+
// Deterministic reviewer skip-gate. Only skips what is PROVABLY empty:
|
|
303
|
+
// short — combined turn text under the floor (pre-existing behaviour).
|
|
304
|
+
// trivial — recall tier "skip": pleasantry/ack turns.
|
|
305
|
+
// lookup — seeds-tier command turn where the harness AFFIRMED no write-ish
|
|
306
|
+
// tool ran (signals.wrote === false), the exchange is small, and
|
|
307
|
+
// the user text carries no correction/preference language. A pure
|
|
308
|
+
// status lookup has nothing to journal; anything ambiguous
|
|
309
|
+
// (wrote undefined, long reply, preference words) still reviews —
|
|
310
|
+
// accuracy first, the gate only trims provable no-ops.
|
|
311
|
+
function shouldSkipReview({ userText, assistantText, signals = {} }) {
|
|
312
|
+
const combined = (String(userText || "") + String(assistantText || "")).trim();
|
|
313
|
+
if (combined.length < MIN_TURN_CHARS) return "short";
|
|
314
|
+
if (signals.tier === "skip") return "trivial";
|
|
315
|
+
if (signals.tier === "seeds" && signals.wrote === false && combined.length < 1500
|
|
316
|
+
&& !PREFERENCE_RE.test(String(userText || ""))) return "lookup";
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
297
320
|
// Fire-and-forget. `announce` is an async (text) => void bound to the
|
|
298
321
|
// originating channel; failures are logged, never thrown into the turn.
|
|
299
|
-
function reviewTurn({ userText, assistantText, channelId, announce }) {
|
|
322
|
+
function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
|
|
300
323
|
if (!enabled()) return;
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
324
|
+
const skip = shouldSkipReview({ userText, assistantText, signals });
|
|
325
|
+
if (skip) {
|
|
326
|
+
if (skip !== "short") { // count only the new gate's savings, not the old floor
|
|
327
|
+
try { require("./recall/metrics").logReviewerSkip(); } catch (e) {}
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
303
331
|
|
|
304
332
|
const prompt = buildReviewPrompt(redactSensitive(String(userText || "")), redactSensitive(String(assistantText || "")));
|
|
305
333
|
|
|
@@ -379,4 +407,4 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
|
|
|
379
407
|
});
|
|
380
408
|
}
|
|
381
409
|
|
|
382
|
-
module.exports = { reviewTurn, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, ENTITY_EMOJI };
|
|
410
|
+
module.exports = { reviewTurn, shouldSkipReview, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, ENTITY_EMOJI };
|