@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.
@@ -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 ownerIsAlive(lockDir) {
48
- let owner;
49
- try { owner = JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8")); }
50
- catch (_) { return false; }
51
- const pid = Number(owner?.pid);
52
- if (!Number.isSafeInteger(pid) || pid < 1) return false;
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(pid, 0);
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 (age > this.staleLockMs && !ownerIsAlive(this.lockDir)) {
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
  };
@@ -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 applied = await queue.enqueue("review", () => applyDecisionBatch({
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
- reviewerMetric({ provider: resolvedProvider, model: utilityResult?.model || "", status: "failed", code: error.code || "REVIEW_FAILED" });
623
- console.warn(`[pack-review] reviewer failed: ${error.message}`);
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: "failed",
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): keyword seeds are precise
154
- // enough; inject user-origin seeds directly and skip graph + walker
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
- if (words.length <= 4 && PLEASANTRY_RE.test(t)) return "skip";
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 + "" + x.b).localeCompare(y.a + "" + y.b));
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
 
@@ -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
- const next = bump ? existing.weight + bump : Math.max(existing.weight, weight);
92
- const stamp = bump ? now() : null;
93
- if (stamp) {
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(next, stamp, src, dst, t);
139
+ .run(settled + bump, now(), src, dst, t);
96
140
  } else {
97
- db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, src, dst, t);
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.weight,
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
- // Same floor contract as decay(); additionally, `related` edges that fall to
162
- // their structural base are deleted outright: a link-derived edge re-derives
163
- // at base weight on the next sync (harmless), while a co-use edge that earned
164
- // its weight from wrong firings is gone for good.
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.weight * factor);
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 per (node, neighbour).
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.weight;
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, decay reinforced weights,
382
- // drop orphans. Deterministic + safe — the high-judgment "extract shared
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, opts = {}) {
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, decayed, pruned, ...stats() };
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, decay, weakenNode, expand,
439
+ reinforce, reinforceSet, effectiveWeight, weakenNode, expand,
408
440
  syncFromCorpus, parseLinks, pruneOrphans, tend, stats, isSharedConcern,
409
441
  _resetForTest,
410
442
  };
@@ -132,8 +132,7 @@ function history() { return readFile().history || []; }
132
132
 
133
133
  function write(data) {
134
134
  try {
135
- fs.mkdirSync(path.dirname(TUNING_FILE), { recursive: true });
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
  }