@inetafrica/open-claudia 3.0.44 → 3.1.1
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 +46 -0
- package/README.md +12 -7
- package/bin/tool.js +15 -5
- package/bot.js +78 -3
- package/core/actions.js +22 -0
- package/core/auth-flow.js +68 -0
- package/core/boot-health.js +84 -0
- package/core/doctor-memory.js +213 -0
- package/core/doctor.js +5 -0
- package/core/dream.js +11 -9
- package/core/entities.js +47 -3
- package/core/handlers.js +160 -2
- package/core/keyring.js +24 -4
- package/core/memory-mutation-queue.js +42 -11
- package/core/pack-review.js +60 -4
- package/core/packs.js +47 -3
- package/core/recall/discoverer.js +22 -6
- package/core/recall/graph.js +84 -52
- package/core/recall/tuning.js +1 -2
- package/core/router.js +18 -0
- package/core/runner.js +21 -0
- package/core/safe-mode.js +227 -0
- package/core/state.js +2 -2
- package/core/system-prompt.js +43 -3
- package/core/tool-graph.js +99 -71
- package/core/tools.js +3 -2
- package/core/turn-observer.js +23 -1
- package/package.json +3 -3
- package/test-extractive-injection.js +21 -0
- package/test-memory-mutation-queue.js +33 -3
- package/test-provider-compaction.js +5 -0
- package/test-provider-core-prompt.js +3 -0
- package/test-provider-language.js +59 -1
- package/test-provider-recall.js +33 -0
- package/test-recall-discoverer.js +8 -0
- package/test-recall-graph.js +66 -11
- package/test-tool-graph.js +67 -16
- package/test-tool-manifest.js +32 -0
- package/test-tools.js +31 -0
|
@@ -8,8 +8,17 @@ const CONFIG_DIR = require("../config-dir");
|
|
|
8
8
|
const DEFAULT_MAX_PENDING = 50;
|
|
9
9
|
const DEFAULT_LOCK_TIMEOUT_MS = 30 * 1000;
|
|
10
10
|
const DEFAULT_LOCK_POLL_MS = 25;
|
|
11
|
-
const DEFAULT_STALE_LOCK_MS = 30 * 60 * 1000;
|
|
12
11
|
const DEFAULT_DRAIN_TIMEOUT_MS = 5000;
|
|
12
|
+
// A lock dir whose owner.json never landed (crash inside the mkdir→write
|
|
13
|
+
// window) is an orphan once it outlives this grace.
|
|
14
|
+
const ORPHAN_LOCK_GRACE_MS = 5000;
|
|
15
|
+
|
|
16
|
+
// Distinguishes process INCARNATIONS sharing a pid. In a container the bot is
|
|
17
|
+
// pid 1 and a restarted bot is ALSO pid 1, so pid-liveness alone made a lock
|
|
18
|
+
// left by a crashed incarnation look held forever — every writer then timed
|
|
19
|
+
// out (measured on a pod with 43 restarts: 87% of reviewer runs failed with
|
|
20
|
+
// MEMORY_MUTATION_LOCK_TIMEOUT and pack capture starved for 16 days).
|
|
21
|
+
const BOOT_ID = crypto.randomBytes(8).toString("hex");
|
|
13
22
|
|
|
14
23
|
class MutationQueueError extends Error {
|
|
15
24
|
constructor(code, message, details = {}) {
|
|
@@ -44,20 +53,33 @@ function safeMetadata(metadata) {
|
|
|
44
53
|
return result;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
function readOwner(lockDir) {
|
|
57
|
+
try { return JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8")); }
|
|
58
|
+
catch (_) { return null; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pidLooksAlive(pid) {
|
|
62
|
+
const n = Number(pid);
|
|
63
|
+
if (!Number.isSafeInteger(n) || n < 1) return false;
|
|
53
64
|
try {
|
|
54
|
-
process.kill(
|
|
65
|
+
process.kill(n, 0);
|
|
55
66
|
return true;
|
|
56
67
|
} catch (error) {
|
|
57
68
|
return error.code === "EPERM";
|
|
58
69
|
}
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
// Is a held lock safe to break? Never for a live holder; immediately for a
|
|
73
|
+
// dead one. owner.pid === our pid means either ourselves (same bootId — never
|
|
74
|
+
// break) or a previous incarnation that reused our pid (container pid-1 case —
|
|
75
|
+
// definitionally dead, break at once).
|
|
76
|
+
function lockIsStale(lockDir, ageMs) {
|
|
77
|
+
const owner = readOwner(lockDir);
|
|
78
|
+
if (!owner) return ageMs > ORPHAN_LOCK_GRACE_MS;
|
|
79
|
+
if (Number(owner.pid) === process.pid) return owner.bootId !== BOOT_ID;
|
|
80
|
+
return !pidLooksAlive(owner.pid);
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
class MemoryMutationQueue {
|
|
62
84
|
constructor(options = {}) {
|
|
63
85
|
this.lockDir = options.lockDir || path.join(CONFIG_DIR, "memory-mutation.lock");
|
|
@@ -65,7 +87,6 @@ class MemoryMutationQueue {
|
|
|
65
87
|
this.maxPending = positiveInteger(options.maxPending, DEFAULT_MAX_PENDING, "maxPending");
|
|
66
88
|
this.lockTimeoutMs = positiveInteger(options.lockTimeoutMs, DEFAULT_LOCK_TIMEOUT_MS, "lockTimeoutMs");
|
|
67
89
|
this.lockPollMs = positiveInteger(options.lockPollMs, DEFAULT_LOCK_POLL_MS, "lockPollMs");
|
|
68
|
-
this.staleLockMs = positiveInteger(options.staleLockMs, DEFAULT_STALE_LOCK_MS, "staleLockMs");
|
|
69
90
|
this.accepting = true;
|
|
70
91
|
this.stopReason = null;
|
|
71
92
|
this.pending = new Map();
|
|
@@ -93,7 +114,7 @@ class MemoryMutationQueue {
|
|
|
93
114
|
try {
|
|
94
115
|
fs.mkdirSync(this.lockDir, { recursive: false, mode: 0o700 });
|
|
95
116
|
try {
|
|
96
|
-
fs.writeFileSync(path.join(this.lockDir, "owner.json"), `${JSON.stringify({ token, pid: process.pid, id: entry.id, at: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
117
|
+
fs.writeFileSync(path.join(this.lockDir, "owner.json"), `${JSON.stringify({ token, pid: process.pid, bootId: BOOT_ID, id: entry.id, at: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
97
118
|
} catch (error) {
|
|
98
119
|
try { fs.rmSync(this.lockDir, { recursive: true, force: true }); } catch (_) {}
|
|
99
120
|
throw error;
|
|
@@ -107,7 +128,7 @@ class MemoryMutationQueue {
|
|
|
107
128
|
|
|
108
129
|
try {
|
|
109
130
|
const age = Date.now() - fs.statSync(this.lockDir).mtimeMs;
|
|
110
|
-
if (
|
|
131
|
+
if (lockIsStale(this.lockDir, age)) {
|
|
111
132
|
fs.rmSync(this.lockDir, { recursive: true, force: true });
|
|
112
133
|
}
|
|
113
134
|
} catch (_) { /* another process may have released it */ }
|
|
@@ -230,6 +251,15 @@ class MemoryMutationQueue {
|
|
|
230
251
|
}
|
|
231
252
|
}
|
|
232
253
|
|
|
254
|
+
// Read-only view of the shared lock for health surfaces (/doctor).
|
|
255
|
+
function inspectLock(lockDir) {
|
|
256
|
+
const dir = lockDir || path.join(CONFIG_DIR, "memory-mutation.lock");
|
|
257
|
+
let stat;
|
|
258
|
+
try { stat = fs.statSync(dir); } catch (_) { return { held: false }; }
|
|
259
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
260
|
+
return { held: true, ageMs, owner: readOwner(dir), stale: lockIsStale(dir, ageMs) };
|
|
261
|
+
}
|
|
262
|
+
|
|
233
263
|
const memoryMutationQueue = new MemoryMutationQueue();
|
|
234
264
|
|
|
235
265
|
module.exports = {
|
|
@@ -238,4 +268,5 @@ module.exports = {
|
|
|
238
268
|
MemoryMutationQueue,
|
|
239
269
|
MutationQueueError,
|
|
240
270
|
memoryMutationQueue,
|
|
271
|
+
inspectLock,
|
|
241
272
|
};
|
package/core/pack-review.js
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
// rule.
|
|
8
8
|
|
|
9
9
|
const crypto = require("crypto");
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
10
12
|
const { spawnUtilityAgent, cancelUtilityAgents } = require("./utility-agent");
|
|
11
13
|
const { memoryMutationQueue } = require("./memory-mutation-queue");
|
|
14
|
+
const { atomicWriteFileSync } = require("./fsutil");
|
|
15
|
+
const CONFIG_DIR = require("../config-dir");
|
|
12
16
|
const packs = require("./packs");
|
|
13
17
|
const entities = require("./entities");
|
|
14
18
|
const lessons = require("./lessons");
|
|
@@ -160,6 +164,7 @@ Decide. Rules:
|
|
|
160
164
|
- Bias toward action: if the turn did real work on an identifiable topic (a named system, project, server, app, person, or domain), record it — at minimum a journal line. Most working turns deserve one. Reserve empty actions for small talk, pure status checks, and turns that contain nothing new.
|
|
161
165
|
- ATTRIBUTION: prefer updating one of the ACTIVE packs above. The turn text often does not name its project (pronouns, follow-ups, pasted URLs) — never attribute the work to a different pack from the index on thematic similarity alone when an active pack fits the work.
|
|
162
166
|
- State/Stance/Procedure rewrites are MERGES, never regenerations: start from the pack's CURRENT content shown in the ACTIVE block above, carry forward every fact that is still true, drop only what this turn contradicted or completed, then fold in what changed. Never drop a durable fact merely because this turn did not mention it. If a pack's current content is NOT shown above, do not rewrite its sections — record a journal line only (section rewrites there are discarded).
|
|
167
|
+
- LOCKED lines: a Stance line containing the word LOCKED is a constraint the user explicitly froze. NEVER weaken, reword, or drop a LOCKED line in a stance rewrite — carry it forward verbatim — unless THIS turn shows the user explicitly unlocking or changing it. COMPLIANCE CHECK: compare the assistant's reply against every LOCKED line in the ACTIVE packs above; if the reply violated one (did what the line forbids, or contradicted the locked choice), append a journal line on that pack recording it, e.g. "VIOLATION: reply used <X> where Stance locks <Y>." — violations must become visible, never silently absorbed.
|
|
163
168
|
- UPDATE an existing pack when the turn touched that topic: append a journal line, and rewrite State if where-things-stand changed. Update Stance when the user expressed a lasting preference or rule; Procedure when a verified working method emerged.
|
|
164
169
|
- PROMOTE durable conclusions out of the Journal. A confirmed root-cause diagnosis, an established fact, or a settled design decision must go into Stance/Procedure/State (the parts always injected in full), NOT only a Journal line — Journal is truncated to the last few lines on injection, so a conclusion left only there will silently fall out of recall over time.
|
|
165
170
|
- CREATE a pack ONLY when the turn worked on a durable topic that NO existing pack covers. Before creating, scan the existing packs above for one already about this project/system/domain — if one exists, UPDATE it instead. A new pack must be a genuinely DISTINCT topic, never a narrower facet, version, release, sub-feature, phase, or component of a topic an existing pack already owns. In particular: a release or version of a project (e.g. a pack named after "<project> v2.6.39") is a Journal line + State update on that project's EXISTING pack — NEVER its own pack. Likewise sub-features of one system belong in that system's pack, not in siblings. When unsure whether something is a new topic or a facet of an existing one, treat it as a facet and UPDATE. Do not create packs for one-off trivia.
|
|
@@ -533,10 +538,38 @@ function reviewerMetric(entry) {
|
|
|
533
538
|
try { require("./recall/metrics").logReviewerRun(entry); } catch (_) {}
|
|
534
539
|
}
|
|
535
540
|
|
|
541
|
+
// Lock-timeout seatbelt. A decision that cannot get the mutation lock even
|
|
542
|
+
// after one retry used to be dropped on the floor — on one pod 87% of
|
|
543
|
+
// reviewer runs lost their computed mutations this way and nobody could see
|
|
544
|
+
// what was lost. Persist it instead (capped, no auto-replay: pack state may
|
|
545
|
+
// have moved on, so /doctor surfaces the backlog and a human decides).
|
|
546
|
+
const DEFERRED_FILE = path.join(CONFIG_DIR, "pending-review-mutations.jsonl");
|
|
547
|
+
const MAX_DEFERRED = 20;
|
|
548
|
+
const LOCK_RETRY_DELAY_MS = 60 * 1000;
|
|
549
|
+
|
|
550
|
+
function persistDeferredDecision(entry) {
|
|
551
|
+
try {
|
|
552
|
+
let lines = [];
|
|
553
|
+
try { lines = fs.readFileSync(DEFERRED_FILE, "utf-8").trim().split("\n").filter(Boolean); } catch (_) {}
|
|
554
|
+
lines.push(JSON.stringify(entry));
|
|
555
|
+
atomicWriteFileSync(DEFERRED_FILE, lines.slice(-MAX_DEFERRED).join("\n") + "\n", { mode: 0o600 });
|
|
556
|
+
return true;
|
|
557
|
+
} catch (_) { return false; }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function listDeferredDecisions() {
|
|
561
|
+
try {
|
|
562
|
+
return fs.readFileSync(DEFERRED_FILE, "utf-8").trim().split("\n").filter(Boolean)
|
|
563
|
+
.map((l) => { try { return JSON.parse(l); } catch (_) { return null; } })
|
|
564
|
+
.filter(Boolean);
|
|
565
|
+
} catch (_) { return []; }
|
|
566
|
+
}
|
|
567
|
+
|
|
536
568
|
function createPackReviewer(dependencies = {}) {
|
|
537
569
|
const runUtility = dependencies.spawnUtilityAgent || spawnUtilityAgent;
|
|
538
570
|
const queue = dependencies.mutationQueue || memoryMutationQueue;
|
|
539
571
|
const cancelUtilities = dependencies.cancelUtilityAgents || cancelUtilityAgents;
|
|
572
|
+
const lockRetryDelayMs = dependencies.lockRetryDelayMs ?? LOCK_RETRY_DELAY_MS;
|
|
540
573
|
const handles = new Map();
|
|
541
574
|
let accepting = true;
|
|
542
575
|
|
|
@@ -586,7 +619,7 @@ function createPackReviewer(dependencies = {}) {
|
|
|
586
619
|
reviewerMetric({ provider: utilityResult.provider, model: utilityResult.model, status: "no_change", fallbackUsed: utilityResult.fallbackUsed });
|
|
587
620
|
return { status: "no_change", provider: utilityResult.provider, model: utilityResult.model, fallbackUsed: !!utilityResult.fallbackUsed };
|
|
588
621
|
}
|
|
589
|
-
const
|
|
622
|
+
const enqueueApply = () => queue.enqueue("review", () => applyDecisionBatch({
|
|
590
623
|
decision,
|
|
591
624
|
activePacks,
|
|
592
625
|
versions,
|
|
@@ -597,6 +630,27 @@ function createPackReviewer(dependencies = {}) {
|
|
|
597
630
|
purpose: "review",
|
|
598
631
|
runId,
|
|
599
632
|
});
|
|
633
|
+
let applied;
|
|
634
|
+
try {
|
|
635
|
+
applied = await enqueueApply();
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error.code !== "MEMORY_MUTATION_LOCK_TIMEOUT") throw error;
|
|
638
|
+
await new Promise((resolve) => setTimeout(resolve, lockRetryDelayMs));
|
|
639
|
+
try {
|
|
640
|
+
applied = await enqueueApply();
|
|
641
|
+
} catch (retryError) {
|
|
642
|
+
if (retryError.code === "MEMORY_MUTATION_LOCK_TIMEOUT") {
|
|
643
|
+
retryError.deferred = persistDeferredDecision({
|
|
644
|
+
at: new Date().toISOString(),
|
|
645
|
+
source: source || "",
|
|
646
|
+
provider: utilityResult.provider || "",
|
|
647
|
+
model: utilityResult.model || "",
|
|
648
|
+
decision,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
throw retryError;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
600
654
|
let announcementFailed = false;
|
|
601
655
|
if (applied.lines.length && typeof announce === "function") {
|
|
602
656
|
try { await announce(formatAnnouncement(applied.lines)); }
|
|
@@ -619,10 +673,11 @@ function createPackReviewer(dependencies = {}) {
|
|
|
619
673
|
try { await announce(formatAnnouncement(partialLines)); }
|
|
620
674
|
catch (_) { announcementFailed = true; }
|
|
621
675
|
}
|
|
622
|
-
|
|
623
|
-
|
|
676
|
+
const status = error.deferred ? "deferred" : "failed";
|
|
677
|
+
reviewerMetric({ provider: resolvedProvider, model: utilityResult?.model || "", status, code: error.code || "REVIEW_FAILED" });
|
|
678
|
+
console.warn(`[pack-review] reviewer ${status}: ${error.message}`);
|
|
624
679
|
return {
|
|
625
|
-
status
|
|
680
|
+
status,
|
|
626
681
|
provider: resolvedProvider || null,
|
|
627
682
|
model: utilityResult?.model || null,
|
|
628
683
|
lines: partialLines,
|
|
@@ -690,6 +745,7 @@ module.exports = {
|
|
|
690
745
|
contentVersion,
|
|
691
746
|
createPackReviewer,
|
|
692
747
|
drainReviews: defaultReviewer.drainReviews,
|
|
748
|
+
listDeferredDecisions,
|
|
693
749
|
parseDecision,
|
|
694
750
|
provenanceTag,
|
|
695
751
|
reviewTurn: defaultReviewer.reviewTurn,
|
package/core/packs.js
CHANGED
|
@@ -528,7 +528,7 @@ let _db = null;
|
|
|
528
528
|
let _indexedAt = 0;
|
|
529
529
|
let _dirty = true;
|
|
530
530
|
|
|
531
|
-
function markIndexDirty() { _dirty = true; }
|
|
531
|
+
function markIndexDirty() { _dirty = true; _lexicalDocsAt = -1; }
|
|
532
532
|
|
|
533
533
|
function openDb() {
|
|
534
534
|
if (!DatabaseSync) return null;
|
|
@@ -609,6 +609,50 @@ function queryTerms(text) {
|
|
|
609
609
|
return terms;
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
+
// Pure-JS floor under matchPacks for substrates without node:sqlite (a
|
|
613
|
+
// node-20 pod once ran 16 days with zero recall because the matcher bailed
|
|
614
|
+
// silently). Same shape and scoring as the FTS path; porter stemming is
|
|
615
|
+
// approximated by substring hits on the term plus a suffix-stripped variant.
|
|
616
|
+
function termVariants(t) {
|
|
617
|
+
for (const suffix of ["ing", "ed", "es", "s"]) {
|
|
618
|
+
if (t.length > suffix.length + 2 && t.endsWith(suffix)) return [t, t.slice(0, -suffix.length)];
|
|
619
|
+
}
|
|
620
|
+
return [t];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
let _lexicalDocs = null;
|
|
624
|
+
let _lexicalDocsAt = -1;
|
|
625
|
+
|
|
626
|
+
function lexicalDocs() {
|
|
627
|
+
const mtime = latestMtime();
|
|
628
|
+
if (_lexicalDocs && _lexicalDocsAt === mtime) return _lexicalDocs;
|
|
629
|
+
_lexicalDocs = listPacks().map((p) => ({
|
|
630
|
+
dir: p.dir,
|
|
631
|
+
name: p.name,
|
|
632
|
+
strong: [p.name, p.description, p.tags.join(" ")].join("\n").toLowerCase(),
|
|
633
|
+
body: [p.sections.Stance, p.sections.Procedure, p.sections.State, p.sections.Journal].join("\n").toLowerCase(),
|
|
634
|
+
}));
|
|
635
|
+
_lexicalDocsAt = mtime;
|
|
636
|
+
return _lexicalDocs;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function matchPacksLexical(text, { limit = 3, threshold = null } = {}) {
|
|
640
|
+
const terms = queryTerms(text);
|
|
641
|
+
if (terms.length === 0) return [];
|
|
642
|
+
const minScore = threshold ?? Number(process.env.PACK_MATCH_THRESHOLD || 2);
|
|
643
|
+
const scored = [];
|
|
644
|
+
for (const doc of lexicalDocs()) {
|
|
645
|
+
let score = 0;
|
|
646
|
+
for (const t of terms) {
|
|
647
|
+
const variants = termVariants(t);
|
|
648
|
+
if (variants.some((v) => doc.strong.includes(v))) score += 2;
|
|
649
|
+
else if (variants.some((v) => doc.body.includes(v))) score += 1;
|
|
650
|
+
}
|
|
651
|
+
if (score >= minScore) scored.push({ dir: doc.dir, name: doc.name, score });
|
|
652
|
+
}
|
|
653
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, Math.max(1, Math.min(10, limit)));
|
|
654
|
+
}
|
|
655
|
+
|
|
612
656
|
// Match a free-text message against packs. BM25 is unusable here — with
|
|
613
657
|
// a small corpus its IDF term collapses to ~0 — so score per term
|
|
614
658
|
// instead: a hit in name/description/tags counts 2 (porter-stemmed via
|
|
@@ -616,14 +660,14 @@ function queryTerms(text) {
|
|
|
616
660
|
// single incidental body word (score 1) never drags a pack in.
|
|
617
661
|
function matchPacks(text, { limit = 3, threshold = null } = {}) {
|
|
618
662
|
const db = openDb();
|
|
619
|
-
if (!db) return
|
|
663
|
+
if (!db) return matchPacksLexical(text, { limit, threshold });
|
|
620
664
|
ensureIndex();
|
|
621
665
|
const terms = queryTerms(text);
|
|
622
666
|
if (terms.length === 0) return [];
|
|
623
667
|
const minScore = threshold ?? Number(process.env.PACK_MATCH_THRESHOLD || 2);
|
|
624
668
|
const scores = new Map();
|
|
625
669
|
let stmt;
|
|
626
|
-
try { stmt = db.prepare("SELECT dir FROM packs WHERE packs MATCH ?"); } catch (e) { return
|
|
670
|
+
try { stmt = db.prepare("SELECT dir FROM packs WHERE packs MATCH ?"); } catch (e) { return matchPacksLexical(text, { limit, threshold }); }
|
|
627
671
|
for (const t of terms) {
|
|
628
672
|
const quoted = `"${t.replace(/"/g, '""')}"`;
|
|
629
673
|
let strong = [];
|
|
@@ -150,18 +150,34 @@ function maybeSync(packsLib, entitiesLib) {
|
|
|
150
150
|
// seed — with a 147-pack corpus that is virtually every turn (measured: gated
|
|
151
151
|
// 0.4% of 500 turns), so "thanks!" still paid a full 13s walk. Three tiers:
|
|
152
152
|
// skip — pure pleasantry/ack/emoji: no recall at all, regardless of seeds.
|
|
153
|
-
// seeds — short command-like turns (≤4 words
|
|
154
|
-
// enough; inject user-origin seeds directly
|
|
155
|
-
// (the classic baseline, ~ms instead of ~13s).
|
|
153
|
+
// seeds — short command-like turns (≤4 words after any pleasantry prefix):
|
|
154
|
+
// keyword seeds are precise enough; inject user-origin seeds directly
|
|
155
|
+
// and skip graph + walker (the classic baseline, ~ms instead of ~13s).
|
|
156
156
|
// full — substantive turns: graph expansion + walker judgement.
|
|
157
157
|
const PLEASANTRY_RE = /^(ok(ay)?|k|kk|thanks?|thank you|thx|ty|yes|yep|yeah|no|nope|nah|cool|nice|great|perfect|awesome|lovely|done|sure|got it|sounds good|will do|lol|haha|hi|hey|hello|yo|morning|evening|night|good (morning|afternoon|evening|night)|bye|goodnight|cheers|please do|go ahead)\b/;
|
|
158
158
|
|
|
159
|
+
// B2: a pleasantry PREFIX must not hide a real request from the gate — "ok
|
|
160
|
+
// deploy kazee now" is a deploy command wearing an ack, yet the old ≤4-word
|
|
161
|
+
// rule skipped recall for it entirely. Strip leading pleasantry tokens
|
|
162
|
+
// (stacked ones too) plus separators and tier what remains; a pure pleasantry
|
|
163
|
+
// chain strips to "" and skips regardless of its length.
|
|
164
|
+
function stripPleasantryPrefix(t) {
|
|
165
|
+
let s = t;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const m = PLEASANTRY_RE.exec(s);
|
|
168
|
+
if (!m) return s;
|
|
169
|
+
s = s.slice(m[0].length).replace(/^[\s,.!?;:—–-]+/, "");
|
|
170
|
+
if (!s) return "";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
159
174
|
function recallTier(userText, seedCount) {
|
|
160
175
|
const t = String(userText || "").trim().toLowerCase();
|
|
161
176
|
if (!t) return "skip";
|
|
162
|
-
const words = t.split(/\s+/).filter(Boolean);
|
|
163
177
|
if (!/[a-z0-9]/.test(t)) return "skip"; // emoji/punctuation only
|
|
164
|
-
|
|
178
|
+
const core = stripPleasantryPrefix(t);
|
|
179
|
+
if (!core) return "skip"; // pure pleasantry/ack chain
|
|
180
|
+
const words = core.split(/\s+/).filter(Boolean);
|
|
165
181
|
if (words.length <= 2 && seedCount === 0) return "skip";
|
|
166
182
|
if (words.length <= tuning.get("seedsTierMaxWords")) return "seeds";
|
|
167
183
|
return "full";
|
|
@@ -325,7 +341,7 @@ function cokeptPairs(keptIds, scoreById, groundedIds, { minScore = 60, maxPairs
|
|
|
325
341
|
pairs.push({ a, b, strength: scoreById.get(a) + scoreById.get(b) });
|
|
326
342
|
}
|
|
327
343
|
}
|
|
328
|
-
pairs.sort((x, y) => y.strength - x.strength || (x.a + "
|
|
344
|
+
pairs.sort((x, y) => y.strength - x.strength || (x.a + "\u0000" + x.b).localeCompare(y.a + "\u0000" + y.b));
|
|
329
345
|
return pairs.slice(0, Math.max(0, maxPairs)).map((p) => [p.a, p.b]);
|
|
330
346
|
}
|
|
331
347
|
|
package/core/recall/graph.js
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
// `weight` and `last_reinforced`. Structural edges (parent/related from pack
|
|
13
13
|
// metadata) are re-derived idempotently; their weight floor is kept but any
|
|
14
14
|
// extra reinforcement earned through use is preserved.
|
|
15
|
+
//
|
|
16
|
+
// Decay is computed ON READ, never rewritten in place ("decay-on-read"): the
|
|
17
|
+
// stored `weight` is the value AS OF `last_reinforced`, and every read maps it
|
|
18
|
+
// through effectiveWeight(). Writes that change a weight (reinforce, weaken)
|
|
19
|
+
// first settle the decay accrued so far, then apply their delta and restamp.
|
|
20
|
+
// The old nightly decay() rewrite reapplied the full age to an already-decayed
|
|
21
|
+
// weight every night (compounding — a 60d half-life emptied in ~10 nights);
|
|
22
|
+
// with no rewrite path, that bug class cannot exist.
|
|
15
23
|
|
|
16
24
|
const fs = require("fs");
|
|
17
25
|
const path = require("path");
|
|
@@ -29,13 +37,14 @@ const EDGE_TYPES = ["parent", "governed-by", "related"];
|
|
|
29
37
|
// Base weights when an edge is first derived from structure.
|
|
30
38
|
const BASE_WEIGHT = { parent: 3, "governed-by": 2.5, related: 1 };
|
|
31
39
|
// Spreading-activation tunables (overridable via env / dream knobs).
|
|
40
|
+
// The decay half-life lives in tuning.js (`decayHalfLifeDays`, env-pinnable),
|
|
41
|
+
// read lazily so a dream knob change applies to the very next read.
|
|
32
42
|
const DEFAULTS = {
|
|
33
43
|
hops: 2,
|
|
34
44
|
threshold: 0.18, // firing threshold as a fraction of the strongest seed
|
|
35
45
|
damping: 0.55, // fraction of activation transmitted per hop
|
|
36
46
|
perNodeTopK: 6, // cap fan-out per node (sparsity)
|
|
37
47
|
maxWeight: 6, // weight at which an edge transmits at full damping
|
|
38
|
-
halfLifeDays: 60, // Hebbian weight half-life for decay()
|
|
39
48
|
};
|
|
40
49
|
|
|
41
50
|
let _db = null;
|
|
@@ -62,6 +71,17 @@ function openDb() {
|
|
|
62
71
|
)`);
|
|
63
72
|
db.exec("CREATE INDEX IF NOT EXISTS edges_src ON edges (src)");
|
|
64
73
|
db.exec("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
|
|
74
|
+
// v1 migration: legacy DBs carry weights the old nightly decay() rewrite
|
|
75
|
+
// had already over-decayed (it reapplied the full age every night). Treat
|
|
76
|
+
// stored values as current and restart the clock — decay is read-time from
|
|
77
|
+
// here on, so this is the last time the stamp is touched in bulk.
|
|
78
|
+
try {
|
|
79
|
+
const uv = db.prepare("PRAGMA user_version").get() || {};
|
|
80
|
+
if (Number(uv.user_version || 0) < 1) {
|
|
81
|
+
db.prepare("UPDATE edges SET last_reinforced=? WHERE last_reinforced IS NOT NULL").run(now());
|
|
82
|
+
db.exec("PRAGMA user_version = 1");
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {}
|
|
65
85
|
_db = db;
|
|
66
86
|
return db;
|
|
67
87
|
} catch (e) {
|
|
@@ -76,6 +96,29 @@ function normType(t) {
|
|
|
76
96
|
return EDGE_TYPES.includes(s) ? s : "related";
|
|
77
97
|
}
|
|
78
98
|
|
|
99
|
+
// Effective decay half-life: explicit override > dream-tunable knob (env-pinnable).
|
|
100
|
+
function currentHalfLife(opts = {}) {
|
|
101
|
+
if (Number.isFinite(opts.halfLifeDays)) return Math.max(1, opts.halfLifeDays);
|
|
102
|
+
let hl;
|
|
103
|
+
try { hl = require("./tuning").get("decayHalfLifeDays"); } catch (e) {}
|
|
104
|
+
return Number.isFinite(hl) ? Math.max(1, hl) : 60;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Read-time weight of an edge row: exponential decay of the stored weight since
|
|
108
|
+
// last_reinforced, clamped at the structural floor. Pure — storage is never
|
|
109
|
+
// touched. An edge that was never reinforced (last_reinforced null) does not
|
|
110
|
+
// decay: it sits at its structural base until first use.
|
|
111
|
+
function effectiveWeight(e, nowMs = Date.now(), halfLifeDays = currentHalfLife()) {
|
|
112
|
+
if (!e || !Number.isFinite(e.weight)) return 0;
|
|
113
|
+
if (!e.last_reinforced) return e.weight;
|
|
114
|
+
const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
|
|
115
|
+
if (!(ageDays > 0)) return e.weight;
|
|
116
|
+
const floor = BASE_WEIGHT[e.type] || 0;
|
|
117
|
+
if (e.weight <= floor) return e.weight;
|
|
118
|
+
const f = Math.exp(-Math.log(2) * ageDays / Math.max(1, halfLifeDays));
|
|
119
|
+
return Math.max(floor, e.weight * f);
|
|
120
|
+
}
|
|
121
|
+
|
|
79
122
|
// Upsert an edge. By default keeps the larger of the existing/new weight so a
|
|
80
123
|
// structural re-derive never stomps reinforcement; pass `{ bump }` to add to
|
|
81
124
|
// the current weight (Hebbian) and stamp last_reinforced.
|
|
@@ -86,15 +129,19 @@ function addEdge(src, dst, type, opts = {}) {
|
|
|
86
129
|
const weight = Number.isFinite(opts.weight) ? opts.weight : (BASE_WEIGHT[t] || 1);
|
|
87
130
|
const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
|
|
88
131
|
try {
|
|
89
|
-
const existing = db.prepare("SELECT weight FROM edges WHERE src=? AND dst=? AND type=?").get(src, dst, t);
|
|
132
|
+
const existing = db.prepare("SELECT weight, last_reinforced FROM edges WHERE src=? AND dst=? AND type=?").get(src, dst, t);
|
|
90
133
|
if (existing) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
134
|
+
if (bump) {
|
|
135
|
+
// Settle the decay accrued since last_reinforced, then bump and restamp
|
|
136
|
+
// — keeps the invariant that the stored weight is valued at its stamp.
|
|
137
|
+
const settled = effectiveWeight({ ...existing, type: t });
|
|
94
138
|
db.prepare("UPDATE edges SET weight=?, last_reinforced=? WHERE src=? AND dst=? AND type=?")
|
|
95
|
-
.run(
|
|
139
|
+
.run(settled + bump, now(), src, dst, t);
|
|
96
140
|
} else {
|
|
97
|
-
|
|
141
|
+
const next = Math.max(existing.weight, weight);
|
|
142
|
+
if (next !== existing.weight) {
|
|
143
|
+
db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, src, dst, t);
|
|
144
|
+
}
|
|
98
145
|
}
|
|
99
146
|
} else {
|
|
100
147
|
db.prepare("INSERT INTO edges (src, dst, type, weight, last_reinforced, created) VALUES (?,?,?,?,?,?)")
|
|
@@ -123,18 +170,21 @@ function allEdges() {
|
|
|
123
170
|
catch (e) { return []; }
|
|
124
171
|
}
|
|
125
172
|
|
|
126
|
-
// Undirected neighbours of a node: edges where it is src OR dst.
|
|
173
|
+
// Undirected neighbours of a node: edges where it is src OR dst. Weights are
|
|
174
|
+
// read-time effective values.
|
|
127
175
|
function neighbors(nodeId) {
|
|
128
176
|
const db = openDb();
|
|
129
177
|
if (!db || !nodeId) return [];
|
|
130
178
|
try {
|
|
131
179
|
const rows = db.prepare(
|
|
132
|
-
"SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?"
|
|
180
|
+
"SELECT src, dst, type, weight, last_reinforced FROM edges WHERE src=? OR dst=?"
|
|
133
181
|
).all(nodeId, nodeId);
|
|
182
|
+
const nowMs = Date.now();
|
|
183
|
+
const hl = currentHalfLife();
|
|
134
184
|
return rows.map((r) => ({
|
|
135
185
|
node: r.src === nodeId ? r.dst : r.src,
|
|
136
186
|
type: r.type,
|
|
137
|
-
weight: r
|
|
187
|
+
weight: effectiveWeight(r, nowMs, hl),
|
|
138
188
|
}));
|
|
139
189
|
} catch (e) { return []; }
|
|
140
190
|
}
|
|
@@ -158,24 +208,30 @@ function reinforceSet(nodeIds, amount = 1) {
|
|
|
158
208
|
|
|
159
209
|
// Negative Hebbian: weaken every edge touching a node that keeps surfacing
|
|
160
210
|
// via the graph but never gets kept or used — firing wrongly, so unwire.
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
211
|
+
// Operates on the EFFECTIVE weight (settle accrued decay first, then apply the
|
|
212
|
+
// factor and restamp — same invariant as the reinforce path). Floor contract
|
|
213
|
+
// as everywhere: never below the type's base weight. Additionally, `related`
|
|
214
|
+
// edges that fall to their structural base are deleted outright: a
|
|
215
|
+
// link-derived edge re-derives at base weight on the next sync (harmless),
|
|
216
|
+
// while a co-use edge that earned its weight from wrong firings is gone for
|
|
217
|
+
// good.
|
|
165
218
|
function weakenNode(nodeId, { factor = 0.6, prune = true } = {}) {
|
|
166
219
|
const db = openDb();
|
|
167
220
|
if (!db || !nodeId) return { weakened: 0, pruned: 0 };
|
|
168
221
|
let weakened = 0, pruned = 0;
|
|
169
222
|
try {
|
|
170
|
-
const rows = db.prepare("SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?").all(nodeId, nodeId);
|
|
223
|
+
const rows = db.prepare("SELECT src, dst, type, weight, last_reinforced FROM edges WHERE src=? OR dst=?").all(nodeId, nodeId);
|
|
224
|
+
const nowMs = Date.now();
|
|
225
|
+
const hl = currentHalfLife();
|
|
226
|
+
const stamp = now();
|
|
171
227
|
for (const e of rows) {
|
|
172
228
|
const floor = BASE_WEIGHT[e.type] || 0;
|
|
173
|
-
const next = Math.max(floor, e
|
|
229
|
+
const next = Math.max(floor, effectiveWeight(e, nowMs, hl) * factor);
|
|
174
230
|
if (prune && e.type === "related" && next <= floor + 0.05) {
|
|
175
231
|
db.prepare("DELETE FROM edges WHERE src=? AND dst=? AND type=?").run(e.src, e.dst, e.type);
|
|
176
232
|
pruned++;
|
|
177
233
|
} else if (Math.abs(next - e.weight) > 1e-6) {
|
|
178
|
-
db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, e.src, e.dst, e.type);
|
|
234
|
+
db.prepare("UPDATE edges SET weight=?, last_reinforced=? WHERE src=? AND dst=? AND type=?").run(next, stamp, e.src, e.dst, e.type);
|
|
179
235
|
weakened++;
|
|
180
236
|
}
|
|
181
237
|
}
|
|
@@ -183,33 +239,6 @@ function weakenNode(nodeId, { factor = 0.6, prune = true } = {}) {
|
|
|
183
239
|
return { weakened, pruned };
|
|
184
240
|
}
|
|
185
241
|
|
|
186
|
-
// Exponential time decay on reinforced weight. Structural floor is preserved:
|
|
187
|
-
// an edge never decays below its type's base weight.
|
|
188
|
-
function decay({ halfLifeDays = DEFAULTS.halfLifeDays } = {}) {
|
|
189
|
-
const db = openDb();
|
|
190
|
-
if (!db) return 0;
|
|
191
|
-
const rows = allEdges();
|
|
192
|
-
const nowMs = Date.now();
|
|
193
|
-
const ln2 = Math.log(2);
|
|
194
|
-
let changed = 0;
|
|
195
|
-
for (const e of rows) {
|
|
196
|
-
if (!e.last_reinforced) continue;
|
|
197
|
-
const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
|
|
198
|
-
if (!(ageDays > 0)) continue;
|
|
199
|
-
const factor = Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
|
|
200
|
-
const floor = BASE_WEIGHT[e.type] || 0;
|
|
201
|
-
const next = Math.max(floor, e.weight * factor);
|
|
202
|
-
if (Math.abs(next - e.weight) > 1e-6) {
|
|
203
|
-
try {
|
|
204
|
-
db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?")
|
|
205
|
-
.run(next, e.src, e.dst, e.type);
|
|
206
|
-
changed++;
|
|
207
|
-
} catch (e2) {}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
return changed;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
242
|
// Spreading activation. `seeds` is [{ id, score }] (score = match strength).
|
|
214
243
|
// Returns Map id -> { activation, hop, via } for nodes that fired above the
|
|
215
244
|
// threshold, EXCLUDING the original seeds (those are already in the result set
|
|
@@ -220,10 +249,13 @@ function expand(seeds, opts = {}) {
|
|
|
220
249
|
const seedList = (seeds || []).filter((s) => s && s.id);
|
|
221
250
|
if (!seedList.length || !available()) return out;
|
|
222
251
|
|
|
223
|
-
// Build adjacency once, keeping the strongest edge
|
|
252
|
+
// Build adjacency once over READ-TIME weights, keeping the strongest edge
|
|
253
|
+
// per (node, neighbour).
|
|
224
254
|
const adj = new Map();
|
|
255
|
+
const nowMs = Date.now();
|
|
256
|
+
const hl = currentHalfLife(opts);
|
|
225
257
|
for (const e of allEdges()) {
|
|
226
|
-
const w = e
|
|
258
|
+
const w = effectiveWeight(e, nowMs, hl);
|
|
227
259
|
const push = (from, to) => {
|
|
228
260
|
if (!adj.has(from)) adj.set(from, new Map());
|
|
229
261
|
const m = adj.get(from);
|
|
@@ -378,14 +410,14 @@ function pruneOrphans(packsLib, entitiesLib) {
|
|
|
378
410
|
return removed;
|
|
379
411
|
}
|
|
380
412
|
|
|
381
|
-
// Nightly maintenance: refresh structural edges,
|
|
382
|
-
//
|
|
413
|
+
// Nightly maintenance: refresh structural edges, drop orphans. There is no
|
|
414
|
+
// weight pass — decay is applied on read (effectiveWeight), so nightly has
|
|
415
|
+
// nothing to rewrite. Deterministic + safe — the high-judgment "extract shared
|
|
383
416
|
// concern node" surgery is left to the dream model proper.
|
|
384
|
-
function tend(packsLib, entitiesLib
|
|
417
|
+
function tend(packsLib, entitiesLib) {
|
|
385
418
|
const synced = syncFromCorpus(packsLib, entitiesLib);
|
|
386
|
-
const decayed = decay(opts);
|
|
387
419
|
const pruned = pruneOrphans(packsLib, entitiesLib);
|
|
388
|
-
return { synced: synced.edges,
|
|
420
|
+
return { synced: synced.edges, pruned, ...stats() };
|
|
389
421
|
}
|
|
390
422
|
|
|
391
423
|
function stats() {
|
|
@@ -404,7 +436,7 @@ module.exports = {
|
|
|
404
436
|
EDGE_TYPES, DEFAULTS, GRAPH_DB,
|
|
405
437
|
available, openDb,
|
|
406
438
|
addEdge, removeEdge, allEdges, neighbors,
|
|
407
|
-
reinforce, reinforceSet,
|
|
439
|
+
reinforce, reinforceSet, effectiveWeight, weakenNode, expand,
|
|
408
440
|
syncFromCorpus, parseLinks, pruneOrphans, tend, stats, isSharedConcern,
|
|
409
441
|
_resetForTest,
|
|
410
442
|
};
|
package/core/recall/tuning.js
CHANGED
|
@@ -132,8 +132,7 @@ function history() { return readFile().history || []; }
|
|
|
132
132
|
|
|
133
133
|
function write(data) {
|
|
134
134
|
try {
|
|
135
|
-
|
|
136
|
-
fs.writeFileSync(TUNING_FILE, JSON.stringify(data, null, 2));
|
|
135
|
+
require("../fsutil").atomicWriteFileSync(TUNING_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
137
136
|
} catch (e) {}
|
|
138
137
|
_cache = null; _cacheMtime = -1;
|
|
139
138
|
}
|
package/core/router.js
CHANGED
|
@@ -27,6 +27,7 @@ const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
|
|
|
27
27
|
const keyring = require("./keyring");
|
|
28
28
|
const { classifyPastedCredential } = require("./credential-detect");
|
|
29
29
|
const channelWizard = require("./channel-wizard");
|
|
30
|
+
const safeModeLib = require("./safe-mode");
|
|
30
31
|
require("./handlers"); // side-effect: register slash commands
|
|
31
32
|
|
|
32
33
|
const USER_VISIBLE_PROVIDER_ERRORS = new Set([
|
|
@@ -96,6 +97,19 @@ async function onMessage(envelope) {
|
|
|
96
97
|
// owner's canonical bucket on first contact, before any state is read.
|
|
97
98
|
try { autoLinkOwnerChannel(envelope.adapter?.type, envelope.channelId); } catch (e) {}
|
|
98
99
|
|
|
100
|
+
// Safe mode: only the diagnosis/rescue command set runs; everything else
|
|
101
|
+
// gets a short banner pointing at /safemode. Preempts wizards, media and
|
|
102
|
+
// agent turns — nothing may write memory or spawn a provider here.
|
|
103
|
+
if (safeModeLib.isSafeMode()) {
|
|
104
|
+
const raw = envelope.text || "";
|
|
105
|
+
const isCommand = envelope.type === "command" || raw.startsWith("/");
|
|
106
|
+
if (!isCommand || !safeModeLib.commandAllowed(raw)) {
|
|
107
|
+
if (isDuplicate(envelope)) return;
|
|
108
|
+
await send(safeModeLib.banner());
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
// A channel wizard listening for the owner's message on a freshly-added
|
|
100
114
|
// adapter consumes that inbound message here: it arrives on a different
|
|
101
115
|
// channel than the operator's, so the isAwaiting() gate below (keyed to
|
|
@@ -151,6 +165,10 @@ async function onMessage(envelope) {
|
|
|
151
165
|
async function onAction(envelope) {
|
|
152
166
|
return scope(envelope, async () => {
|
|
153
167
|
try {
|
|
168
|
+
if (safeModeLib.isSafeMode()) {
|
|
169
|
+
try { await send(safeModeLib.banner()); } catch (e) {}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
154
172
|
if (await channelWizard.handleAction(envelope)) return;
|
|
155
173
|
await handleAction(envelope);
|
|
156
174
|
}
|