@lh8ppl/claude-memory-kit 0.4.1 → 0.4.3
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/bin/cmk-approve-permission.mjs +0 -0
- package/bin/cmk-guard-memory.mjs +0 -0
- package/package.json +2 -2
- package/src/audit-log.mjs +1 -0
- package/src/auto-persona.mjs +161 -18
- package/src/config-core.mjs +21 -5
- package/src/conflict-queue.mjs +18 -0
- package/src/graduation.mjs +39 -0
- package/src/heat.mjs +75 -0
- package/src/index-db.mjs +22 -0
- package/src/index-rebuild.mjs +67 -14
- package/src/inject-context.mjs +6 -0
- package/src/lessons-promote.mjs +95 -12
- package/src/mcp-server.mjs +10 -1
- package/src/memory-write.mjs +18 -0
- package/src/merge-facts.mjs +19 -0
- package/src/poison-guard.mjs +42 -0
- package/src/provenance.mjs +27 -0
- package/src/scratchpad.mjs +64 -25
- package/src/trust-score.mjs +120 -0
- package/src/trust-signal.mjs +73 -0
- package/src/write-fact.mjs +49 -4
package/src/scratchpad.mjs
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
37
37
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
38
38
|
import { writeBullet, parseBulletProvenance, isProvenanceCommentLine } from './provenance.mjs';
|
|
39
|
-
import { graduateForCapRelief } from './graduation.mjs';
|
|
39
|
+
import { graduateForCapRelief, condenseScratchpadForCapRelief } from './graduation.mjs';
|
|
40
40
|
|
|
41
41
|
const VALID_TRUST = new Set(['high', 'medium', 'low']);
|
|
42
42
|
const VALID_WRITE_SOURCES = new Set([
|
|
@@ -227,6 +227,19 @@ export function ensureSectionExists(scratchpadPath, sectionTitle) {
|
|
|
227
227
|
|
|
228
228
|
const EVICTED_ID_RE = /^- \(([PUL]-[A-Za-z0-9]+)\)/;
|
|
229
229
|
|
|
230
|
+
// Sweep order (Task 151.5, ADR-0016 §20.3): within the stale, non-high swept
|
|
231
|
+
// set, LOW-trust evicts before MEDIUM — value-ordered, not the value-BLIND
|
|
232
|
+
// single-axis sweep (MemoryOS-LFU / MemOS-top-N) the research flags as the
|
|
233
|
+
// Task-151 bug. The drop GATE stays a two-axis CONJUNCTION (not-high AND stale);
|
|
234
|
+
// this only orders the eviction LIST (→ the archive + audit order), so a
|
|
235
|
+
// debugger / a future partial-drop sees the cheapest bullets first.
|
|
236
|
+
// 151.6 re-eval RESOLVED (D-238): KEEP the `trust` ENUM here — the evolved
|
|
237
|
+
// trust_score is a FLOOR/protection signal (memclaw/letta/graphiti), NOT a sweep-
|
|
238
|
+
// ranking driver (rank-and-sweep-by-score = the MemoryOS-LFU/MemOS-top-N bug). The
|
|
239
|
+
// floor (never-zero) + the enum's high-trust-exempt already deliver the protection;
|
|
240
|
+
// no index-db I/O on this path. See design §20.3.
|
|
241
|
+
const CONSOLIDATE_TRUST_ORDER = { low: 0, medium: 1, high: 2 };
|
|
242
|
+
|
|
230
243
|
function consolidate(text, { nowDate }) {
|
|
231
244
|
const lines = text.split(/\r?\n/); // Task 139: CRLF-tolerant
|
|
232
245
|
const removeIdx = new Set();
|
|
@@ -253,14 +266,28 @@ function consolidate(text, { nowDate }) {
|
|
|
253
266
|
removeIdx.add(i + 1);
|
|
254
267
|
// Task 91.2: capture the dropped bullet so the caller can ARCHIVE it
|
|
255
268
|
// (recoverable, per the §6.5 tombstone principle) instead of hard-deleting.
|
|
269
|
+
// Carry trust + age so the eviction list can be value-ordered (151.5).
|
|
256
270
|
const idMatch = bulletLine.match(EVICTED_ID_RE);
|
|
257
|
-
evicted.push({
|
|
271
|
+
evicted.push({
|
|
272
|
+
id: idMatch ? idMatch[1] : 'unknown',
|
|
273
|
+
block: `${bulletLine}\n${commentLine}`,
|
|
274
|
+
trust: prov.trust,
|
|
275
|
+
atMs: at.getTime(),
|
|
276
|
+
});
|
|
258
277
|
bulletsRemoved++;
|
|
259
278
|
}
|
|
260
279
|
|
|
261
280
|
if (removeIdx.size === 0) {
|
|
262
281
|
return { text, bulletsRemoved: 0, evicted: [] };
|
|
263
282
|
}
|
|
283
|
+
// 151.5: order the eviction list LOW-trust first, then OLDEST first (the
|
|
284
|
+
// "long-unaccessed" axis, capture-age proxy until 151.6). The file removal
|
|
285
|
+
// (removeIdx) is unchanged — only the archive/audit order reflects value.
|
|
286
|
+
evicted.sort(
|
|
287
|
+
(a, b) =>
|
|
288
|
+
(CONSOLIDATE_TRUST_ORDER[a.trust] ?? 1) - (CONSOLIDATE_TRUST_ORDER[b.trust] ?? 1) ||
|
|
289
|
+
a.atMs - b.atMs,
|
|
290
|
+
);
|
|
264
291
|
const out = lines.filter((_, i) => !removeIdx.has(i)).join('\n');
|
|
265
292
|
return { text: out, bulletsRemoved, evicted };
|
|
266
293
|
}
|
|
@@ -365,21 +392,23 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
365
392
|
evictedBullets = consolidated.evicted ?? [];
|
|
366
393
|
}
|
|
367
394
|
|
|
368
|
-
// 2b.
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
395
|
+
// 2b. Cap relief after stale-drop. The path DIFFERS by tier (Task 151.4, §20.3):
|
|
396
|
+
// - PROJECT (tier P): GRADUATE the oldest high-trust bullets OUT of the hot
|
|
397
|
+
// index into the permanent fact store (context/memory/*.md). Those stay
|
|
398
|
+
// recall-reachable (`cmk search`), so moving them out of MEMORY.md is fine —
|
|
399
|
+
// it's an injection-budget trim, not data loss.
|
|
400
|
+
// - USER PERSONA (tier U): NEVER graduate — the persona's value IS being
|
|
401
|
+
// injected at cold-open, and the user-tier graduation target (fragments/) is
|
|
402
|
+
// NOT read by inject-context, so graduating a promoted trait there strands it
|
|
403
|
+
// (Hole B — the v0.3.1 cold-open bug). Instead CONDENSE in place (mechanical,
|
|
404
|
+
// no LLM — drops no bullet); if still over cap the file grows past the inject
|
|
405
|
+
// budget (load-cap, not write-cap — D-61) and the snapshot load-cap + sweep
|
|
406
|
+
// order (151.5) keeps the high-trust traits injected. LLM rewrite is Task 95.
|
|
407
|
+
// Local tier (machine-paths/overrides) is excluded: machine config, not facts.
|
|
379
408
|
let bulletsGraduated = 0;
|
|
380
409
|
let graduatedIds = [];
|
|
381
410
|
let finalBytes = Buffer.byteLength(finalContent, 'utf8');
|
|
382
|
-
if (finalBytes > cap &&
|
|
411
|
+
if (finalBytes > cap && tier === 'P') {
|
|
383
412
|
const grad = graduateForCapRelief({
|
|
384
413
|
text: finalContent,
|
|
385
414
|
capBytes: cap,
|
|
@@ -392,6 +421,10 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
392
421
|
graduatedIds = grad.graduated;
|
|
393
422
|
bulletsGraduated = graduatedIds.length;
|
|
394
423
|
finalBytes = Buffer.byteLength(finalContent, 'utf8');
|
|
424
|
+
} else if (finalBytes > cap && tier === 'U') {
|
|
425
|
+
// Demote-not-evict: reclaim bytes without losing a single persona trait.
|
|
426
|
+
finalContent = condenseScratchpadForCapRelief(finalContent);
|
|
427
|
+
finalBytes = Buffer.byteLength(finalContent, 'utf8');
|
|
395
428
|
}
|
|
396
429
|
|
|
397
430
|
// 3. Load-cap, NOT write-cap (Task 94 / D-61 / design §19). The write ALWAYS
|
|
@@ -529,17 +562,23 @@ export function sweepScratchpadForCapRelief({
|
|
|
529
562
|
|
|
530
563
|
let graduatedIds = [];
|
|
531
564
|
if (Buffer.byteLength(working, 'utf8') > cap) {
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
565
|
+
// Task 151.4 (§20.3): PROJECT graduates to the recall-reachable fact store;
|
|
566
|
+
// USER PERSONA condenses in place — never graduates to un-injected fragments/
|
|
567
|
+
// (Hole B). tier is guaranteed P||U by the gate above.
|
|
568
|
+
if (tier === 'P') {
|
|
569
|
+
const grad = graduateForCapRelief({
|
|
570
|
+
text: working,
|
|
571
|
+
capBytes: cap,
|
|
572
|
+
tier,
|
|
573
|
+
projectRoot,
|
|
574
|
+
userDir,
|
|
575
|
+
now: ts,
|
|
576
|
+
});
|
|
577
|
+
working = grad.text;
|
|
578
|
+
graduatedIds = grad.graduated;
|
|
579
|
+
} else {
|
|
580
|
+
working = condenseScratchpadForCapRelief(working);
|
|
581
|
+
}
|
|
543
582
|
}
|
|
544
583
|
|
|
545
584
|
if (working === original) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// trust-score.mjs — the evolving PROTECTION field (Task 151.6+, ADR-0016 §20.2).
|
|
2
|
+
//
|
|
3
|
+
// `trust_score` is a FLOAT per fact, stored in the REBUILDABLE INDEX (the
|
|
4
|
+
// index-db `observations.trust_score` column), NOT in committed frontmatter
|
|
5
|
+
// (D-218: a moving value in a committed .md = git-diff noise; markdown-as-truth
|
|
6
|
+
// keeps committed files clean). It is reconstructed on every full reindex.
|
|
7
|
+
//
|
|
8
|
+
// Two trust signals, two homes (the kit's deliberate split — MemOS proves a
|
|
9
|
+
// single unified score lets a noisy fact rank high):
|
|
10
|
+
// - `trust` ENUM (high/medium/low) — committed frontmatter, the CURATED signal
|
|
11
|
+
// a human/classifier assigned; it gates promotion-protection coarsely and is
|
|
12
|
+
// the INIT seed here.
|
|
13
|
+
// - `trust_score` FLOAT — this module, the rebuildable index; it EVOLVES from
|
|
14
|
+
// passive outcomes (151.7 update rule + 151.8 signals) so a fact that keeps
|
|
15
|
+
// getting contradicted sinks and a fact that keeps getting restated rises,
|
|
16
|
+
// with NO ritual (D-169).
|
|
17
|
+
//
|
|
18
|
+
// 151.6 ships ONLY the INIT (source-based seed). 151.7 adds the asymmetric,
|
|
19
|
+
// clamped, floored update rule; 151.8 wires the three passive signals.
|
|
20
|
+
|
|
21
|
+
// The floor (memclaw `_adjust_weights`): trust_score never reaches zero, so a
|
|
22
|
+
// fact is never auto-deleted purely by trust decay — demote-not-evict (§20.3).
|
|
23
|
+
export const TRUST_SCORE_FLOOR = 0.05;
|
|
24
|
+
// The ceiling: cap below 1.0 so a maxed-out fact still has reinforcement headroom
|
|
25
|
+
// and the init never starts at the top.
|
|
26
|
+
export const TRUST_SCORE_CEIL = 0.95;
|
|
27
|
+
|
|
28
|
+
// Base score per committed trust enum — the curated coarse signal.
|
|
29
|
+
const TRUST_ENUM_BASE = { high: 0.8, medium: 0.5, low: 0.3 };
|
|
30
|
+
const DEFAULT_ENUM_BASE = 0.5; // unknown enum → treat as medium
|
|
31
|
+
|
|
32
|
+
// Source adjustment: a USER-ATTESTED write (the user stated/curated it) starts
|
|
33
|
+
// higher than a MACHINE-DERIVED one at the same enum — the design's
|
|
34
|
+
// "user-explicit > auto-extract" (memory-os source-based init). `imported` is
|
|
35
|
+
// neutral (provenance unknown); `seed` is a scaffold placeholder, slightly low.
|
|
36
|
+
const SOURCE_ADJUST = {
|
|
37
|
+
'user-explicit': +0.1,
|
|
38
|
+
'manual-edit': +0.1,
|
|
39
|
+
imported: 0,
|
|
40
|
+
'auto-extract': -0.05,
|
|
41
|
+
compressor: -0.05,
|
|
42
|
+
seed: -0.1,
|
|
43
|
+
};
|
|
44
|
+
const DEFAULT_SOURCE_ADJUST = 0; // unknown source → neutral
|
|
45
|
+
|
|
46
|
+
// Recurrence contribution to the SEED (Task 151.8 — the research fix). A re-stated
|
|
47
|
+
// fact seeds HIGHER, and durably: this is reconstructed from the committed
|
|
48
|
+
// `recurrence_count` (151.1) on every reindex, so the "restatement reinforcement"
|
|
49
|
+
// can never be wiped by a reseed (unlike a fragile overlay delta would be). This
|
|
50
|
+
// is the MemoryOS/MemOS/honcho pattern — the recurrence COUNT is a TERM in the
|
|
51
|
+
// score, NOT a separate event. The cap is LOAD-BEARING (MemOS `min(count·w, 2)`):
|
|
52
|
+
// recurrence is a tie-breaker, never a runaway driver — a high-recurrence LOW-trust
|
|
53
|
+
// fact must never outrank a once-stated HIGH-trust one (the value-blind-LFU bug).
|
|
54
|
+
const RECUR_WEIGHT = 0.02; // per recurrence beyond the first
|
|
55
|
+
const RECUR_CONTRIB_CAP = 0.1; // max total recurrence lift (5 recurrences = full)
|
|
56
|
+
|
|
57
|
+
function recurrenceTerm(recurrenceCount) {
|
|
58
|
+
const n = Number.isFinite(recurrenceCount) && recurrenceCount > 1 ? recurrenceCount : 1;
|
|
59
|
+
return Math.min((n - 1) * RECUR_WEIGHT, RECUR_CONTRIB_CAP);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clamp(n) {
|
|
63
|
+
if (!Number.isFinite(n)) return DEFAULT_ENUM_BASE;
|
|
64
|
+
return Math.min(TRUST_SCORE_CEIL, Math.max(TRUST_SCORE_FLOOR, n));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Initial trust_score for a fact, from its committed signals (Task 151.6 + .8).
|
|
69
|
+
* Pure + deterministic — same inputs, same score; reconstructed on every full
|
|
70
|
+
* reindex. A user-attested source outranks a machine-derived one at the same
|
|
71
|
+
* trust enum; a re-stated fact (higher `recurrenceCount`) seeds higher, CAPPED;
|
|
72
|
+
* the result is clamped to [FLOOR, CEIL].
|
|
73
|
+
*
|
|
74
|
+
* @param {object} o
|
|
75
|
+
* @param {string} [o.trust] the committed trust enum (high|medium|low)
|
|
76
|
+
* @param {string} [o.writeSource] the committed write source (user-explicit|…)
|
|
77
|
+
* @param {number} [o.recurrenceCount] the committed recurrence_count (151.1); a
|
|
78
|
+
* re-stated fact seeds higher (capped) — the
|
|
79
|
+
* DURABLE restatement-reinforcement (151.8)
|
|
80
|
+
* @returns {number} the seed trust_score in [FLOOR, CEIL]
|
|
81
|
+
*/
|
|
82
|
+
export function initTrustScore({ trust, writeSource, recurrenceCount } = {}) {
|
|
83
|
+
const base = TRUST_ENUM_BASE[trust] ?? DEFAULT_ENUM_BASE;
|
|
84
|
+
const adjust = SOURCE_ADJUST[writeSource] ?? DEFAULT_SOURCE_ADJUST;
|
|
85
|
+
return clamp(base + adjust + recurrenceTerm(recurrenceCount));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The asymmetric event deltas (Task 151.7; memclaw `evolve_service._adjust_weights`):
|
|
89
|
+
// a dampen moves MORE than a reinforce, so a contradicted/superseded fact sinks
|
|
90
|
+
// faster than a restated one rises — the conservative, fail-loud posture (a single
|
|
91
|
+
// failure outweighs a single success). Event-driven, NOT clock-driven.
|
|
92
|
+
export const REINFORCE_DELTA = +0.1;
|
|
93
|
+
export const DAMPEN_DELTA = -0.15;
|
|
94
|
+
|
|
95
|
+
// The event → delta map. An unknown event contributes 0 (no-op) so a future
|
|
96
|
+
// signal that isn't wired yet can't silently corrupt a score.
|
|
97
|
+
const EVENT_DELTA = {
|
|
98
|
+
reinforce: REINFORCE_DELTA,
|
|
99
|
+
dampen: DAMPEN_DELTA,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Apply one passive-outcome event to a fact's trust_score (Task 151.7).
|
|
104
|
+
* Pure + deterministic — the SAME (current, event) always yields the same result
|
|
105
|
+
* (event-driven, NOT time-decayed; recency decay is a search-ranking concern
|
|
106
|
+
* computed at read, never stored). The result is clamped to [FLOOR, CEIL]; the
|
|
107
|
+
* floor is load-bearing — repeated dampens settle at 0.05, never zero, so a fact
|
|
108
|
+
* is never auto-deleted by trust decay (demote-not-evict, §20.3). 151.8 maps the
|
|
109
|
+
* three passive signals (contradiction / supersession / restatement) onto the
|
|
110
|
+
* 'dampen' / 'reinforce' events.
|
|
111
|
+
*
|
|
112
|
+
* @param {number} current the fact's current trust_score
|
|
113
|
+
* @param {string} [event] 'reinforce' | 'dampen' (unknown → no-op)
|
|
114
|
+
* @returns {number} the updated trust_score in [FLOOR, CEIL]
|
|
115
|
+
*/
|
|
116
|
+
export function updateTrustScore(current, event) {
|
|
117
|
+
const base = Number.isFinite(current) ? current : DEFAULT_ENUM_BASE;
|
|
118
|
+
const delta = EVENT_DELTA[event] ?? 0;
|
|
119
|
+
return clamp(base + delta);
|
|
120
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// trust-signal.mjs — apply a PASSIVE outcome event to a fact's trust_score
|
|
2
|
+
// (Task 151.8, ADR-0016 §20.2). The I/O half of the trust layer; the pure
|
|
3
|
+
// arithmetic is `updateTrustScore` in trust-score.mjs.
|
|
4
|
+
//
|
|
5
|
+
// The three passive signals (D-169, zero ritual — no `cmk` command):
|
|
6
|
+
// - CONTRADICTION (a write conflicts with an existing fact) → dampen the existing
|
|
7
|
+
// - SUPERSESSION (`superseded_by` set / a replace) → dampen the OLD fact
|
|
8
|
+
// - RE-SURFACE (the 151.1 recurrence bump) → reinforce the fact
|
|
9
|
+
// 151.8 wires these call sites to this helper.
|
|
10
|
+
//
|
|
11
|
+
// DURABILITY POSTURE (decided 2026-06-30, D-237 — research- + code-grounded):
|
|
12
|
+
// trust_score lives in the REBUILDABLE index (the `observations.trust_score`
|
|
13
|
+
// column), which the kit treats as a "regenerable read-cache" (index-db.mjs).
|
|
14
|
+
// So this is a RUNTIME OVERLAY: the delta survives the common path (reindexBoot
|
|
15
|
+
// skips unchanged files) → durable session-to-session on a machine; a FULL
|
|
16
|
+
// reindex (`cmk reindex --full` / repair) reseeds from the committed `trust`
|
|
17
|
+
// enum and resets the overlay — acceptable because trust_score is the LOCAL
|
|
18
|
+
// protection signal, while the committed `trust` enum is the PORTABLE one (the
|
|
19
|
+
// two-fields design). Every surveyed system that evolves trust (memclaw
|
|
20
|
+
// `_adjust_weights`, MemoryOS, honcho, EverOS, captain-claw) keeps it in the
|
|
21
|
+
// runtime DB store, not in a committed/replayed log — this matches them.
|
|
22
|
+
//
|
|
23
|
+
// BEST-EFFORT: a trust nudge must NEVER break the primary write. Every failure
|
|
24
|
+
// (no projectRoot, no DB, missing row, bad input) returns a benign result and
|
|
25
|
+
// NEVER throws — the caller (a write path) is unaffected.
|
|
26
|
+
|
|
27
|
+
import { openIndexDb } from './index-db.mjs';
|
|
28
|
+
import { updateTrustScore } from './trust-score.mjs';
|
|
29
|
+
|
|
30
|
+
const SELECT_TRUST_SCORE_SQL = 'SELECT trust_score FROM observations WHERE id = ?';
|
|
31
|
+
const UPDATE_TRUST_SCORE_SQL = 'UPDATE observations SET trust_score = ? WHERE id = ?';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply one passive-outcome event to a fact's trust_score overlay (Task 151.8).
|
|
35
|
+
* BEST-EFFORT — swallows every error, never throws.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} o
|
|
38
|
+
* @param {string} [o.projectRoot] the project whose index-db holds the row
|
|
39
|
+
* (ignored when `db` is supplied)
|
|
40
|
+
* @param {string} [o.id] the affected fact's canonical id
|
|
41
|
+
* @param {string} [o.event] 'reinforce' | 'dampen' (unknown → no-op write)
|
|
42
|
+
* @param {object} [o.db] an ALREADY-OPEN index-db handle to reuse — a
|
|
43
|
+
* caller that fires several signals in one op
|
|
44
|
+
* (e.g. a merge dampening idA+idB) passes one
|
|
45
|
+
* handle to avoid open/close per call. When given,
|
|
46
|
+
* this function does NOT close it (the caller owns
|
|
47
|
+
* its lifecycle). Omit → open + close our own.
|
|
48
|
+
* @returns {{action:'updated'|'not-found'|'skipped', id?:string, trust_score?:number}}
|
|
49
|
+
*/
|
|
50
|
+
export function applyTrustSignal({ projectRoot, id, event, db: sharedDb } = {}) {
|
|
51
|
+
if (!id || (!projectRoot && !sharedDb)) return { action: 'skipped' };
|
|
52
|
+
let db = sharedDb;
|
|
53
|
+
try {
|
|
54
|
+
if (!db) db = openIndexDb({ projectRoot });
|
|
55
|
+
const row = db.prepare(SELECT_TRUST_SCORE_SQL).get(id);
|
|
56
|
+
if (!row) return { action: 'not-found', id };
|
|
57
|
+
const next = updateTrustScore(row.trust_score, event);
|
|
58
|
+
db.prepare(UPDATE_TRUST_SCORE_SQL).run(next, id);
|
|
59
|
+
return { action: 'updated', id, trust_score: next };
|
|
60
|
+
} catch {
|
|
61
|
+
// best-effort: a trust overlay update must never break the primary write.
|
|
62
|
+
return { action: 'skipped', id };
|
|
63
|
+
} finally {
|
|
64
|
+
// Only close a handle WE opened — never the caller's shared handle.
|
|
65
|
+
if (!sharedDb && db) {
|
|
66
|
+
try {
|
|
67
|
+
db.close();
|
|
68
|
+
} catch {
|
|
69
|
+
// ignore close errors on a best-effort path
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/write-fact.mjs
CHANGED
|
@@ -106,6 +106,12 @@ function buildFrontmatterObject(opts, computed) {
|
|
|
106
106
|
created_at: computed.createdAt,
|
|
107
107
|
write_source: opts.writeSource,
|
|
108
108
|
trust: opts.trust,
|
|
109
|
+
// Task 151.1 (ADR-0016 / design §20.1): the capped-recurrence promotion
|
|
110
|
+
// signal. Starts at 1 on create; the duplicate-hit path bumps it when the
|
|
111
|
+
// SAME canonical fact re-surfaces (same content-hash id). A promotion fact,
|
|
112
|
+
// so it lives in committed frontmatter (diffable) — unlike trust_score,
|
|
113
|
+
// which moves on every recall and lives in the rebuildable index (D-218).
|
|
114
|
+
recurrence_count: computed.recurrenceCount ?? 1,
|
|
109
115
|
source_file: opts.sourceFile,
|
|
110
116
|
source_line: opts.sourceLine,
|
|
111
117
|
source_sha1: opts.sourceSha1,
|
|
@@ -141,6 +147,27 @@ function readExistingFactId(path) {
|
|
|
141
147
|
return frontmatter?.id ?? null;
|
|
142
148
|
}
|
|
143
149
|
|
|
150
|
+
// Task 151.1 (ADR-0016 / design §20.1): a duplicate write = the SAME canonical
|
|
151
|
+
// fact re-surfaced → bump its `recurrence_count` in place. Only the bumped fact
|
|
152
|
+
// is touched (the over-mutation guard). Returns the new count, or null if the
|
|
153
|
+
// file can't be read/parsed (best-effort: a re-surface bump must never turn a
|
|
154
|
+
// successful no-op into an error).
|
|
155
|
+
function bumpRecurrence(path) {
|
|
156
|
+
try {
|
|
157
|
+
const { frontmatter, body } = parse(readFileSync(path, 'utf8'));
|
|
158
|
+
if (!frontmatter) return null;
|
|
159
|
+
const current = Number.isInteger(frontmatter.recurrence_count)
|
|
160
|
+
? frontmatter.recurrence_count
|
|
161
|
+
: 1; // pre-151 facts have no field → treat as 1, this re-surface makes 2
|
|
162
|
+
const next = current + 1;
|
|
163
|
+
frontmatter.recurrence_count = next;
|
|
164
|
+
writeFileSync(path, format({ frontmatter, body }), 'utf8');
|
|
165
|
+
return next;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
export function writeFact(opts = {}) {
|
|
145
172
|
const errors = validateOptions(opts);
|
|
146
173
|
if (errors.length > 0) {
|
|
@@ -211,15 +238,25 @@ export function writeFact(opts = {}) {
|
|
|
211
238
|
const existingIdAtPath = readExistingFactId(path);
|
|
212
239
|
if (existingIdAtPath !== null) {
|
|
213
240
|
if (existingIdAtPath === id) {
|
|
241
|
+
// Task 151.1: the same canonical fact re-surfaced → bump recurrence_count.
|
|
242
|
+
const recurrenceCount = bumpRecurrence(path);
|
|
214
243
|
appendAuditEntry(tierRoot, {
|
|
215
244
|
ts: createdAt,
|
|
216
|
-
action: '
|
|
245
|
+
action: 'recurrence',
|
|
217
246
|
tier: opts.tier,
|
|
218
247
|
id,
|
|
219
|
-
reasonCode: REASON_CODES.
|
|
248
|
+
reasonCode: REASON_CODES.RECURRENCE,
|
|
249
|
+
extra: { recurrenceCount },
|
|
220
250
|
paths: { before: path },
|
|
221
251
|
});
|
|
222
|
-
|
|
252
|
+
// Task 151.8 (research fix): the re-surface RESTATEMENT signal is NOT a
|
|
253
|
+
// fragile overlay delta — `bumpRecurrence` just wrote the new recurrence_count
|
|
254
|
+
// to the committed file, and `initTrustScore` folds a CAPPED recurrence term
|
|
255
|
+
// into the seed, so the next reindex reconstructs a HIGHER trust_score from
|
|
256
|
+
// the durable count (MemoryOS/MemOS/honcho: the count IS a score term). No
|
|
257
|
+
// overlay write here — it would only be reseeded away by the reindex that the
|
|
258
|
+
// file change triggers. Durable-by-construction.
|
|
259
|
+
return { action: 'skipped', skipReason: 'duplicate', id, path, recurrenceCount };
|
|
223
260
|
}
|
|
224
261
|
return errorResult({
|
|
225
262
|
category: ERROR_CATEGORIES.COLLISION,
|
|
@@ -233,20 +270,28 @@ export function writeFact(opts = {}) {
|
|
|
233
270
|
|
|
234
271
|
const elsewhere = findExistingFactById(factDir, id);
|
|
235
272
|
if (elsewhere) {
|
|
273
|
+
// Task 151.1: re-surface via a different slug → bump the ORIGINAL fact.
|
|
274
|
+
const recurrenceCount = bumpRecurrence(elsewhere);
|
|
236
275
|
appendAuditEntry(tierRoot, {
|
|
237
276
|
ts: createdAt,
|
|
238
|
-
action: '
|
|
277
|
+
action: 'recurrence',
|
|
239
278
|
tier: opts.tier,
|
|
240
279
|
id,
|
|
241
280
|
reasonCode: REASON_CODES.DUPLICATE_ELSEWHERE,
|
|
281
|
+
extra: { recurrenceCount },
|
|
242
282
|
paths: { before: elsewhere, after: path },
|
|
243
283
|
});
|
|
284
|
+
// Task 151.8 (research fix): restatement reinforcement is DURABLE via the seed
|
|
285
|
+
// (initTrustScore folds the committed recurrence_count), not a doomed overlay —
|
|
286
|
+
// the bump rewrote the file, so any overlay write would be reseeded away. See
|
|
287
|
+
// the same-id branch above.
|
|
244
288
|
return {
|
|
245
289
|
action: 'skipped',
|
|
246
290
|
skipReason: 'duplicate-elsewhere',
|
|
247
291
|
id,
|
|
248
292
|
path,
|
|
249
293
|
duplicateAt: elsewhere,
|
|
294
|
+
recurrenceCount,
|
|
250
295
|
};
|
|
251
296
|
}
|
|
252
297
|
|