@avantf/dsh-mem 0.9.0 → 0.10.0

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/lib/index.js CHANGED
@@ -167,6 +167,146 @@ const DEGRADED_WEIGHTS = {
167
167
  */
168
168
  const DEGRADED_LEG_NOTE = "本次仅词法腿,语义腿未就绪(语义后端不可用,结果只来自 FTS 与实体腿)。";
169
169
  //#endregion
170
+ //#region ../contract/lib/trust_curve.js
171
+ /**
172
+ * The trust decay curve — PURE functions, no IO, no config object of its own.
173
+ *
174
+ * This file is the ONE implementation of "how much trust is left after N active days" and of every
175
+ * inverse of it. `core/lifecycle/trust.ts` (read path, recall reinforcement), the tick's settle
176
+ * statement (`db/dao/facts.ts`) and the config's load-time validation (`config.ts`) all delegate
177
+ * here, so a change of shape cannot leave one of them behind (docs/TRUST_MODEL.md §2.2, DESIGN §18).
178
+ *
179
+ * It lives in the CONTRACT package because the load-time validation needs the same math, and the
180
+ * dependency direction is core → contract, never the reverse.
181
+ *
182
+ * ## The two shapes
183
+ *
184
+ * `linear` (TRUST_MODEL D7, the P3 equivalent tier): trust falls by a constant amount per active
185
+ * day, `slope = BASE_TRUST / base_lifetime_days` ⇒ a fact that starts at `BASE_TRUST` reaches 0
186
+ * after exactly `base_lifetime_days` active days.
187
+ *
188
+ * `accelerating` (the v2 target shape): `dT/dt = −λ·(a + 1 − T)` — the closer to 1, the slower the
189
+ * decay, so "almost permanent" facts really linger. Closed form (plan §3.1):
190
+ *
191
+ * T(t) = (1 + a) − ((1 + a) − T₀)·e^(λt) λ = ln((1 + a)/(1 + a − BASE_TRUST)) / B
192
+ *
193
+ * Note the POSITIVE exponent: `u = a + 1 − T` grows, so `e^(λt)` is the decay direction here (the
194
+ * plan writes it this way, and the closed-form test pins it). `a = decay_curvature`.
195
+ *
196
+ * ## Units
197
+ *
198
+ * `days` is ALWAYS the ACTIVE-DAY counter (`avantf_stats.trust_clock`), never wall time.
199
+ */
200
+ /**
201
+ * The frozen anchor (TRUST_MODEL D20): a new fact's initial trust, the trust value the lifetime
202
+ * anchor is defined at (`daysToZero(BASE_TRUST) === base_lifetime_days`), and the divider between
203
+ * the two recall-reinforcement segments. One constant on purpose — the three uses must never be
204
+ * configurable apart, or "a new fact lives 90 active days" stops being true.
205
+ */
206
+ const BASE_TRUST = .5;
207
+ /** The decay shapes `decay_shape` accepts. */
208
+ const DECAY_SHAPES = ["linear", "accelerating"];
209
+ /** Read the curve out of the `trust` section (the single mapping from keys to math). */
210
+ function curveOf(trust) {
211
+ return {
212
+ shape: trust.decay_shape,
213
+ curvature: trust.decay_curvature,
214
+ baseDays: trust.base_lifetime_days
215
+ };
216
+ }
217
+ /**
218
+ * Trust units lost per active day — the LINEAR slope, `BASE_TRUST / base_lifetime_days`.
219
+ *
220
+ * `linear` is the only shape with a constant slope; for `accelerating` this returns the LOCAL slope
221
+ * at the anchor `T = BASE_TRUST` (`λ·(BASE_TRUST + a)`). It exists as the building block of the
222
+ * `linear` branch of {@link trustAfterDays} / {@link ceilingForDays} and for the tests that pin the
223
+ * anchor; it is deliberately NOT a scalar stand-in for the curve — anything that needs "how much
224
+ * trust is left after N days" uses {@link trustAfterDays}, and anything that needs "N days from a
225
+ * threshold" uses {@link ceilingForDays}. (An earlier caller — `trust_diagnose.forgetting_soon` —
226
+ * used it as a linear rate and was wrong once `accelerating` became the default.)
227
+ */
228
+ function slopeOf(shape, curvature, baseDays) {
229
+ if (shape === "linear") return BASE_TRUST / baseDays;
230
+ return lambdaFor(curvature, baseDays) * (BASE_TRUST + curvature);
231
+ }
232
+ /**
233
+ * `λ = ln((1 + a) / (1 + a − BASE_TRUST)) / baseDays` — pinned so the anchor holds:
234
+ * `λ·baseDays = ln((1 + a)/(0.5 + a))`, i.e. `daysToZero(BASE_TRUST) === baseDays` for ANY `a`.
235
+ */
236
+ function lambdaFor(curvature, baseDays) {
237
+ return Math.log((1 + curvature) / (1 + curvature - BASE_TRUST)) / baseDays;
238
+ }
239
+ /**
240
+ * `T − days·slope` (linear) or the closed form `(1 + a) − ((1 + a) − T)·e^(λ·days)`.
241
+ *
242
+ * UNCLAMPED on purpose: callers clamp to `[0, 1]` themselves (`effectiveTrust`'s read path and the
243
+ * settle write both must clamp, and a shared clamp here would hide a caller that forgot).
244
+ */
245
+ function trustAfterDays(T, days, cfg) {
246
+ if (cfg.shape === "linear") return T - days * slopeOf(cfg.shape, cfg.curvature, cfg.baseDays);
247
+ const a = cfg.curvature;
248
+ return 1 + a - (1 + a - T) * Math.exp(lambdaFor(a, cfg.baseDays) * days);
249
+ }
250
+ /**
251
+ * Active days between two trust values, `t(T0 → T1)`.
252
+ *
253
+ * `linear`: `(T0 − T1) / slope`; `accelerating`: `ln((1 + a − T1)/(1 + a − T0)) / λ`. Both are
254
+ * signed the natural way (a RISING target yields a negative number of days), so the formula is
255
+ * usable as an inverse without special cases. Returns `NaN` only for an out-of-domain input
256
+ * (`T > 1 + a` on the accelerating branch), which the config validation makes unreachable.
257
+ */
258
+ function daysBetween(T0, T1, cfg) {
259
+ if (cfg.shape === "linear") return (T0 - T1) / slopeOf(cfg.shape, cfg.curvature, cfg.baseDays);
260
+ const a = cfg.curvature;
261
+ return Math.log((1 + a - T1) / (1 + a - T0)) / lambdaFor(a, cfg.baseDays);
262
+ }
263
+ /** Active days until the value reaches 0 — `remainingDays`'s whole body. */
264
+ function daysToZero(T, cfg) {
265
+ return daysBetween(T, 0, cfg);
266
+ }
267
+ /**
268
+ * The trust ceiling a `max_retention_days` promises: after being reinforced up to `ceiling`, a fact
269
+ * survives `days` more active days.
270
+ *
271
+ * `linear`: `days · slope` (= `days / (2·baseDays)`); `accelerating`: `(1 + a)(1 − e^(−λ·days))`.
272
+ * This is an INVERSION (`daysToZero(ceilingForDays(R)) === R`), not a configuration value — the
273
+ * config has no `recall_ceiling` key any more (D16).
274
+ */
275
+ function ceilingForDays(days, cfg) {
276
+ if (cfg.shape === "linear") return days * slopeOf(cfg.shape, cfg.curvature, cfg.baseDays);
277
+ const a = cfg.curvature;
278
+ return (1 + a) * (1 - Math.exp(-lambdaFor(a, cfg.baseDays) * days));
279
+ }
280
+ /**
281
+ * The inverse of {@link ceilingForDays}: how many active days a given ceiling is worth. Used by the
282
+ * deprecated-key warning (`config/loader.ts`) to translate an old `recall_ceiling` value, and by the
283
+ * validation messages.
284
+ */
285
+ function retentionDaysForCeiling(ceiling, cfg) {
286
+ if (cfg.shape === "linear") return ceiling / slopeOf(cfg.shape, cfg.curvature, cfg.baseDays);
287
+ return daysToZero(ceiling, cfg);
288
+ }
289
+ /**
290
+ * `R_max` of the accelerating shape — the days at which the ceiling reaches 1 (`ceiling → ∞`).
291
+ *
292
+ * R_max = baseDays · ln((1 + a)/a) / ln((1 + a)/(1 + a − BASE_TRUST))
293
+ *
294
+ * `max_retention_days` must stay strictly below it, or a reinforced fact would stop aging like
295
+ * `pinned` does (D14: permanent is set only by a person).
296
+ */
297
+ function RMAX(curvature, baseDays) {
298
+ return baseDays * Math.log((1 + curvature) / curvature) / Math.log((1 + curvature) / (1 + curvature - BASE_TRUST));
299
+ }
300
+ /**
301
+ * The shape-aware upper bound on `max_retention_days`: the days at which the ceiling hits 1.
302
+ *
303
+ * `linear`: `2·baseDays` (the ceiling is `days/(2·baseDays)`); `accelerating`: {@link RMAX}. Both
304
+ * are STRICT bounds — at the bound the ceiling is exactly 1, which `superRefine` rejects.
305
+ */
306
+ function retentionDaysLimit(shape, curvature, baseDays) {
307
+ return shape === "linear" ? 2 * baseDays : RMAX(curvature, baseDays);
308
+ }
309
+ //#endregion
170
310
  //#region ../contract/lib/config.js
171
311
  /**
172
312
  * Upper bound on tokens handed to the embedder. 0 = auto (the loaded model's declared
@@ -528,40 +668,83 @@ const presenceSchema = z.object({
528
668
  /** Long-lived surfaces (plugin / MCP) tick this often; 0 = startup pass only. */
529
669
  heartbeat_minutes: z.number().int().nonnegative().default(60)
530
670
  });
671
+ /**
672
+ * Trust & forgetting (TRUST_MODEL.md §2 / §7).
673
+ *
674
+ * The knobs are DAYS and a SHAPE, never a slope: the operator's promise is "how long does a memory
675
+ * last", and the arithmetic that turns days into trust units lives in `trust_curve.ts` (one
676
+ * implementation, shared with the load-time validation below). `recall_ceiling` is gone — the
677
+ * reinforcement ceiling is an INVERSION of `max_retention_days` (D16).
678
+ */
531
679
  const trustSchema = z.object({
532
- /** false = no presence, no decay, no reinforcement, no pinning (TTL/idle/purge keep running). */
680
+ /** false = no presence, no decay, no reinforcement, no pinning, no automatic archiving (purge keeps running). */
533
681
  enabled: z.boolean().default(true),
534
- /** Initial trust of a new fact. */
535
- start: z.number().min(0).max(1).default(.5),
536
- /** Linear decay per active day: `0.5 / 90` ⇒ 90 active days from start to 0. */
537
- decay_per_day: z.number().min(0).max(1).default(.5 / 90),
682
+ /**
683
+ * The anchor: active days a fact takes to fall from `BASE_TRUST` (0.5, frozen — D20) to 0, i.e.
684
+ * "how long a never-recalled memory lives". `start` is NOT configurable any more: it is the same
685
+ * 0.5 as the anchor and the reinforcement divider, so one knob could untie all three.
686
+ */
687
+ base_lifetime_days: z.number().int().positive().default(90),
688
+ /**
689
+ * The longest stay recall reinforcement can buy, in ACTIVE days — the ceiling is inverted from
690
+ * this (`ceilingForDays`), never configured (D16).
691
+ *
692
+ * P4 ships **174** with `decay_shape: 'accelerating'` ⇒ ceiling ≈ **0.7492**. That pair is a
693
+ * deliberate PROMISE change, not a correction: the longest stay grows 153 → 174 active days and the
694
+ * reinforcement ceiling falls 0.85 → 0.7492 (CHANGELOG/unreleased.md). P3's equivalent tier —
695
+ * `linear` + 153 ⇒ exactly 0.85 — stays legal and is the documented rollback.
696
+ */
697
+ max_retention_days: z.number().int().positive().default(174),
698
+ /** `accelerating` (the v2 target shape) or `linear` (P3's equivalent tier / the rollback). */
699
+ decay_shape: z.enum(DECAY_SHAPES).default("accelerating"),
700
+ /**
701
+ * `a` — the accelerating shape's floor term. `a > 0` is a hard requirement: at `a = 0`, `T = 1`
702
+ * is a fixed point, so an `unpin`ned `trust = 1` fact would never decay again — a second kind of
703
+ * permanent. The `(0, 0.5]` bound is checked in the `superRefine` below (with the reason in the
704
+ * message). Unused by `linear`, present so both shapes share one schema.
705
+ */
706
+ decay_curvature: z.number().default(.05),
538
707
  /** Trust ≤ this ⇒ forgotten (archived 'forgot'). 0 = "reaching zero is forgetting". */
539
708
  forget_threshold: z.number().min(0).max(1).default(0),
540
- /** Reaching this via explicit feedback promotes to permanent (snapped to 1.0). */
541
- permanent_threshold: z.number().min(0).max(1).default(.9),
542
- /** A recall of something at/below this raises it straight back to this value. */
543
- recall_floor: z.number().min(0).max(1).default(.5),
544
- /** Per-recall gain (before marginal decay). */
545
- recall_delta: z.number().min(0).max(1).default(.03),
709
+ /**
710
+ * Per-recall gain BELOW `BASE_TRUST`, capped at `BASE_TRUST` itself. `0.1` ⇒ five recalls take a
711
+ * fact from 0.05 back to the base (the second crossing — from exactly 0.5 up — is the above-base
712
+ * segment, D20).
713
+ */
714
+ recall_gain_below_base: z.number().gt(0).max(.5).default(.1),
715
+ /** Per-recall gain above `BASE_TRUST` (before marginal decay), capped at the derived ceiling. */
716
+ recall_gain_above_base: z.number().gt(0).max(1).default(.01),
546
717
  /** Effective reinforcement events per fact per 24h (R10: zero-gain ones don't count). */
547
718
  recall_daily_cap: z.number().int().nonnegative().default(3),
548
- /** 1 = every in-window gain is equal; 0.5 = 0.03 / 0.015 / 0.0075 … */
719
+ /** 1 = every in-window gain is equal; 0.5 = 0.01 / 0.005 / 0.0025 … */
549
720
  recall_marginal_decay: z.number().min(0).max(1).default(1),
550
- /** Recall alone can never exceed this (and never pins — see D11). */
551
- recall_ceiling: z.number().min(0).max(1).default(.85),
552
- /** Explicit `helpful` / `unhelpful` step. */
553
- feedback_delta: z.number().min(0).max(1).default(.05),
554
- /** 0 = unlimited; >0 also caps feedback reinforcement per fact per 24h. */
555
- feedback_daily_cap: z.number().int().nonnegative().default(0),
556
721
  /** A revised fact inherits the settled trust (and pin) of the row it replaces. */
557
722
  inherit_trust_on_update: z.boolean().default(true),
558
723
  /** Pinned (permanent) facts are never physically purged. */
559
724
  purge_skips_pinned: z.boolean().default(true),
560
- /** Calendar fallback: unused for this many days ⇒ archived 'idle'. */
561
- idle_calendar_days: z.number().int().positive().default(365),
562
725
  /** Per-tick budget for the settle sweep (the other sweeps only touch critical rows). */
563
726
  tick_max_facts: z.number().int().positive().default(5e3),
564
727
  presence: presenceSchema.prefault({})
728
+ }).superRefine((trust, ctx) => {
729
+ const curve = curveOf(trust);
730
+ const base = trust.base_lifetime_days;
731
+ const days = trust.max_retention_days;
732
+ const a = trust.decay_curvature;
733
+ const fail = (path, message) => {
734
+ ctx.addIssue({
735
+ code: "custom",
736
+ path: [path],
737
+ message
738
+ });
739
+ };
740
+ const ceiling = () => ceilingForDays(days, curve).toFixed(4);
741
+ if (!(a > 0) || a > .5) fail("decay_curvature", `decay_curvature must be in (0, 0.5]; got ${String(a)} — 0 会让 T=1 永不衰减(unpin 之后仍然不老,等于第二层"永久"),>0.5 会把可配寿命区间压到默认值以下`);
742
+ if (days <= base) fail("max_retention_days", `max_retention_days must be > base_lifetime_days (${String(base)}); got ${String(days)} — 你这个值等价于 ceiling=${ceiling()},召回加强不会产生任何增益;合法区间 (${String(base)}, ${retentionDaysLimit(trust.decay_shape, a, base).toFixed(1)})`);
743
+ const limit = retentionDaysLimit(trust.decay_shape, a, base);
744
+ if (!(days < limit)) {
745
+ const other = trust.decay_shape === "linear" ? `accelerating (a=${String(a)}): ${RMAX(a, base).toFixed(1)}` : `linear: ${(2 * base).toFixed(1)}`;
746
+ fail("max_retention_days", `max_retention_days must be < ${limit.toFixed(1)} (${trust.decay_shape}, a=${String(a)}, base=${String(base)}); legal range (${String(base)}, ${limit.toFixed(1)}); got ${String(days)} — 你这个值等价于 ceiling=${ceiling()},与"永久只由人设"冲突;另一种形状的上界:${other}`);
747
+ }
565
748
  });
566
749
  const lifecycleSchema = z.object({
567
750
  /** Purge window, measured in ACTIVE days since `archived_clock`. */
@@ -758,8 +941,6 @@ const KIND_FILTER_DESCRIPTION = `限定记忆层;可选(取值:${MEMORY_KI
758
941
  const SOURCE_REF_DESCRIPTION = "这条事实的来源(可选):未给 = 未知来源,引擎不会臆造。用 `kind:` 前缀标明来源类型——`session:<会话 id>`(会话供述)、`tool:<工具名>`(工具产出)、无前缀则记为 `manual`(调用方手工给出),前缀之后的文本原样保存。可用 `mem_admin` 的 `list`(`source=`)反查同一来源产生了哪些事实。";
759
942
  /** P-13 `event_date`: the event time, written into `valid_from`. */
760
943
  const EVENT_DATE_DESCRIPTION = "这件事发生的时间(可选,ISO 日期或日期时间,如 2026-10-06 或 2026-10-06T09:30:00+08:00):写入事实的事件时间,供时间窗检索与展示使用;不参与打分。未给 = 未知(不写)。";
761
- /** P-13 `valid_until`: a known end, written into `valid_to` WITHOUT archiving the row. */
762
- const VALID_UNTIL_DESCRIPTION = "这条事实有效到什么时候(可选,ISO 日期或日期时间):写入有效期终点,**不会归档该事实**——「仍然有效、但已知截止于某时」的事实照常参与检索。未给 = 未知(不写)。";
763
944
  /**
764
945
  * `title`: the short, human-readable handle of a journal entry (JOURNAL_TITLE_SEARCH §2.4/§11.1).
765
946
  *
@@ -883,9 +1064,12 @@ function modelFacingToolResult(toolKey, action, value) {
883
1064
  * - `source_ref` — the citation the answer must carry;
884
1065
  * - `created_at` / `updated_at` — memory MAINTENANCE: when it was written and when it was last
885
1066
  * changed is what tells the model whether a fact is fresh enough to act on (and `updated_at`
886
- * differing from `created_at` is the only visible sign that it was revised);
887
- * - `valid_from` / `valid_to` — when the fact was true (P-07), which is what an "as of when"
888
- * question needs;
1067
+ * differing from `created_at` is the only visible sign that it was revised). Both are INSTANTS
1068
+ * rendered as ISO-8601 WITH the host's offset (`2026-10-28T19:58:48+08:00`), so the model does no
1069
+ * timezone arithmetic and cannot read them as local time;
1070
+ * - `valid_from` — the event time (P-07/P-13), which is what an "as of when" question needs. A
1071
+ * CALENDAR DATE (`YYYY-MM-DD`) with no zone — deliberately a different shape from the two
1072
+ * instants above, because it answers a different question;
889
1073
  * - `title` — the journal entry's short handle, for recognition and for the follow-up write flow
890
1074
  * (an update inherits it). Display only: it never moved this hit's `score`;
891
1075
  * - `scores` / `final` — only ever present when the caller explicitly asked for the evidence.
@@ -905,7 +1089,6 @@ const MODEL_HIT_FIELDS = [
905
1089
  "created_at",
906
1090
  "updated_at",
907
1091
  "valid_from",
908
- "valid_to",
909
1092
  "scores",
910
1093
  "final"
911
1094
  ];
@@ -913,7 +1096,6 @@ const MODEL_HIT_FIELDS = [
913
1096
  const NULLABLE_HIT_FIELDS = /* @__PURE__ */ new Set([
914
1097
  "updated_at",
915
1098
  "valid_from",
916
- "valid_to",
917
1099
  "title"
918
1100
  ]);
919
1101
  /**
@@ -1167,10 +1349,8 @@ const RememberUnion = z.discriminatedUnion("action", [
1167
1349
  content: z.string().min(1).describe("要写入或替换的事实内容:一句自包含的陈述。action=add 时必填。"),
1168
1350
  kind: z.enum(MEMORY_KINDS).optional().describe(KIND_WRITE_DESCRIPTION),
1169
1351
  title: z.string().min(1).max(120).optional().describe(TITLE_WRITE_DESCRIPTION),
1170
- ttl_days: z.number().int().nonnegative().optional().describe("有效期天数(0 或省略 = 不设有效期;正整数 = 自写入起该天数后自动归档)。update 未给则继承被改写事实的 TTL。"),
1171
1352
  source_ref: z.string().min(1).optional().describe(SOURCE_REF_DESCRIPTION),
1172
1353
  event_date: z.string().min(1).optional().describe(EVENT_DATE_DESCRIPTION),
1173
- valid_until: z.string().min(1).optional().describe(VALID_UNTIL_DESCRIPTION),
1174
1354
  relations: z.array(RelationInputSchema).max(20).optional().describe(RELATIONS_DESCRIPTION)
1175
1355
  }),
1176
1356
  z.object({
@@ -1180,24 +1360,14 @@ const RememberUnion = z.discriminatedUnion("action", [
1180
1360
  kind: z.enum(MEMORY_KINDS).optional().describe(`改写后的${KIND_WRITE_DESCRIPTION}`),
1181
1361
  title: z.string().min(1).max(120).optional().describe(`改写后的${TITLE_WRITE_DESCRIPTION}`),
1182
1362
  retitle: z.literal(true).optional().describe(RETITLE_DESCRIPTION),
1183
- ttl_days: z.number().int().nonnegative().optional().describe("新的有效期天数(0 = 取消有效期);可选,未给则继承被改写事实的 TTL。"),
1184
1363
  source_ref: z.string().min(1).optional().describe(SOURCE_REF_DESCRIPTION),
1185
1364
  event_date: z.string().min(1).optional().describe(EVENT_DATE_DESCRIPTION),
1186
- valid_until: z.string().min(1).optional().describe(VALID_UNTIL_DESCRIPTION),
1187
1365
  relations: z.array(RelationInputSchema).max(20).optional().describe(`改写后的${RELATIONS_DESCRIPTION}`)
1188
1366
  }),
1189
1367
  z.object({
1190
1368
  action: z.literal("remove"),
1191
1369
  fact_id: z.number().int().positive().describe("要删除的事实 ID。action=remove 时必填。"),
1192
1370
  reason: z.string().optional().describe("删除原因;可选。")
1193
- }),
1194
- z.object({
1195
- action: z.literal("helpful"),
1196
- fact_id: z.number().int().positive().describe("对回答有帮助的事实 ID。action=helpful 时必填。")
1197
- }),
1198
- z.object({
1199
- action: z.literal("unhelpful"),
1200
- fact_id: z.number().int().positive().describe("对回答无帮助的事实 ID。action=unhelpful 时必填。")
1201
1371
  })
1202
1372
  ]);
1203
1373
  /**
@@ -1220,9 +1390,13 @@ function modelSubset(union, actions) {
1220
1390
  return z.discriminatedUnion("action", options);
1221
1391
  }
1222
1392
  /**
1223
- * The writes the model may make. `helpful` / `unhelpful` are operator feedback: the model cannot
1224
- * observe the retention clocks they move, and a model that could would be able to "refresh" a fact by
1225
- * voting on it — the exact behaviour the retention policy exists to prevent.
1393
+ * The writes the model may make — `MODEL_REMEMBER_ACTIONS` and `REMEMBER_ACTIONS` are the same three
1394
+ * again since the operator-side `helpful` / `unhelpful` actions were removed: a vote on a fact was a
1395
+ * way to move its retention clock, and a model able to vote could "refresh" a fact by voting on it.
1396
+ *
1397
+ * Permanent is set by ONE route only: an explicit `pin` from a human (`mem_admin` / CLI / settings
1398
+ * page). Do NOT re-add an action that promotes or demotes a fact from a write path — that is the
1399
+ * behaviour the retention policy exists to prevent.
1226
1400
  */
1227
1401
  const MODEL_REMEMBER_ACTIONS = [
1228
1402
  "add",
@@ -1393,7 +1567,7 @@ const ModelAdminUnion = modelSubset(AdminUnion, [
1393
1567
  const REMEMBER_TOOL = {
1394
1568
  key: "remember",
1395
1569
  name: "mem_remember",
1396
- description: "写入/更新/删除长期记忆事实。add=新增一条简洁的独立陈述;update=改写已有事实;remove=删除。写入没有改变任何状态时是静默 no-op(add 命中完全重复的内容、update 重存同一内容)。`kind` 必填,两层:`journal`=做了什么/聊了什么/想了什么(必须同时给 `event_date`,写事情发生的日期而不是写入日期,缺了会被拒绝);`note`=稳定事实/偏好/决策/坑。只记会改变后续行为的摘要,不抄全文;不要把每段聊天都写成 journal;`note` 可用 `relations` 写出稳定关系(`{subject, predicate, object}` 三个名字);`journal` 不写 relations,需要留关系时另写一条 note;journal 的 add 必须给 `title`(短标题,见下),总结覆盖目标/关键动作/结论/产出位置/未决项;从 journal 提炼为 note 时用 `source_ref` 指回来源。标题流程:写 journal 前标题不确定时,用 `mem_recall` 的 `titles`(2–4 个主题关键词)找候选,读候选的 title/event_date/摘要判断是否同一事务——同一事务用 `update` 该 fact_id 并省略 title(继承),不同事务 `add` 并给新的 title。同一事务复用同一 title;后续轮次都用上一次 update 返回的新 fact_id;title 在一条 revision 链内不可变,显式换 title 会被拒绝(标题为 NULL 的旧 journal 可一次性回填);发现标题写错时,用 `update` 带 `retitle: true` 与正确的 title 纠正一次——旧标题保留为别名,用旧标题检索仍能命中。`ttl_days` 规则:显式给定的值一定生效,未给则保留该事实原有的值——update 未给时继承被改写事实的 ttl_days。",
1570
+ description: "写入/更新/删除长期记忆事实。add=新增一条简洁的独立陈述;update=改写已有事实;remove=删除。写入没有改变任何状态时是静默 no-op(add 命中完全重复的内容、update 重存同一内容)。`kind` 必填,两层:`journal`=做了什么/聊了什么/想了什么(必须同时给 `event_date`,写事情发生的日期而不是写入日期,缺了会被拒绝);`note`=稳定事实/偏好/决策/坑。只记会改变后续行为的摘要,不抄全文;不要把每段聊天都写成 journal;`note` 可用 `relations` 写出稳定关系(`{subject, predicate, object}` 三个名字);`journal` 不写 relations,需要留关系时另写一条 note;journal 的 add 必须给 `title`(短标题,见下),总结覆盖目标/关键动作/结论/产出位置/未决项;从 journal 提炼为 note 时用 `source_ref` 指回来源。标题流程:写 journal 前标题不确定时,用 `mem_recall` 的 `titles`(2–4 个主题关键词)找候选,读候选的 title/event_date/摘要判断是否同一事务——同一事务用 `update` 该 fact_id 并省略 title(继承),不同事务 `add` 并给新的 title。同一事务复用同一 title;后续轮次都用上一次 update 返回的新 fact_id;title 在一条 revision 链内不可变,显式换 title 会被拒绝(标题为 NULL 的旧 journal 可一次性回填);发现标题写错时,用 `update` 带 `retitle: true` 与正确的 title 纠正一次——旧标题保留为别名,用旧标题检索仍能命中。",
1397
1571
  input: ModelRememberUnion
1398
1572
  };
1399
1573
  const RECALL_TOOL = {
@@ -1419,7 +1593,7 @@ Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER;
1419
1593
  *
1420
1594
  * Descriptions are authored per action ("action=update 时必填"), so taking the
1421
1595
  * first branch's text verbatim told a model that `fact_id` only matters for
1422
- * `update` although `remove/helpful/unhelpful` require it too. The action-clause
1596
+ * `update` although `remove` requires it too. The action-clause
1423
1597
  * is stripped and replaced by the derived set of actions that make the field
1424
1598
  * required.
1425
1599
  *
@@ -1733,6 +1907,96 @@ function adoptWellFormed(kit) {
1733
1907
  };
1734
1908
  }
1735
1909
  //#endregion
1910
+ //#region ../contract/lib/time.js
1911
+ /**
1912
+ * The ONE place a stored timestamp turns into a string a human (or a model) reads.
1913
+ *
1914
+ * ── THE BUG THIS MODULE EXISTS FOR ───────────────────────────────────────────────────────────────
1915
+ *
1916
+ * `facts.created_at` / `updated_at` / `archived_at` (and `contradiction_log.detected_at`, and the
1917
+ * retrieval-health stamp) are written by SQLite's `DEFAULT CURRENT_TIMESTAMP`, which is **UTC**, and
1918
+ * `store/memory.ts` used to hand the raw text up to every surface. Two consequences, both reported as
1919
+ * "记忆时间显示不对":
1920
+ *
1921
+ * 1. the UI printed `2026-10-28 11:58:48` as if it were a local wall clock (Asia/Shanghai really
1922
+ * meant `19:58:48`), because a zone-less string cannot say otherwise;
1923
+ * 2. the MODEL received the same zone-less string next to `event_date`, which is a calendar DATE
1924
+ * with no time and no zone. Two different kinds of value, one shape — the model had to guess
1925
+ * which one it was looking at, and it cannot.
1926
+ *
1927
+ * ── THE SPLIT THIS MODULE ENFORCES ───────────────────────────────────────────────────────────────
1928
+ *
1929
+ * Storage stays UTC and stays byte-identical (`facts_io` export/import/snapshot is a compatibility
1930
+ * surface and is deliberately NOT routed through here). The fix is in the two DISPLAY faces:
1931
+ *
1932
+ * - {@link formatOffsetIso} — the MODEL face (`mem_recall` hits, `mem_admin list/detail/
1933
+ * contradictions`, `mem_admin stats`, the CLI and MCP that print those payloads): an ISO-8601
1934
+ * instant **with an explicit offset**, e.g. `2026-10-28T19:58:48+08:00`, rendered in the host's
1935
+ * local zone. The offset is part of the string, so the model needs no conversion and the value is
1936
+ * never ambiguous again — while `event_date` stays `2026-10-28`, a calendar date with no zone.
1937
+ * - {@link formatLocalHuman} — the HUMAN face (the 记忆 tab): `2026-10-28 19:58`, the same instant,
1938
+ * local, to the minute.
1939
+ *
1940
+ * Both read the input through the SAME parser ({@link parseUtcTs}) and the same instant, so the two
1941
+ * faces cannot disagree. The UI deliberately formats the model face's OWN string rather than
1942
+ * re-deriving one from the raw column: the input it gets already carries an offset, `parseUtcTs`
1943
+ * leaves an explicit offset alone, and there is therefore exactly ONE parse rule and ONE pair of
1944
+ * renderers in the repository — not a second opinion in the browser.
1945
+ *
1946
+ * Dependency-free on purpose: this module is reachable from the DSH client bundle through the
1947
+ * `@avantf/mem-contract/time` subpath (the bundle must not pull in the tool schemas / zod). It has no
1948
+ * DSH host and no DB, so the engine, the CLI and the MCP face can all use it.
1949
+ *
1950
+ * @module contract/time
1951
+ */
1952
+ /** Two-digit, zero-padded — the only formatting primitive the two renderers share. */
1953
+ function pad2$2(value) {
1954
+ return String(value).padStart(2, "0");
1955
+ }
1956
+ /**
1957
+ * `'YYYY-MM-DD HH:MM:SS'` (SQLite UTC) or ISO → epoch ms; null/garbage → null.
1958
+ *
1959
+ * A stamp with NO zone designator is read as UTC, not as local time. `Date.parse` treats a zone-less
1960
+ * ISO string as LOCAL, and everything in this store is written in UTC (`CURRENT_TIMESTAMP`, and
1961
+ * {@link formatUtcTs}), so handing one over as-is would shift every quota window and every decay
1962
+ * measurement by the host's UTC offset — a fact reinforced at 23:00 UTC+8 would look reinforced 8
1963
+ * hours earlier, and the 24h cap would open or close at the wrong moment. An explicit `Z` or
1964
+ * `±HH:MM` is left alone (that is what makes a `formatOffsetIso` output safe to feed back in).
1965
+ */
1966
+ function parseUtcTs(ts) {
1967
+ if (ts === null || ts === void 0 || ts === "") return null;
1968
+ const iso = ts.includes("T") ? ts : ts.replace(" ", "T");
1969
+ const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(iso) ? iso : `${iso}Z`;
1970
+ const ms = Date.parse(zoned);
1971
+ return Number.isFinite(ms) ? ms : null;
1972
+ }
1973
+ /** Epoch ms → `'YYYY-MM-DD HH:MM:SS'` (the SQLite `CURRENT_TIMESTAMP` shape, UTC). */
1974
+ function formatUtcTs(ms) {
1975
+ return new Date(ms).toISOString().slice(0, 19).replace("T", " ");
1976
+ }
1977
+ /** The host's offset at that instant, as `+HH:MM` / `-HH:MM` (DST-correct: read from the Date). */
1978
+ function offsetSuffix(at) {
1979
+ const minutesEast = -at.getTimezoneOffset();
1980
+ const sign = minutesEast < 0 ? "-" : "+";
1981
+ const abs = Math.abs(minutesEast);
1982
+ return `${sign}${pad2$2(Math.floor(abs / 60))}:${pad2$2(abs % 60)}`;
1983
+ }
1984
+ /**
1985
+ * The MODEL face: an instant as ISO-8601 with an explicit offset, in the host's local zone
1986
+ * (`2026-10-28T19:58:48+08:00`).
1987
+ *
1988
+ * `null` — never `''` and never a fabricated stamp — when the value is absent or unreadable, so a
1989
+ * caller keeps its own "unknown" spelling (`''` for `created_at`, `null` for `updated_at`) instead of
1990
+ * rendering an empty time. `+00:00` (not `Z`) on a UTC host: the shape MUST carry a sign, which is
1991
+ * what a caller regex-checks.
1992
+ */
1993
+ function formatOffsetIso(ts) {
1994
+ const ms = parseUtcTs(ts);
1995
+ if (ms === null) return null;
1996
+ const at = new Date(ms);
1997
+ return `${String(at.getFullYear())}-${pad2$2(at.getMonth() + 1)}-${pad2$2(at.getDate())}T${pad2$2(at.getHours())}:${pad2$2(at.getMinutes())}:${pad2$2(at.getSeconds())}` + offsetSuffix(at);
1998
+ }
1999
+ //#endregion
1736
2000
  //#region lib/types/prompt.js
1737
2001
  /**
1738
2002
  * The one system-prompt section this plugin contributes (DESIGN §10).
@@ -2299,8 +2563,13 @@ const DEFAULT_COMMON_CONFIG = `# avantf-mem 公共配置(分层第②层)。
2299
2563
  # purge_after_archived_days: 365 # 归档事实的物理清理窗口(活跃日)
2300
2564
  # contradiction_threshold: 0.6
2301
2565
 
2302
- # trust:
2303
- # decay_per_day: 0.0055…
2566
+ # trust: # 信任与遗忘:旋钮是「天数 + 形状」,不是斜率
2567
+ # base_lifetime_days: 90 # 锚点:0.5 起点 → 0 的活跃日数(新事实不被召回时的寿命)
2568
+ # max_retention_days: 174 # 召回加强最多能换来的活跃日数;ceiling 由它反解(accelerating 下 ≈ 0.7492)
2569
+ # decay_shape: accelerating # accelerating(默认)| linear(回滚档:配 153 时 ceiling 回到 0.85)
2570
+ # decay_curvature: 0.05 # accelerating 的下限项 a,合法区间 (0, 0.5];linear 下不参与
2571
+ # recall_gain_below_base: 0.1 # 低于基准 0.5 时每次召回加多少(封顶基准:0.05 起 5 次到 0.5)
2572
+ # recall_gain_above_base: 0.01 # 高于基准 0.5 时每次召回加多少(封顶上面反解出的 ceiling)
2304
2573
  # presence:
2305
2574
  # heartbeat_minutes: 60 # 常驻进程的心跳(活跃日时钟与生命周期扫描)
2306
2575
 
@@ -2403,16 +2672,60 @@ function zodShape(schema) {
2403
2672
  }
2404
2673
  }
2405
2674
  /**
2675
+ * The `trust.*` keys the v2 key replacement deleted (plan §4.1/§4.3), each with the sentence that
2676
+ * tells the operator what to write instead.
2677
+ *
2678
+ * They are still "unknown keys" — the config loader's existing channel — but the generic
2679
+ * `unknown config key(s) ignored: trust.decay_per_day` would silently change a lifetime the operator
2680
+ * had computed on purpose (`decay_per_day` → "how many days is my memory good for?" is exactly the
2681
+ * number that must not drift). Each message therefore names the replacement AND the conversion, computed
2682
+ * from the SHIPPED defaults (`base_lifetime_days: 90`, `decay_curvature: 0.05`) with the same
2683
+ * `trust_curve` functions the runtime uses.
2684
+ *
2685
+ * The default curve is read from `defaultConfig`, not written out as literals: if the shipped
2686
+ * anchor/curvature moves, these messages move with it instead of quoting stale numbers.
2687
+ */
2688
+ const trustKeyWarning = (() => {
2689
+ const shipped = curveOf(defaultConfig.trust);
2690
+ const asDays = (shape, ceiling) => {
2691
+ const days = retentionDaysForCeiling(ceiling, {
2692
+ ...shipped,
2693
+ shape
2694
+ });
2695
+ return Number.isFinite(days) ? String(Math.round(days)) : "?";
2696
+ };
2697
+ return {
2698
+ start: () => "trust.start is gone (v2): 起点冻结为 0.5(它与锚点 base_lifetime_days、召回基准是同一个数,不再可配,D20)。这一行删掉即可。",
2699
+ decay_per_day: () => "trust.decay_per_day is gone (v2): 衰减形状由 trust.decay_shape 决定,\"能记多久\"用 trust.base_lifetime_days 与 trust.max_retention_days 表达。默认配置下一条 0.5 起点的事实仍然是 90 个活跃日归零——如果你的旧值就是为了这个,删掉即可。",
2700
+ recall_floor: () => "trust.recall_floor is gone (v2): 基准 0.5 冻结;\"低于基准先爬回基准\"由 trust.recall_gain_below_base 表达(默认 0.1 ⇒ 0.05 起五次召回回到 0.5,不是一次)。这一行删掉即可。",
2701
+ recall_delta: () => "trust.recall_delta is gone (v2): 召回加强改成分段增益——低于基准用 trust.recall_gain_below_base(默认 0.1,封顶基准)、高于基准用 trust.recall_gain_above_base(默认 0.01,仍乘 trust.recall_marginal_decay);两者都不再等于旧的 0.03 一档。",
2702
+ recall_ceiling: (value) => {
2703
+ const ceiling = Number(value);
2704
+ return `trust.recall_ceiling is gone (v2): 召回上限现在用"寿命"表达。你写的 ${String(value)} 等价于 trust.max_retention_days: ${asDays("linear", ceiling)}(decay_shape: linear) / ${asDays("accelerating", ceiling)}(decay_shape: accelerating,默认 a=0.05);ceiling 由 max_retention_days 反解,不再可配。`;
2705
+ }
2706
+ };
2707
+ })();
2708
+ /** `trust.<key>` → the version-specific message, or `undefined` for a genuine typo. */
2709
+ function deprecatedTrustKey(key, value) {
2710
+ if (!key.startsWith("trust.")) return void 0;
2711
+ const explain = trustKeyWarning[key.slice(6)];
2712
+ return explain === void 0 ? void 0 : explain(value);
2713
+ }
2714
+ /**
2406
2715
  * Warn once about unknown config keys — at the ROOT level and inside known sections
2407
2716
  * (TRUST_MODEL.md §7 / review R15+R23). This is generic typo protection, not legacy
2408
2717
  * compatibility: `vector_store` or `semantics` are exactly the mistakes it catches.
2409
2718
  *
2719
+ * A DELETED key earns its own sentence ({@link trustKeyWarning}): it is not a typo, and the operator
2720
+ * needs the replacement + the conversion, not "check the spelling".
2721
+ *
2410
2722
  * Goes to the injected logger, not the process-wide default: this is the one message
2411
2723
  * that tells an operator their config file is being partly ignored, and the DSH host
2412
2724
  * only surfaces what the plugin's logger carries.
2413
2725
  */
2414
2726
  function warnUnknownKeys(source, raw, rootShape, logger) {
2415
2727
  const unknown = [];
2728
+ const explained = [];
2416
2729
  for (const [key, value] of Object.entries(raw)) {
2417
2730
  const section = rootShape[key];
2418
2731
  if (section === void 0) {
@@ -2421,8 +2734,15 @@ function warnUnknownKeys(source, raw, rootShape, logger) {
2421
2734
  }
2422
2735
  const childShape = zodShape(section);
2423
2736
  if (childShape === void 0 || !isPlainObject(value)) continue;
2424
- for (const childKey of Object.keys(value)) if (childShape[childKey] === void 0) unknown.push(`${key}.${childKey}`);
2737
+ for (const childKey of Object.keys(value)) {
2738
+ const full = `${key}.${childKey}`;
2739
+ if (childShape[childKey] !== void 0) continue;
2740
+ const message = deprecatedTrustKey(full, value[childKey]);
2741
+ if (message === void 0) unknown.push(full);
2742
+ else explained.push(message);
2743
+ }
2425
2744
  }
2745
+ for (const message of explained) logger.warn(`${source}: ${message}`);
2426
2746
  if (unknown.length > 0) logger.warn(`${source}: unknown config key(s) ignored: ${unknown.join(", ")} — check the spelling against docs/DESIGN.md §3`);
2427
2747
  }
2428
2748
  /** Section-aware merge: top-level sections merge one level deep, scalars/arrays replace. */
@@ -3503,7 +3823,7 @@ async function runToolSpec$1(rt, spec, args) {
3503
3823
  //#region ../core/lib/trustHeartbeat.js
3504
3824
  /**
3505
3825
  * The trust heartbeat: the interval beat that advances the active-day clock and performs the
3506
- * settle / forget / idle / purge sweeps (TRUST_MODEL.md §5), shared by the DSH plugin and the MCP
3826
+ * settle / forget / purge sweeps (TRUST_MODEL.md §5), shared by the DSH plugin and the MCP
3507
3827
  * server.
3508
3828
  *
3509
3829
  * The two surfaces used to install it separately, and the copies drifted in exactly the places a
@@ -5492,12 +5812,18 @@ const DDL = [
5492
5812
  conflict_checked INTEGER NOT NULL DEFAULT 0,
5493
5813
  archived_at TIMESTAMP,
5494
5814
  archive_reason TEXT,
5815
+ -- DEPRECATED (v2, D18): the per-fact TTL is gone. The column is kept so export / import /
5816
+ -- snapshot formats stay readable and a rollback still finds its data, but nothing reads or
5817
+ -- writes it any more (new rows keep the DEFAULT 0).
5495
5818
  ttl_days INTEGER DEFAULT 0,
5496
5819
  -- P-07/P-13: when the fact became true and when it stopped being true. NULL = unknown, and no
5497
- -- retrieval leg reads either column (the envelope only carries them, and only when non-null).
5498
- -- valid_to is written by mem_remember valid_until WITHOUT archiving the row, which is what
5499
- -- makes "still active, known to end at T" representable at all; the two archival paths
5500
- -- (supersede, contradiction verdict) also stamp it for audit.
5820
+ -- retrieval leg reads either column (the envelope only carries valid_from now, and only when
5821
+ -- non-null). valid_from is the caller's event time (event_date, the event filter axis).
5822
+ -- DEPRECATED (v2, D19): valid_to is no longer written (valid_until is gone, and so is the
5823
+ -- archival audit stamp). UNLIKE ttl_days it does NOT round-trip: export writes valid_to: null
5824
+ -- and restore ignores the field, deliberately, so an old "known end" cannot be resurrected as a
5825
+ -- validity window. The column survives only so old exports/files stay readable and the DDL does
5826
+ -- not churn; do not read a rollback guarantee into it.
5501
5827
  valid_from TIMESTAMP,
5502
5828
  valid_to TIMESTAMP,
5503
5829
  -- The INSTANTS those two bounds name, in epoch milliseconds (migration 12).
@@ -5524,10 +5850,8 @@ const DDL = [
5524
5850
  `CREATE INDEX IF NOT EXISTS idx_facts_supersedes ON facts(supersedes_id)`,
5525
5851
  `CREATE INDEX IF NOT EXISTS idx_facts_trust ON facts(status, pinned, settle_clock)`,
5526
5852
  `CREATE INDEX IF NOT EXISTS idx_facts_updated ON facts(status, updated_at)`,
5527
- `CREATE INDEX IF NOT EXISTS idx_facts_ttl ON facts(status, ttl_days)`,
5528
5853
  `CREATE INDEX IF NOT EXISTS idx_facts_purge ON facts(status, pinned, archived_clock)`,
5529
5854
  `CREATE INDEX IF NOT EXISTS idx_facts_forget ON facts(status, pinned, trust_score)`,
5530
- `CREATE INDEX IF NOT EXISTS idx_facts_idle_cutoff ON facts(status, pinned, julianday(COALESCE(last_retrieved_at, created_at)))`,
5531
5855
  `CREATE INDEX IF NOT EXISTS idx_facts_bonus_window ON facts(bonus_window_at)`,
5532
5856
  `CREATE TABLE IF NOT EXISTS fact_sources (
5533
5857
  fact_id INTEGER NOT NULL REFERENCES facts(fact_id) ON DELETE CASCADE,
@@ -7868,7 +8192,7 @@ function timeParams(range) {
7868
8192
  return timeSql(range)?.params ?? [];
7869
8193
  }
7870
8194
  /**
7871
- * One stored `valid_from`/`valid_to` string → epoch ms, using the SAME grammar as the filters.
8195
+ * One stored event-time (`valid_from`) string → epoch ms, using the SAME grammar as the filters.
7872
8196
  *
7873
8197
  * This is what the write path stamps into `valid_from_ms` and what migration 12's backfill applies to
7874
8198
  * existing rows, so "the event time a caller wrote" and "the event time a window compares" can never
@@ -8762,6 +9086,14 @@ const MEMORY_SCHEMA = {
8762
9086
  db.exec(TITLE_ALIASES_TABLE_DDL);
8763
9087
  for (const sql of TITLE_ALIASES_INDEXES) db.exec(sql);
8764
9088
  }
9089
+ },
9090
+ {
9091
+ version: 18,
9092
+ name: "retire-ttl-and-valid-to",
9093
+ up: (db) => {
9094
+ db.exec("DROP INDEX IF EXISTS idx_facts_idle_cutoff");
9095
+ db.exec("DROP INDEX IF EXISTS idx_facts_ttl");
9096
+ }
8765
9097
  }
8766
9098
  ]
8767
9099
  };
@@ -9014,6 +9346,102 @@ function entityBags(db, ids, q) {
9014
9346
  return out;
9015
9347
  }
9016
9348
  //#endregion
9349
+ //#region ../core/lib/lifecycle/trust.js
9350
+ const DAY_MS$1 = 864e5;
9351
+ /** The reinforcement quota window is a CALENDAR day ("每天" is a calendar notion, §2.3). */
9352
+ const QUOTA_WINDOW_MS = DAY_MS$1;
9353
+ function clamp01(v) {
9354
+ if (!Number.isFinite(v)) return 0;
9355
+ return Math.min(1, Math.max(0, v));
9356
+ }
9357
+ /** The curve of one `trust` section — the single mapping from config keys to the math. */
9358
+ function trustCurveOf(cfg) {
9359
+ return curveOf(cfg);
9360
+ }
9361
+ function isPinned(row) {
9362
+ return Number(row.pinned) === 1;
9363
+ }
9364
+ /** eff(f) — the trust a caller should act on right now (spec §2.2). */
9365
+ function effectiveTrust(row, clock, cfg, enabled = cfg.enabled) {
9366
+ if (!enabled) return clamp01(row.trust_score);
9367
+ if (isPinned(row)) return 1;
9368
+ return clamp01(trustAfterDays(row.trust_score, clock - row.settle_clock, trustCurveOf(cfg)));
9369
+ }
9370
+ /**
9371
+ * What a caller should SEE for a row — the R11 display rule (spec §6):
9372
+ * `active` → `eff()`; **non-active → the stored value verbatim** (the decay
9373
+ * formula keeps running mathematically for archived rows, so the projection must
9374
+ * not use it), and `enabled=false` → stored value (D12).
9375
+ */
9376
+ function displayTrust(row, clock, cfg, enabled = cfg.enabled) {
9377
+ if (row.status !== "active") return clamp01(row.trust_score);
9378
+ return effectiveTrust(row, clock, cfg, enabled);
9379
+ }
9380
+ /**
9381
+ * Remaining active days: the inverse of the curve at the current eff (`daysToZero`).
9382
+ * `null` for pinned rows, non-active rows and when trust is disabled — the contract's
9383
+ * `remaining_days` (spec §6 / R11).
9384
+ */
9385
+ function remainingDays(row, clock, cfg, enabled = cfg.enabled) {
9386
+ if (row.status !== "active" || isPinned(row) || !enabled) return null;
9387
+ return daysToZero(effectiveTrust(row, clock, cfg, enabled), trustCurveOf(cfg));
9388
+ }
9389
+ function rollWindow(row, nowMs) {
9390
+ const at = row.bonus_window_at;
9391
+ const started = parseUtcTs(at);
9392
+ if (at === null || started === null || nowMs - started >= QUOTA_WINDOW_MS) return {
9393
+ count: 0,
9394
+ at: formatUtcTs(nowMs),
9395
+ reset: true
9396
+ };
9397
+ return {
9398
+ count: row.bonus_count ?? 0,
9399
+ at,
9400
+ reset: false
9401
+ };
9402
+ }
9403
+ /**
9404
+ * Apply one recall to a fact: settle, then grant a bonus inside the daily quota.
9405
+ * Returns `next === settled` when the quota is exhausted, the ceiling is reached,
9406
+ * the fact is pinned, or the gain would be zero (R2/R10/D11).
9407
+ *
9408
+ * The gain is SEGMENTED at the frozen anchor `BASE_TRUST` (D20):
9409
+ * - strictly below the base: `min(BASE_TRUST, T + recall_gain_below_base)`, so the shipped
9410
+ * `recall_gain_below_base: 0.1` takes 0.05 → 0.5 in five recalls and never past the base;
9411
+ * - AT OR ABOVE the base: `recall_gain_above_base × marginal_decay^count`, capped at the ceiling
9412
+ * INVERTED from `max_retention_days` (there is no `recall_ceiling` key any more, D16). The
9413
+ * boundary is strictly `<` on purpose: a fact sitting exactly on the base (a fresh one, on the
9414
+ * first recall after it has finished settling) takes the above-base branch and starts climbing.
9415
+ */
9416
+ function grantRecallBonus(row, clock, nowMs, cfg, enabled = cfg.enabled) {
9417
+ const settled = effectiveTrust(row, clock, cfg, enabled);
9418
+ const window = rollWindow(row, nowMs);
9419
+ if (row.status !== "active" || isPinned(row) || !enabled || window.count >= cfg.recall_daily_cap) return {
9420
+ settled,
9421
+ next: settled,
9422
+ granted: false,
9423
+ bonusCount: window.count,
9424
+ windowAt: window.at
9425
+ };
9426
+ const gain = cfg.recall_gain_above_base * Math.pow(cfg.recall_marginal_decay, window.count);
9427
+ const ceiling = ceilingForDays(cfg.max_retention_days, trustCurveOf(cfg));
9428
+ const next = settled < .5 ? Math.max(settled, Math.min(BASE_TRUST, settled + cfg.recall_gain_below_base)) : Math.max(settled, Math.min(ceiling, settled + gain));
9429
+ if (next <= settled + 1e-9) return {
9430
+ settled,
9431
+ next: settled,
9432
+ granted: false,
9433
+ bonusCount: window.count,
9434
+ windowAt: window.at
9435
+ };
9436
+ return {
9437
+ settled,
9438
+ next,
9439
+ granted: true,
9440
+ bonusCount: window.count + 1,
9441
+ windowAt: window.at
9442
+ };
9443
+ }
9444
+ //#endregion
9017
9445
  //#region ../core/lib/db/dao/facts.js
9018
9446
  /**
9019
9447
  * The column a pure time window orders by — the axis the caller filtered on.
@@ -9051,7 +9479,7 @@ const FACT_COLUMNS_NO_BLOB = `fact_id, content, kind, title, tags, trust_score,
9051
9479
  pinned, pinned_at, bonus_count, bonus_window_at, last_reinforced_at, archived_clock,
9052
9480
  retrieval_count, helpful_count, last_retrieved_at, embedding_model, vector_store, status,
9053
9481
  supersedes_id, entities_version, conflict_checked, archived_at, archive_reason, ttl_days,
9054
- valid_from, valid_to, valid_from_ms, valid_to_ms, assert_count, created_at, updated_at`;
9482
+ valid_from, valid_from_ms, assert_count, created_at, updated_at`;
9055
9483
  /**
9056
9484
  * The FTS leg's exact SQL, exported so the plan guard (`test/fts_plan.spec.ts`) and the bench
9057
9485
  * control group EXPLAIN the production text itself instead of a copy that can silently drift.
@@ -9369,12 +9797,11 @@ var FactsDao = class FactsDao {
9369
9797
  const out = /* @__PURE__ */ new Map();
9370
9798
  if (!ids.length) return out;
9371
9799
  const { placeholders, values } = inList(ids);
9372
- const rows = this.db.prepare(`SELECT fact_id, created_at, updated_at, valid_from, valid_to, title FROM facts WHERE fact_id IN (${placeholders})`).all(...values);
9800
+ const rows = this.db.prepare(`SELECT fact_id, created_at, updated_at, valid_from, title FROM facts WHERE fact_id IN (${placeholders})`).all(...values);
9373
9801
  for (const row of rows) out.set(row.fact_id, {
9374
9802
  created_at: row.created_at === null ? "" : String(row.created_at),
9375
9803
  updated_at: row.updated_at === null ? null : String(row.updated_at),
9376
9804
  valid_from: row.valid_from === null || row.valid_from === void 0 ? null : String(row.valid_from),
9377
- valid_to: row.valid_to === null || row.valid_to === void 0 ? null : String(row.valid_to),
9378
9805
  title: row.title === null || row.title === void 0 || row.title === "" ? null : String(row.title)
9379
9806
  });
9380
9807
  return out;
@@ -9414,31 +9841,33 @@ var FactsDao = class FactsDao {
9414
9841
  * `createdAt` is named EXPLICITLY rather than left to the column DEFAULT, because a
9415
9842
  * superseding revision must INHERIT the archived row's `created_at` (the memory's first
9416
9843
  * assertion date, distinct from when its text was rewritten). `updated_at` and
9417
- * `last_retrieved_at` are stamped `CURRENT_TIMESTAMP` in the same statement: a revision
9418
- * whose `created_at` is old must not be judged idle by the next tick, where "idle" reads
9419
- * `COALESCE(last_retrieved_at, created_at)`.
9844
+ * `last_retrieved_at` are stamped `CURRENT_TIMESTAMP` in the same statement, so a revision
9845
+ * carries a fresh "last used" observation like the row it replaces (that column no longer
9846
+ * feeds any decision — see TRUST_MODEL.md §2.6).
9847
+ *
9848
+ * `ttl_days` is NOT named: TTL is gone (v2), the column keeps its DEFAULT 0 and nothing
9849
+ * reads or writes it any more.
9420
9850
  */
9421
9851
  insertRevision(v) {
9422
9852
  return this.db.prepare(`INSERT OR IGNORE INTO facts
9423
- (content, kind, title, ttl_days, supersedes_id, hrr_vector,
9853
+ (content, kind, title, supersedes_id, hrr_vector,
9424
9854
  trust_score, settle_clock, pinned, pinned_at, bonus_count, bonus_window_at,
9425
- entities_version, conflict_checked, valid_from, valid_to, valid_from_ms, valid_to_ms,
9855
+ entities_version, conflict_checked, valid_from, valid_from_ms,
9426
9856
  created_at, updated_at, last_retrieved_at)
9427
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, ?, ?, ?, ?,
9428
- ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`).run(v.content, v.kind, v.title ?? null, v.ttlDays, v.supersedesId, v.hrr, v.trust, v.clock, v.pinned, v.pinnedAt, v.windowAt, 3, v.validFrom ?? null, v.validTo ?? null, v.validFromMs ?? null, v.validToMs ?? null, v.createdAt);
9857
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, ?, ?,
9858
+ ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`).run(v.content, v.kind, v.title ?? null, v.supersedesId, v.hrr, v.trust, v.clock, v.pinned, v.pinnedAt, v.windowAt, 3, v.validFrom ?? null, v.validFromMs ?? null, v.createdAt);
9429
9859
  }
9430
9860
  /**
9431
- * P-10: land an explicitly given validity on a row that already exists (a duplicate `add` whose
9432
- * caller named `event_date` / `valid_until`). Omitted arguments keep what the row has, exactly
9433
- * like `kind`/`ttl_days` (see `add`'s doc).
9861
+ * P-10: land an explicitly given event time on a row that already exists (a duplicate `add`
9862
+ * whose caller named `event_date`). An omitted argument keeps what the row has, exactly like
9863
+ * `kind` (see `add`'s doc).
9434
9864
  */
9435
- applyValidity(factId, validFrom, validTo) {
9436
- if (validFrom === void 0 && validTo === void 0) return;
9865
+ applyValidity(factId, validFrom) {
9866
+ if (validFrom === void 0) return;
9437
9867
  this.db.prepare(`UPDATE facts
9438
9868
  SET valid_from = COALESCE(?, valid_from), valid_from_ms = COALESCE(?, valid_from_ms),
9439
- valid_to = COALESCE(?, valid_to), valid_to_ms = COALESCE(?, valid_to_ms),
9440
9869
  updated_at = CURRENT_TIMESTAMP
9441
- WHERE fact_id = ?`).run(validFrom?.text ?? null, validFrom?.ms ?? null, validTo?.text ?? null, validTo?.ms ?? null, factId);
9870
+ WHERE fact_id = ?`).run(validFrom.text, validFrom.ms, factId);
9442
9871
  }
9443
9872
  /** P-10: the row's assertion counter (`1` when the column is somehow NULL on a legacy row). */
9444
9873
  assertCount(factId) {
@@ -9458,17 +9887,6 @@ var FactsDao = class FactsDao {
9458
9887
  return Number(row?.n ?? 1);
9459
9888
  }
9460
9889
  /**
9461
- * P-07 audit: stamp `valid_to` on a row that is leaving the active corpus (supersede / a
9462
- * contradiction verdict). `COALESCE` keeps an end the caller already declared (`valid_until`):
9463
- * overwriting it with the archival instant would replace a real answer with an administrative one.
9464
- */
9465
- markValidTo(factId, at, atMs = null) {
9466
- this.db.prepare(`UPDATE facts
9467
- SET valid_to = COALESCE(valid_to, ?),
9468
- valid_to_ms = CASE WHEN valid_to IS NULL THEN ? ELSE valid_to_ms END
9469
- WHERE fact_id = ?`).run(at, atMs, factId);
9470
- }
9471
- /**
9472
9890
  * P-07: "who superseded this fact", derived from the existing `supersedes_id` link — no
9473
9891
  * redundant column. `MIN` is deterministic when several revisions named the same predecessor
9474
9892
  * (which a merge can produce); the caller renders it as a single direct successor.
@@ -9629,17 +10047,22 @@ var FactsDao = class FactsDao {
9629
10047
  }
9630
10048
  return changes;
9631
10049
  }
9632
- /** Revive an archived row (R18/R19: refresh the idle clock, clear the archive bookkeeping). */
9633
- revive(factId, kind, ttlDays, trust, clock) {
10050
+ /**
10051
+ * Revive an archived row (R19: clear the archive bookkeeping / refresh `last_retrieved_at`).
10052
+ *
10053
+ * `ttl_days` is deliberately NOT named any more: an omitted column keeps whatever the row has
10054
+ * (the old writing value is preserved as data), and there is no TTL left to inherit.
10055
+ */
10056
+ revive(factId, kind, trust, clock) {
9634
10057
  return this.db.prepare(`UPDATE facts
9635
10058
  SET status = 'active', archived_at = NULL, archive_reason = NULL, archived_clock = NULL,
9636
- kind = ?, ttl_days = ?, trust_score = ?, settle_clock = ?,
10059
+ kind = ?, trust_score = ?, settle_clock = ?,
9637
10060
  last_retrieved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
9638
- WHERE fact_id = ? AND status = 'archived'`).run(kind, ttlDays, trust, clock, factId).changes;
10061
+ WHERE fact_id = ? AND status = 'archived'`).run(kind, trust, clock, factId).changes;
9639
10062
  }
9640
- /** Land explicit kind/TTL on a row that already exists (pure duplicate). */
9641
- applyValues(factId, kind, ttlDays) {
9642
- this.db.prepare("UPDATE facts SET kind = ?, ttl_days = ?, updated_at = CURRENT_TIMESTAMP WHERE fact_id = ?").run(kind, ttlDays, factId);
10063
+ /** Land an explicit kind on a row that already exists (pure duplicate). */
10064
+ applyValues(factId, kind) {
10065
+ this.db.prepare("UPDATE facts SET kind = ?, updated_at = CURRENT_TIMESTAMP WHERE fact_id = ?").run(kind, factId);
9643
10066
  }
9644
10067
  /**
9645
10068
  * Land a title on a row that already exists — the ONE write behind every "backfill" path
@@ -9716,13 +10139,12 @@ var FactsDao = class FactsDao {
9716
10139
  this.db.prepare("UPDATE facts SET supersedes_id = ? WHERE fact_id = ? AND supersedes_id IS NULL").run(supersedesId, factId);
9717
10140
  }
9718
10141
  /**
9719
- * Restore an archived fact, settling its trust and refreshing the idle clock (spec §2.6).
10142
+ * Restore an archived fact, settling its trust and refreshing `last_retrieved_at` (spec §2.6).
9720
10143
  *
9721
10144
  * `AND status = 'archived'` is load-bearing, like the guard on every sibling above: this UPDATE
9722
10145
  * clears the archive bookkeeping and refreshes `last_retrieved_at`, so ungated it would take an
9723
- * ACTIVE row and (a) push its idle clock forward — silently extending the life of a fact the next
9724
- * tick was about to re-archive — and (b) reset `trust_score` to the settled value it computed. It
9725
- * returned `1` either way, so the caller could not tell that a row it never asked about had moved.
10146
+ * ACTIVE row and reset `trust_score` to the settled value it computed. It returned `1` either
10147
+ * way, so the caller could not tell that a row it never asked about had moved.
9726
10148
  */
9727
10149
  restore(factId, trust, clock) {
9728
10150
  return this.db.prepare(`UPDATE facts
@@ -9730,32 +10152,6 @@ var FactsDao = class FactsDao {
9730
10152
  trust_score = ?, settle_clock = ?, last_retrieved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
9731
10153
  WHERE fact_id = ? AND status = 'archived'`).run(trust, clock, factId).changes;
9732
10154
  }
9733
- bumpHelpful(factId, delta) {
9734
- this.db.prepare("UPDATE facts SET helpful_count = MAX(0, helpful_count + ?) WHERE fact_id = ?").run(delta, factId);
9735
- }
9736
- /** Feedback outcome: pin at the permanent threshold (trust snaps to 1). */
9737
- pinOutcome(factId, clock, bonusCount, windowAt) {
9738
- this.db.prepare(`UPDATE facts
9739
- SET trust_score = 1, pinned = 1, pinned_at = COALESCE(pinned_at, CURRENT_TIMESTAMP),
9740
- settle_clock = ?, bonus_count = ?, bonus_window_at = ?, archived_clock = NULL,
9741
- last_reinforced_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
9742
- WHERE fact_id = ?`).run(clock, bonusCount, windowAt, factId);
9743
- }
9744
- /** Feedback outcome: the forget line was reached — archive in the same statement. */
9745
- forgetOutcome(factId, trust, clock, bonusCount, windowAt) {
9746
- this.db.prepare(`UPDATE facts
9747
- SET trust_score = ?, settle_clock = ?, bonus_count = ?, bonus_window_at = ?,
9748
- status = 'archived', archived_at = CURRENT_TIMESTAMP, archived_clock = ?,
9749
- archive_reason = 'forgot', updated_at = CURRENT_TIMESTAMP
9750
- WHERE fact_id = ?`).run(trust, clock, bonusCount, windowAt, clock, factId);
9751
- }
9752
- /** Feedback outcome: reinforced (or decayed) but still active. */
9753
- reinforceOutcome(factId, trust, clock, bonusCount, windowAt) {
9754
- this.db.prepare(`UPDATE facts
9755
- SET trust_score = ?, settle_clock = ?, bonus_count = ?, bonus_window_at = ?,
9756
- last_reinforced_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
9757
- WHERE fact_id = ?`).run(trust, clock, bonusCount, windowAt, factId);
9758
- }
9759
10155
  pin(factId, clock) {
9760
10156
  return this.db.prepare(`UPDATE facts
9761
10157
  SET pinned = 1, pinned_at = COALESCE(pinned_at, CURRENT_TIMESTAMP), trust_score = 1,
@@ -9844,17 +10240,25 @@ var FactsDao = class FactsDao {
9844
10240
  return Number(this.db.prepare("SELECT COUNT(*) AS n FROM facts WHERE status = 'active' AND pinned = 1").get()?.n ?? 0);
9845
10241
  }
9846
10242
  /**
9847
- * ACTIVE facts whose decayed trust would reach zero within `withinDays` ACTIVE days —
9848
- * i.e. `trust_score - step × (clock - settle_clock) <= step × withinDays`.
10243
+ * ACTIVE, unpinned facts whose decayed trust reaches zero within `withinDays` ACTIVE days.
10244
+ *
10245
+ * EXACT for every shape, not a linear approximation: the predicate is
10246
+ * `trustAfterDays(eff(now), withinDays, curve) <= 0`, which is the same question the tick asks
10247
+ * when it archives a row. The caller passes the THRESHOLD (`ceilingForDays(withinDays, curve)`)
10248
+ * because "days until zero" is no longer `trust ÷ slope` under `decay_shape: 'accelerating'`;
10249
+ * a slope-based SQL predicate under-reported every fact in `(slope × 7, ceilingForDays(7)]`
10250
+ * (0.0277 … 0.0515 at the shipped defaults) once accelerating became the default.
10251
+ *
10252
+ * COST: one row per active unpinned fact, evaluated in JS — the predicate needs `exp`, which the
10253
+ * SQL layer cannot express portably. `trust_diagnose` is an operator-only diagnostic and the
10254
+ * active set is what `mem_admin list` already walks; if it ever shows up in a profile, prefilter
10255
+ * with an index-friendly bound rather than going back to a slope approximation.
9849
10256
  */
9850
10257
  countForgettingWithin(p) {
9851
- return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM facts
9852
- WHERE status = 'active' AND pinned = 0
9853
- AND (trust_score - :step * (:clock - settle_clock)) <= :step * :soon`).get({
9854
- step: p.step,
9855
- clock: p.clock,
9856
- soon: p.withinDays
9857
- })?.n ?? 0);
10258
+ const rows = this.db.prepare(`SELECT trust_score, settle_clock FROM facts WHERE status = 'active' AND pinned = 0`).all();
10259
+ let n = 0;
10260
+ for (const row of rows) if (trustAfterDays(Number(row.trust_score), p.clock - Number(row.settle_clock), p.curve) <= p.threshold) n += 1;
10261
+ return n;
9858
10262
  }
9859
10263
  /** Facts with a still-live (calendar 24h) reinforcement window — the per-fact quota. */
9860
10264
  /**
@@ -9874,31 +10278,15 @@ var FactsDao = class FactsDao {
9874
10278
  granted: Number(row?.granted ?? 0)
9875
10279
  };
9876
10280
  }
9877
- /** ACTIVE, unpinned facts unused for `days` CALENDAR days (the idle-archival fallback). */
9878
- countIdleCandidates(days) {
9879
- return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM facts
9880
- WHERE status = 'active' AND pinned = 0
9881
- AND julianday(COALESCE(last_retrieved_at, created_at)) < julianday('now', :modifier)`).get({ modifier: `-${days} days` })?.n ?? 0);
9882
- }
9883
10281
  /**
9884
- * Rows still due for ②③④ after a budgeted tick — what the NEXT pass will archive.
10282
+ * Rows still due for ② after a budgeted tick — what the NEXT pass will archive.
9885
10283
  *
9886
- * One statement over the three predicates (they are alternatives, and the sets are disjoint:
9887
- * a row has either a TTL, a forgotten trust score, or an idle clock). Only called when a step
9888
- * actually filled its budget, so the common tick pays nothing for it.
10284
+ * The same predicate the archival statement runs (`FORGET_PREDICATE`), so "backlog" and "what
10285
+ * `archiveForgotten` will do next" cannot disagree. Only called when ② actually filled its
10286
+ * budget, so the common tick pays nothing for it.
9889
10287
  */
9890
10288
  countArchiveBacklog(p) {
9891
- const params = {
9892
- forgetThreshold: p.forgetThreshold,
9893
- idleModifier: `-${p.idleCalendarDays} days`
9894
- };
9895
- return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM (
9896
- SELECT fact_id FROM facts WHERE status = 'active' AND ttl_days > 0
9897
- AND julianday('now') - julianday(created_at) > ttl_days
9898
- UNION SELECT fact_id FROM facts WHERE status = 'active' AND pinned = 0 AND trust_score <= :forgetThreshold
9899
- UNION SELECT fact_id FROM facts WHERE status = 'active' AND pinned = 0
9900
- AND julianday(COALESCE(last_retrieved_at, created_at)) < julianday('now', :idleModifier)
9901
- )`).get(params)?.n ?? 0);
10289
+ return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM facts WHERE ${FactsDao.FORGET_PREDICATE}`).get({ forgetThreshold: p.forgetThreshold })?.n ?? 0);
9902
10290
  }
9903
10291
  /**
9904
10292
  * Drop unusable (stale-dim) vectors so a re-encode can replace them — and re-queue the same
@@ -10237,48 +10625,60 @@ var FactsDao = class FactsDao {
10237
10625
  * row, and `settle_clock < c` is a seek + range scan of exactly the pending rows.
10238
10626
  */
10239
10627
  static SETTLE_PREDICATE = "status = 'active' AND pinned = 0 AND settle_clock < CAST(:clock AS INTEGER)";
10240
- /** ① materialize decay, bounded to `budget` rows. */
10628
+ /**
10629
+ * The ONE forgetting predicate, shared by the archival statement and its backlog count.
10630
+ *
10631
+ * Kept as a constant for the same reason as {@link SETTLE_PREDICATE}: the count answers "how many
10632
+ * will the next pass archive", so it must be the same WHERE body the archival itself runs —
10633
+ * otherwise `archived_deferred` and the actual sweep would drift apart.
10634
+ */
10635
+ static FORGET_PREDICATE = "status = 'active' AND pinned = 0 AND trust_score <= :forgetThreshold";
10636
+ /**
10637
+ * ① materialize decay, bounded to `budget` rows.
10638
+ *
10639
+ * PER ROW, through `trustAfterDays` — not one UPDATE with an inline `trust_score − step·Δ`. The
10640
+ * formula lives in `contract/src/trust_curve.ts` and nowhere else; the `linear` branch of that
10641
+ * function is bit-identical to the statement this replaced (`T − days·slope`, the same two
10642
+ * operations in the same order), so P3's equivalent tier materializes exactly the same numbers.
10643
+ *
10644
+ * The SELECT is the old UPDATE's WHERE body with the old subquery's ORDER/LIMIT, so the row SET is
10645
+ * the same one the statement would have touched (and `settled` still counts rows matched, not rows
10646
+ * whose value happened to move).
10647
+ *
10648
+ * COST: one `UPDATE` PER ROW inside the tick's single `IMMEDIATE` transaction (the accelerating
10649
+ * formula needs `exp`, which SQL cannot express here, and a second SQL implementation would
10650
+ * drift). That is why every caller is budgeted by `trust.tick_max_facts` (5000 by default) and
10651
+ * `admin maintenance` is the only unbudgeted pass — measured ~22 ms per 5000 rows, inside the
10652
+ * tick's budget; it is a deliberate trade, not a regression.
10653
+ */
10241
10654
  settleBudgeted(p) {
10242
- return this.db.prepare(`UPDATE facts
10243
- SET trust_score = MAX(0, MIN(1, trust_score - :step * (:clock - settle_clock))),
10244
- settle_clock = :clock
10655
+ const rows = this.db.prepare(`SELECT fact_id, trust_score, settle_clock FROM facts
10245
10656
  WHERE ${FactsDao.SETTLE_PREDICATE}
10246
- AND fact_id IN (SELECT fact_id FROM facts WHERE ${FactsDao.SETTLE_PREDICATE} ORDER BY settle_clock ASC LIMIT :budget)`).run(p).changes;
10657
+ ORDER BY settle_clock ASC LIMIT :budget`).all({
10658
+ clock: p.clock,
10659
+ budget: p.budget
10660
+ });
10661
+ return this.settleRows(rows, p.clock, p.curve);
10247
10662
  }
10248
10663
  /** ① materialize decay for every pending row (no budget). */
10249
10664
  settleAll(p) {
10250
- return this.db.prepare(`UPDATE facts
10251
- SET trust_score = MAX(0, MIN(1, trust_score - :step * (:clock - settle_clock))),
10252
- settle_clock = :clock
10253
- WHERE ${FactsDao.SETTLE_PREDICATE}`).run(p).changes;
10665
+ const rows = this.db.prepare(`SELECT fact_id, trust_score, settle_clock FROM facts
10666
+ WHERE ${FactsDao.SETTLE_PREDICATE}
10667
+ ORDER BY settle_clock ASC`).all({ clock: p.clock });
10668
+ return this.settleRows(rows, p.clock, p.curve);
10669
+ }
10670
+ /** The shared per-row write of ①: settle the curve at `clock`, then stamp `settle_clock`. */
10671
+ settleRows(rows, clock, curve) {
10672
+ const stmt = this.db.prepare("UPDATE facts SET trust_score = ?, settle_clock = ? WHERE fact_id = ?");
10673
+ for (const row of rows) stmt.run(clamp01(trustAfterDays(Number(row.trust_score), clock - Number(row.settle_clock), curve)), clock, row.fact_id);
10674
+ return rows.length;
10254
10675
  }
10255
10676
  /** Rows still awaiting ① (the budget ran out). */
10256
10677
  countPendingSettle(p) {
10257
10678
  return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM facts WHERE ${FactsDao.SETTLE_PREDICATE}`).get(p)?.n ?? 0);
10258
10679
  }
10259
10680
  /**
10260
- * ② calendar TTL — explicit instruction, so `pinned` is NOT exempt.
10261
- *
10262
- * `budget` (0 = none) caps how many rows ONE tick may archive: the tick is a single IMMEDIATE
10263
- * transaction shared by several processes, so an unbudgeted pass over a large backlog held the
10264
- * write lock for as long as it took (measured 14400 rows in one tick). The remainder stays due
10265
- * and is reported as a deferred count, then archived by the next tick.
10266
- */
10267
- archiveExpiredByTtl(p) {
10268
- return this.db.prepare(`UPDATE facts
10269
- SET status = 'archived', archived_at = CURRENT_TIMESTAMP, archived_clock = :clock, archive_reason = 'ttl'
10270
- WHERE status = 'active' AND ttl_days > 0
10271
- AND julianday('now') - julianday(created_at) > ttl_days
10272
- AND (:budget = 0 OR fact_id IN (
10273
- SELECT fact_id FROM facts WHERE status = 'active' AND ttl_days > 0
10274
- AND julianday('now') - julianday(created_at) > ttl_days LIMIT :budget))
10275
- RETURNING fact_id`).all({
10276
- clock: p.clock,
10277
- budget: p.budget ?? 0
10278
- }).map((row) => row.fact_id);
10279
- }
10280
- /**
10281
- * ③ trust-driven forgetting (① already clamped trust to the threshold).
10681
+ * ② trust-driven forgetting (① already clamped trust to the threshold).
10282
10682
  *
10283
10683
  * Sargable as written (`status`/`pinned` equality + `trust_score <= ?`), and served by
10284
10684
  * `idx_facts_forget(status, pinned, trust_score)`: before that index this statement read every
@@ -10287,9 +10687,9 @@ var FactsDao = class FactsDao {
10287
10687
  archiveForgotten(p) {
10288
10688
  return this.db.prepare(`UPDATE facts
10289
10689
  SET status = 'archived', archived_at = CURRENT_TIMESTAMP, archived_clock = :clock, archive_reason = 'forgot'
10290
- WHERE status = 'active' AND pinned = 0 AND trust_score <= :forgetThreshold
10690
+ WHERE ${FactsDao.FORGET_PREDICATE}
10291
10691
  AND (:budget = 0 OR fact_id IN (
10292
- SELECT fact_id FROM facts WHERE status = 'active' AND pinned = 0 AND trust_score <= :forgetThreshold LIMIT :budget))
10692
+ SELECT fact_id FROM facts WHERE ${FactsDao.FORGET_PREDICATE} LIMIT :budget))
10293
10693
  RETURNING fact_id`).all({
10294
10694
  clock: p.clock,
10295
10695
  forgetThreshold: p.forgetThreshold,
@@ -10297,29 +10697,7 @@ var FactsDao = class FactsDao {
10297
10697
  }).map((row) => row.fact_id);
10298
10698
  }
10299
10699
  /**
10300
- * ④ calendar fallback: never touched for `idle_calendar_days`.
10301
- *
10302
- * The comparison is written as `<expression> < julianday('now', :modifier)` — the same
10303
- * expression `idx_facts_idle_cutoff` indexes — so it is a range seek. The old form
10304
- * (`julianday('now') - <expression> > :days`) called `julianday` on EVERY active row and could
10305
- * not use any index (measured 33–212 ms at 180k).
10306
- */
10307
- archiveIdle(p) {
10308
- return this.db.prepare(`UPDATE facts
10309
- SET status = 'archived', archived_at = CURRENT_TIMESTAMP, archived_clock = :clock, archive_reason = 'idle'
10310
- WHERE status = 'active' AND pinned = 0
10311
- AND julianday(COALESCE(last_retrieved_at, created_at)) < julianday('now', :idleModifier)
10312
- AND (:budget = 0 OR fact_id IN (
10313
- SELECT fact_id FROM facts WHERE status = 'active' AND pinned = 0
10314
- AND julianday(COALESCE(last_retrieved_at, created_at)) < julianday('now', :idleModifier) LIMIT :budget))
10315
- RETURNING fact_id`).all({
10316
- clock: p.clock,
10317
- idleModifier: `-${p.idleCalendarDays} days`,
10318
- budget: p.budget ?? 0
10319
- }).map((row) => row.fact_id);
10320
- }
10321
- /**
10322
- * ⑤ physical delete of archived rows past the retention window.
10700
+ * ③ physical delete of archived rows past the retention window.
10323
10701
  *
10324
10702
  * TWO statements in this order, sharing one predicate:
10325
10703
  *
@@ -10328,7 +10706,7 @@ var FactsDao = class FactsDao {
10328
10706
  * DELETE below failed with `SQLITE_CONSTRAINT_FOREIGNKEY` whenever a revision chain
10329
10707
  * crossed `purge_after_archived_days`. Because the whole tick is ONE transaction and it
10330
10708
  * runs from the store's constructor, that failure did not merely skip the purge: it rolled
10331
- * back settle/TTL/idle with it and left the process unable to start, permanently.
10709
+ * back settle/forget with it and left the process unable to start, permanently.
10332
10710
  * 2. delete them.
10333
10711
  *
10334
10712
  * The predicate is built once and interpolated into both statements, so "which rows are
@@ -10373,7 +10751,7 @@ var FactsDao = class FactsDao {
10373
10751
  purgeModifier: `-${p.purgeAfterDays} days`
10374
10752
  };
10375
10753
  }
10376
- /** Archived rows still due for ⑤ after a budgeted pass (0 when the pass finished them). */
10754
+ /** Archived rows still due for ③ after a budgeted pass (0 when the pass finished them). */
10377
10755
  countPurgeBacklog(p) {
10378
10756
  const [a, b] = FactsDao.purgeBranches(p);
10379
10757
  return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM (
@@ -11470,156 +11848,6 @@ var FactSourcesDao = class {
11470
11848
  }
11471
11849
  };
11472
11850
  //#endregion
11473
- //#region ../core/lib/lifecycle/trust.js
11474
- const DAY_MS$1 = 864e5;
11475
- /** The reinforcement quota window is a CALENDAR day ("每天" is a calendar notion, §2.3). */
11476
- const QUOTA_WINDOW_MS = DAY_MS$1;
11477
- function clamp01(v) {
11478
- if (!Number.isFinite(v)) return 0;
11479
- return Math.min(1, Math.max(0, v));
11480
- }
11481
- function isPinned(row) {
11482
- return Number(row.pinned) === 1;
11483
- }
11484
- /**
11485
- * `'YYYY-MM-DD HH:MM:SS'` (SQLite UTC) or ISO → epoch ms; null/garbage → null.
11486
- *
11487
- * A stamp with NO zone designator is read as UTC, not as local time. `Date.parse` treats a zone-less
11488
- * ISO string as LOCAL, and everything in this store is written in UTC (`CURRENT_TIMESTAMP`, and
11489
- * {@link formatUtcTs}), so handing one over as-is would shift every quota window and every decay
11490
- * measurement by the host's UTC offset — a fact reinforced at 23:00 UTC+8 would look reinforced 8
11491
- * hours earlier, and the 24h cap would open or close at the wrong moment. An explicit `Z` or
11492
- * `±HH:MM` is left alone.
11493
- */
11494
- function parseUtcTs(ts) {
11495
- if (ts === null || ts === void 0 || ts === "") return null;
11496
- const iso = ts.includes("T") ? ts : ts.replace(" ", "T");
11497
- const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(iso) ? iso : `${iso}Z`;
11498
- const ms = Date.parse(zoned);
11499
- return Number.isFinite(ms) ? ms : null;
11500
- }
11501
- /** Epoch ms → `'YYYY-MM-DD HH:MM:SS'` (the SQLite `CURRENT_TIMESTAMP` shape, UTC). */
11502
- function formatUtcTs(ms) {
11503
- return new Date(ms).toISOString().slice(0, 19).replace("T", " ");
11504
- }
11505
- /** eff(f) — the trust a caller should act on right now (spec §2.2). */
11506
- function effectiveTrust(row, clock, cfg, enabled = cfg.enabled) {
11507
- if (!enabled) return clamp01(row.trust_score);
11508
- if (isPinned(row)) return 1;
11509
- return clamp01(row.trust_score - cfg.decay_per_day * (clock - row.settle_clock));
11510
- }
11511
- /**
11512
- * What a caller should SEE for a row — the R11 display rule (spec §6):
11513
- * `active` → `eff()`; **non-active → the stored value verbatim** (the decay
11514
- * formula keeps running mathematically for archived rows, so the projection must
11515
- * not use it), and `enabled=false` → stored value (D12).
11516
- */
11517
- function displayTrust(row, clock, cfg, enabled = cfg.enabled) {
11518
- if (row.status !== "active") return clamp01(row.trust_score);
11519
- return effectiveTrust(row, clock, cfg, enabled);
11520
- }
11521
- /**
11522
- * Remaining active days: `eff / step`. `null` for pinned rows, non-active rows and
11523
- * when trust is disabled — the contract's `remaining_days` (spec §6 / R11).
11524
- */
11525
- function remainingDays(row, clock, cfg, enabled = cfg.enabled) {
11526
- if (row.status !== "active" || isPinned(row) || !enabled || cfg.decay_per_day <= 0) return null;
11527
- return effectiveTrust(row, clock, cfg, enabled) / cfg.decay_per_day;
11528
- }
11529
- function rollWindow(row, nowMs) {
11530
- const at = row.bonus_window_at;
11531
- const started = parseUtcTs(at);
11532
- if (at === null || started === null || nowMs - started >= QUOTA_WINDOW_MS) return {
11533
- count: 0,
11534
- at: formatUtcTs(nowMs),
11535
- reset: true
11536
- };
11537
- return {
11538
- count: row.bonus_count ?? 0,
11539
- at,
11540
- reset: false
11541
- };
11542
- }
11543
- /**
11544
- * Apply one recall to a fact: settle, then grant a bonus inside the daily quota.
11545
- * Returns `next === settled` when the quota is exhausted, the ceiling is reached,
11546
- * the fact is pinned, or the gain would be zero (R2/R10/D11).
11547
- */
11548
- function grantRecallBonus(row, clock, nowMs, cfg, enabled = cfg.enabled) {
11549
- const settled = effectiveTrust(row, clock, cfg, enabled);
11550
- const window = rollWindow(row, nowMs);
11551
- if (row.status !== "active" || isPinned(row) || !enabled || window.count >= cfg.recall_daily_cap) return {
11552
- settled,
11553
- next: settled,
11554
- granted: false,
11555
- bonusCount: window.count,
11556
- windowAt: window.at
11557
- };
11558
- const gain = cfg.recall_delta * Math.pow(cfg.recall_marginal_decay, window.count);
11559
- const next = settled <= cfg.recall_floor ? Math.max(settled, cfg.recall_floor) : Math.max(settled, Math.min(cfg.recall_ceiling, settled + gain));
11560
- if (next <= settled + 1e-9) return {
11561
- settled,
11562
- next: settled,
11563
- granted: false,
11564
- bonusCount: window.count,
11565
- windowAt: window.at
11566
- };
11567
- return {
11568
- settled,
11569
- next,
11570
- granted: true,
11571
- bonusCount: window.count + 1,
11572
- windowAt: window.at
11573
- };
11574
- }
11575
- /**
11576
- * Apply one explicit `helpful` (+1) / `unhelpful` (-1) step.
11577
- *
11578
- * Pinned and archived rows are untouched (R7): only `helpful_count` changes for
11579
- * them, so a pinned fact can never end up storing 0.95 while displaying 1.0.
11580
- * When `feedback_daily_cap > 0` feedback shares the per-fact 24h window counter.
11581
- */
11582
- function applyFeedbackDelta(row, clock, delta, nowMs, cfg, enabled = cfg.enabled) {
11583
- const settled = effectiveTrust(row, clock, cfg, enabled);
11584
- const shared = cfg.feedback_daily_cap > 0;
11585
- const window = shared ? rollWindow(row, nowMs) : {
11586
- count: row.bonus_count ?? 0,
11587
- at: row.bonus_window_at ?? formatUtcTs(nowMs),
11588
- reset: false
11589
- };
11590
- if (row.status !== "active" || isPinned(row) || !enabled) return {
11591
- settled,
11592
- next: row.trust_score,
11593
- pin: false,
11594
- forget: false,
11595
- untouched: true,
11596
- bonusCount: window.count,
11597
- windowAt: window.at
11598
- };
11599
- if (shared && window.count >= cfg.feedback_daily_cap) return {
11600
- settled,
11601
- next: settled,
11602
- pin: false,
11603
- forget: false,
11604
- untouched: false,
11605
- bonusCount: window.count,
11606
- windowAt: window.at
11607
- };
11608
- let next = clamp01(settled + delta * cfg.feedback_delta);
11609
- const pin = next >= cfg.permanent_threshold;
11610
- const forget = !pin && next <= cfg.forget_threshold + 1e-9;
11611
- if (forget) next = cfg.forget_threshold;
11612
- return {
11613
- settled,
11614
- next,
11615
- pin,
11616
- forget,
11617
- untouched: false,
11618
- bonusCount: shared ? window.count + 1 : window.count,
11619
- windowAt: window.at
11620
- };
11621
- }
11622
- //#endregion
11623
11851
  //#region ../core/lib/lifecycle/presence.js
11624
11852
  const CLOCK_KEY = "trust_clock";
11625
11853
  const LAST_SEEN_KEY = "trust_last_seen";
@@ -11667,39 +11895,27 @@ function advancePresence(db, opts) {
11667
11895
  function runTrustTick(db, cfg, opts) {
11668
11896
  const clock = opts.clock;
11669
11897
  const budget = opts.budget ?? cfg.trust.tick_max_facts;
11670
- const step = cfg.trust.decay_per_day;
11898
+ const curve = trustCurveOf(cfg.trust);
11671
11899
  const enabled = cfg.trust.enabled ? 1 : 0;
11672
11900
  const trustEnabled = cfg.trust.enabled;
11673
11901
  const facts = new FactsDao(db);
11674
11902
  return db.transaction(() => {
11675
11903
  const settled = !trustEnabled ? 0 : budget > 0 ? facts.settleBudgeted({
11676
- step,
11904
+ curve,
11677
11905
  clock,
11678
11906
  budget
11679
11907
  }) : facts.settleAll({
11680
- step,
11908
+ curve,
11681
11909
  clock
11682
11910
  });
11683
11911
  const skipped = trustEnabled && budget > 0 ? facts.countPendingSettle({ clock }) : 0;
11684
11912
  const archiveBudget = budget > 0 ? budget : 0;
11685
- const ttlIds = facts.archiveExpiredByTtl({
11686
- clock,
11687
- budget: archiveBudget
11688
- });
11689
11913
  const forgotIds = trustEnabled ? facts.archiveForgotten({
11690
11914
  clock,
11691
11915
  forgetThreshold: cfg.trust.forget_threshold,
11692
11916
  budget: archiveBudget
11693
11917
  }) : [];
11694
- const idleIds = facts.archiveIdle({
11695
- clock,
11696
- idleCalendarDays: cfg.trust.idle_calendar_days,
11697
- budget: archiveBudget
11698
- });
11699
- const deferred = archiveBudget > 0 && (ttlIds.length === archiveBudget || forgotIds.length === archiveBudget || idleIds.length === archiveBudget) ? facts.countArchiveBacklog({
11700
- forgetThreshold: cfg.trust.forget_threshold,
11701
- idleCalendarDays: cfg.trust.idle_calendar_days
11702
- }) : 0;
11918
+ const deferred = archiveBudget > 0 && forgotIds.length === archiveBudget ? facts.countArchiveBacklog({ forgetThreshold: cfg.trust.forget_threshold }) : 0;
11703
11919
  const purgeArgs = {
11704
11920
  clock,
11705
11921
  enabled,
@@ -11714,16 +11930,10 @@ function runTrustTick(db, cfg, opts) {
11714
11930
  return {
11715
11931
  clock,
11716
11932
  settled,
11717
- archived_ttl: ttlIds.length,
11718
11933
  archived_forgot: forgotIds.length,
11719
- archived_idle: idleIds.length,
11720
11934
  purged: purgedIds.length,
11721
11935
  purged_ids: purgedIds,
11722
- archived_ids: [
11723
- ...ttlIds,
11724
- ...forgotIds,
11725
- ...idleIds
11726
- ],
11936
+ archived_ids: [...forgotIds],
11727
11937
  skipped,
11728
11938
  archived_deferred: deferred,
11729
11939
  purged_deferred: purgedDeferred
@@ -11747,15 +11957,15 @@ function runMaintenance(db, cfg, opts) {
11747
11957
  });
11748
11958
  return {
11749
11959
  decayed: tick.settled,
11750
- archived_ttl: tick.archived_ttl,
11751
- archived_age: tick.archived_forgot + tick.archived_idle,
11960
+ archived_ttl: 0,
11961
+ archived_age: tick.archived_forgot,
11752
11962
  purged: tick.purged,
11753
11963
  purged_ids: tick.purged_ids,
11754
11964
  archived_ids: tick.archived_ids,
11755
11965
  settled: tick.settled,
11756
11966
  clock: tick.clock,
11757
11967
  archived_forgot: tick.archived_forgot,
11758
- archived_idle: tick.archived_idle,
11968
+ archived_idle: 0,
11759
11969
  skipped: tick.skipped,
11760
11970
  archived_deferred: tick.archived_deferred,
11761
11971
  purged_deferred: tick.purged_deferred
@@ -12154,7 +12364,6 @@ const RESTORE_METADATA_FIELDS = [
12154
12364
  "last_reinforced_at",
12155
12365
  "ttl_days",
12156
12366
  "valid_from",
12157
- "valid_to",
12158
12367
  "supersedes_source_id",
12159
12368
  "archive",
12160
12369
  "sources",
@@ -12300,9 +12509,7 @@ function rowOf(raw, metadataMissing, nowSql) {
12300
12509
  lastRetrievedAt: textOrNull$1(row["last_retrieved_at"]),
12301
12510
  ttlDays: Math.max(0, integerOrNull(row["ttl_days"]) ?? 0),
12302
12511
  validFrom: textOrNull$1(row["valid_from"]),
12303
- validTo: textOrNull$1(row["valid_to"]),
12304
12512
  validFromMs: eventTimeMs(textOrNull$1(row["valid_from"])) ?? null,
12305
- validToMs: eventTimeMs(textOrNull$1(row["valid_to"])) ?? null,
12306
12513
  supersedesId: integerOrNull(row["supersedes_source_id"]),
12307
12514
  mirrorSource: textOrNull$1(row["mirror_source"]),
12308
12515
  mirrorTarget: textOrNull$1(row["mirror_target"]),
@@ -12345,10 +12552,11 @@ const INSERT_FACT_SQL = `INSERT INTO facts (
12345
12552
  fact_id, content, kind, title, legacy_category, status, archived_at, archive_reason,
12346
12553
  created_at, updated_at, trust_score, settle_clock, assert_count, helpful_count,
12347
12554
  pinned, pinned_at, last_reinforced_at, bonus_count, bonus_window_at,
12348
- retrieval_count, last_retrieved_at, ttl_days, valid_from, valid_to, valid_from_ms, valid_to_ms,
12349
- supersedes_id, mirror_source, mirror_target,
12555
+ retrieval_count, last_retrieved_at, ttl_days, valid_from, valid_from_ms,
12556
+ valid_to, valid_to_ms, supersedes_id, mirror_source, mirror_target,
12350
12557
  hrr_vector, semantic_vector, embedding_model, entities_version, conflict_checked
12351
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, 0, 1)`;
12558
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?,
12559
+ NULL, NULL, NULL, 0, 1)`;
12352
12560
  function countOf(db, sql) {
12353
12561
  return Number(db.prepare(sql).get()?.n ?? 0);
12354
12562
  }
@@ -12399,7 +12607,7 @@ async function restoreFactsDocument(target, doc, options = {}) {
12399
12607
  const insertSource = target.db.prepare("INSERT INTO fact_sources (fact_id, kind, ref) VALUES (?, ?, ?)");
12400
12608
  for (const row of rows) {
12401
12609
  try {
12402
- insertFact.run(row.factId, row.content, row.kind, row.title, row.legacyCategory, row.status, row.archivedAt, row.archiveReason, row.createdAt, row.updatedAt, row.trustScore, row.settleClock ?? clock, row.assertCount, row.helpfulCount, row.pinned ? 1 : 0, row.pinnedAt, row.lastReinforcedAt, row.bonusCount, row.bonusWindowAt, row.retrievalCount, row.lastRetrievedAt, row.ttlDays, row.validFrom, row.validTo, row.validFromMs, row.validToMs, row.supersedesId, row.mirrorSource, row.mirrorTarget);
12610
+ insertFact.run(row.factId, row.content, row.kind, row.title, row.legacyCategory, row.status, row.archivedAt, row.archiveReason, row.createdAt, row.updatedAt, row.trustScore, row.settleClock ?? clock, row.assertCount, row.helpfulCount, row.pinned ? 1 : 0, row.pinnedAt, row.lastReinforcedAt, row.bonusCount, row.bonusWindowAt, row.retrievalCount, row.lastRetrievedAt, row.ttlDays, row.validFrom, row.validFromMs, row.supersedesId, row.mirrorSource, row.mirrorTarget);
12403
12611
  for (const source of row.sources) insertSource.run(row.factId, source.kind, source.ref);
12404
12612
  if (row.relations.length > 0) {
12405
12613
  const report = writeExplicitEdgesForFact(target.db, row.factId, row.relations);
@@ -12497,9 +12705,6 @@ var SqliteMemoryRepository = class {
12497
12705
  countForgettingWithin(p) {
12498
12706
  return this.dao.countForgettingWithin(p);
12499
12707
  }
12500
- countIdleCandidates(days) {
12501
- return this.dao.countIdleCandidates(days);
12502
- }
12503
12708
  countArchiveBacklog(p) {
12504
12709
  return this.dao.countArchiveBacklog(p);
12505
12710
  }
@@ -12582,11 +12787,11 @@ var SqliteMemoryRepository = class {
12582
12787
  factId: Number(existing.fact_id)
12583
12788
  };
12584
12789
  }
12585
- applyValidity(factId, validFrom, validTo) {
12586
- this.dao.applyValidity(factId, validFrom, validTo);
12790
+ applyValidity(factId, validFrom) {
12791
+ this.dao.applyValidity(factId, validFrom);
12587
12792
  }
12588
- applyValues(factId, kind, ttlDays) {
12589
- this.dao.applyValues(factId, kind, ttlDays);
12793
+ applyValues(factId, kind) {
12794
+ this.dao.applyValues(factId, kind);
12590
12795
  }
12591
12796
  setTitle(factId, title) {
12592
12797
  this.dao.setTitle(factId, title);
@@ -12603,17 +12808,14 @@ var SqliteMemoryRepository = class {
12603
12808
  incrementAssertCount(factId) {
12604
12809
  return this.dao.incrementAssertCount(factId);
12605
12810
  }
12606
- markValidTo(factId, at, atMs) {
12607
- this.dao.markValidTo(factId, at, atMs);
12608
- }
12609
12811
  linkSupersede(factId, supersedesId) {
12610
12812
  this.dao.linkSupersede(factId, supersedesId);
12611
12813
  }
12612
12814
  archiveSuperseded(factId, clock) {
12613
12815
  return this.dao.archiveSuperseded(factId, clock);
12614
12816
  }
12615
- revive(factId, kind, ttlDays, trust, clock) {
12616
- return this.dao.revive(factId, kind, ttlDays, trust, clock);
12817
+ revive(factId, kind, trust, clock) {
12818
+ return this.dao.revive(factId, kind, trust, clock);
12617
12819
  }
12618
12820
  archive(factId, clock, reason) {
12619
12821
  return this.dao.archive(factId, clock, reason);
@@ -12621,24 +12823,12 @@ var SqliteMemoryRepository = class {
12621
12823
  restore(factId, trust, clock) {
12622
12824
  return this.dao.restore(factId, trust, clock);
12623
12825
  }
12624
- bumpHelpful(factId, delta) {
12625
- this.dao.bumpHelpful(factId, delta);
12626
- }
12627
12826
  pin(factId, clock) {
12628
12827
  return this.dao.pin(factId, clock);
12629
12828
  }
12630
12829
  unpin(factId, clock) {
12631
12830
  return this.dao.unpin(factId, clock);
12632
12831
  }
12633
- pinOutcome(factId, clock, bonusCount, windowAt) {
12634
- this.dao.pinOutcome(factId, clock, bonusCount, windowAt);
12635
- }
12636
- forgetOutcome(factId, trust, clock, bonusCount, windowAt) {
12637
- this.dao.forgetOutcome(factId, trust, clock, bonusCount, windowAt);
12638
- }
12639
- reinforceOutcome(factId, trust, clock, bonusCount, windowAt) {
12640
- this.dao.reinforceOutcome(factId, trust, clock, bonusCount, windowAt);
12641
- }
12642
12832
  touchUsage(ids) {
12643
12833
  this.dao.touchUsage(ids);
12644
12834
  }
@@ -16289,6 +16479,22 @@ const NO_CHECK = {
16289
16479
  */
16290
16480
  const RETRIEVAL_HEALTH_KEY = "retrieval_health";
16291
16481
  /**
16482
+ * The MODEL face's ONE instant renderer: a stored stamp → ISO-8601 **with the host's offset**
16483
+ * (`2026-10-28T19:58:48+08:00`), via `@avantf/mem-contract/time`'s `formatOffsetIso`.
16484
+ *
16485
+ * Every timestamp this store hands UP goes through here, and none of them is localized anywhere
16486
+ * else: the DB column stays UTC (`CURRENT_TIMESTAMP`) and the export document stays byte-identical
16487
+ * (`facts_io` reads rows directly), while the model receives an unambiguous instant and needs no
16488
+ * timezone arithmetic of its own. `null` means "absent/unreadable" — the CALLER keeps its existing
16489
+ * spelling (`''` for `created_at`, `null` for the nullable ones) instead of an empty time.
16490
+ *
16491
+ * NOT applied to `valid_from` / `event_date`: that is a calendar DATE the caller wrote, with no time
16492
+ * and no zone, and rendering it through a clock would invent a midnight.
16493
+ */
16494
+ function modelInstant(value) {
16495
+ return formatOffsetIso(value === null || value === void 0 ? null : String(value));
16496
+ }
16497
+ /**
16292
16498
  * The ONE stored-title-row → `TitleCandidate` mapper, shared by the exact page and the fuzzy
16293
16499
  * shortlist so a candidate cannot describe the same row two ways.
16294
16500
  *
@@ -16306,8 +16512,8 @@ function titleCandidatesOf(rows, includeExcerpt) {
16306
16512
  matched_title: row.matched_title === null || row.matched_title === void 0 ? String(row.title) : String(row.matched_title),
16307
16513
  memory_kind: mapLegacyKind(row.kind),
16308
16514
  status: row.status === "archived" ? "archived" : "active",
16309
- created_at: row.created_at === null ? "" : String(row.created_at),
16310
- updated_at: row.updated_at === null ? null : String(row.updated_at),
16515
+ created_at: modelInstant(row.created_at) ?? "",
16516
+ updated_at: modelInstant(row.updated_at),
16311
16517
  ...row.valid_from === null || row.valid_from === void 0 ? {} : { event_date: String(row.valid_from) },
16312
16518
  ...includeExcerpt && row.excerpt !== null ? { excerpt: String(row.excerpt) } : {}
16313
16519
  }));
@@ -16334,8 +16540,8 @@ function factEvidenceOf(row) {
16334
16540
  status: row["status"] === "archived" ? "archived" : "active",
16335
16541
  ...title === null || title === void 0 || title === "" ? {} : { title: String(title) },
16336
16542
  ...row["valid_from"] === null || row["valid_from"] === void 0 ? {} : { event_date: String(row["valid_from"]) },
16337
- created_at: row["created_at"] === null || row["created_at"] === void 0 ? "" : String(row["created_at"]),
16338
- updated_at: row["updated_at"] === null || row["updated_at"] === void 0 ? null : String(row["updated_at"])
16543
+ created_at: modelInstant(row["created_at"]) ?? "",
16544
+ updated_at: modelInstant(row["updated_at"])
16339
16545
  };
16340
16546
  }
16341
16547
  var MemoryStore = class {
@@ -16487,7 +16693,7 @@ var MemoryStore = class {
16487
16693
  dim: this.vstore.dim
16488
16694
  });
16489
16695
  }
16490
- async add(content, kind, ttlDays, opts) {
16696
+ async add(content, kind, opts) {
16491
16697
  const normalized = normalizeWrite(content).trim();
16492
16698
  if (!normalized) throw new Error("内容不能为空");
16493
16699
  const secret = detectSecretShape(normalized);
@@ -16500,7 +16706,7 @@ var MemoryStore = class {
16500
16706
  if (checkedKind !== void 0 && requiresEventDate(checkedKind) && opts?.eventDate === void 0) throw new Error(JOURNAL_NEEDS_EVENT_DATE);
16501
16707
  const explicitTriples = opts?.relations === void 0 ? void 0 : await this.relations.explicitRelationTriples(checkedKind ?? "note", opts.relations);
16502
16708
  const title = this.resolveWriteTitle(opts?.title, checkedKind);
16503
- const { fact_id, is_new, revived, assert_count } = await this.persistFact(normalized, checkedKind, ttlDays, entityWrites, triples, {
16709
+ const { fact_id, is_new, revived, assert_count } = await this.persistFact(normalized, checkedKind, entityWrites, triples, {
16504
16710
  ...opts,
16505
16711
  title,
16506
16712
  relations: void 0
@@ -16591,7 +16797,7 @@ var MemoryStore = class {
16591
16797
  }], this.vectorWriteTarget(this.vectorSpace()));
16592
16798
  return encoded === 1;
16593
16799
  }
16594
- async update(fact_id, content, kind, ttlDays, opts) {
16800
+ async update(fact_id, content, kind, opts) {
16595
16801
  const row = await this.getRow(fact_id);
16596
16802
  if (!row) throw new Error(`找不到 fact_id=${fact_id}`);
16597
16803
  const normalized = normalizeWrite(content).trim();
@@ -16607,7 +16813,7 @@ var MemoryStore = class {
16607
16813
  if (requiresEventDate(effectiveKind) && opts?.eventDate === void 0 && row.valid_from == null) throw new Error(JOURNAL_NEEDS_EVENT_DATE);
16608
16814
  const title = this.resolveWriteTitle(opts?.title, effectiveKind);
16609
16815
  const explicitTriples = opts?.relations === void 0 ? void 0 : await this.relations.explicitRelationTriples(effectiveKind, opts.relations);
16610
- const { fact_id: newId, is_new, revived, assert_count } = await this.persistFact(normalized, checkedKind, ttlDays, entityWrites, triples, {
16816
+ const { fact_id: newId, is_new, revived, assert_count } = await this.persistFact(normalized, checkedKind, entityWrites, triples, {
16611
16817
  ...opts,
16612
16818
  supersedesId: fact_id,
16613
16819
  archiveOldIfActive: true,
@@ -16641,9 +16847,9 @@ var MemoryStore = class {
16641
16847
  return changed > 0;
16642
16848
  }
16643
16849
  /**
16644
- * Bring an archived fact back (spec §2.6): a `forgot` fact is lifted to
16645
- * `recall_floor` (otherwise the very next tick would re-archive it, R4), the
16646
- * idle clock is refreshed (otherwise an `idle`-archived fact dies again, R18),
16850
+ * Bring an archived fact back (spec §2.6): a `forgot` fact is lifted to the frozen base
16851
+ * `BASE_TRUST` (otherwise the very next tick would re-archive it, R4),
16852
+ * `last_retrieved_at` is refreshed (observation only — no clock reads it any more),
16647
16853
  * and `settle_clock` moves to now so the downtime is not charged.
16648
16854
  */
16649
16855
  async restore(fact_id) {
@@ -16652,7 +16858,7 @@ var MemoryStore = class {
16652
16858
  const trust = this.config.trust;
16653
16859
  const clock = await this.lifecycle.clock();
16654
16860
  const stored = Number(row.trust_score);
16655
- const next = String(row.archive_reason) === "forgot" || stored <= trust.forget_threshold ? Math.max(stored, trust.recall_floor) : stored;
16861
+ const next = String(row.archive_reason) === "forgot" || stored <= trust.forget_threshold ? Math.max(stored, BASE_TRUST) : stored;
16656
16862
  if (await this.facts.restore(fact_id, next, clock) === 0) return false;
16657
16863
  const vec = bytesToFloat32(row.semantic_vector ?? null);
16658
16864
  if (vec && vec.length === this.vstore.dim) this.vstore.add(fact_id, vec);
@@ -16660,42 +16866,6 @@ var MemoryStore = class {
16660
16866
  if ((await this.detectContradictions(fact_id)).complete) await this.facts.markConflictChecked([fact_id]);
16661
16867
  return true;
16662
16868
  }
16663
- async helpful(fact_id) {
16664
- return await this.applyFeedback(fact_id, 1);
16665
- }
16666
- async unhelpful(fact_id) {
16667
- return await this.applyFeedback(fact_id, -1);
16668
- }
16669
- /**
16670
- * Explicit feedback (spec §2.4). Pinned/archived rows only move `helpful_count`
16671
- * (R7); otherwise the decay is settled first, then `±feedback_delta`, pin at the
16672
- * permanent threshold (snap to 1.0) or archive immediately at the forget line.
16673
- */
16674
- async applyFeedback(fact_id, delta) {
16675
- if (await this.getRow(fact_id) === void 0) return null;
16676
- const trust = this.config.trust;
16677
- const evict = await this.lifecycle.immediateTransaction(async () => {
16678
- const row = await this.getRow(fact_id);
16679
- if (!row) return null;
16680
- const clock = await this.lifecycle.clock();
16681
- const outcome = applyFeedbackDelta(asTrustRow(row), clock, delta, Date.now(), trust);
16682
- await this.facts.bumpHelpful(fact_id, delta);
16683
- if (outcome.untouched) return null;
16684
- if (outcome.pin) {
16685
- await this.facts.pinOutcome(fact_id, clock, outcome.bonusCount, outcome.windowAt);
16686
- return null;
16687
- }
16688
- if (outcome.forget) {
16689
- await this.facts.forgetOutcome(fact_id, outcome.next, clock, outcome.bonusCount, outcome.windowAt);
16690
- await this.contradictions.resolveForFact(fact_id);
16691
- return fact_id;
16692
- }
16693
- await this.facts.reinforceOutcome(fact_id, outcome.next, clock, outcome.bonusCount, outcome.windowAt);
16694
- return null;
16695
- });
16696
- if (evict !== null) evictVectors(this.vstore, [evict]);
16697
- return (await this.getRow(fact_id))?.helpful_count ?? 0;
16698
- }
16699
16869
  /** Permanent-memory administration (spec §6). */
16700
16870
  async pin(fact_id) {
16701
16871
  return await this.facts.pin(fact_id, await this.lifecycle.clock()) > 0;
@@ -16713,15 +16883,15 @@ var MemoryStore = class {
16713
16883
  const clock = await this.lifecycle.clock();
16714
16884
  const active = await this.facts.countActive();
16715
16885
  const pinned = await this.facts.countPinnedActive();
16716
- const forgettingSoon = trust.decay_per_day > 0 ? await this.facts.countForgettingWithin({
16717
- step: trust.decay_per_day,
16886
+ const curve = curveOf(trust);
16887
+ const forgettingSoon = await this.facts.countForgettingWithin({
16888
+ threshold: ceilingForDays(FORGETTING_SOON_DAYS, curve),
16718
16889
  clock,
16719
- withinDays: FORGETTING_SOON_DAYS
16720
- }) : 0;
16890
+ curve
16891
+ });
16721
16892
  const bonus = await this.facts.bonusStatedToday();
16722
16893
  const reinforcedToday = bonus.count;
16723
16894
  const grantedToday = bonus.granted;
16724
- const idleCandidates = await this.facts.countIdleCandidates(trust.idle_calendar_days);
16725
16895
  const reasons = await this.facts.archivedByReason();
16726
16896
  const oldest = await this.facts.minSettleClock();
16727
16897
  return {
@@ -16732,7 +16902,6 @@ var MemoryStore = class {
16732
16902
  forgetting_soon: forgettingSoon,
16733
16903
  reinforced_today: reinforcedToday,
16734
16904
  bonus_granted_today: grantedToday,
16735
- idle_candidates: idleCandidates,
16736
16905
  conflict_pending: await this.facts.countPendingConflicts(),
16737
16906
  entities_stale: await this.facts.countStaleEntities(3),
16738
16907
  archived_by_reason: Object.fromEntries(reasons.map((r) => [r.reason ?? "unknown", r.n])),
@@ -16895,9 +17064,18 @@ var MemoryStore = class {
16895
17064
  liveVectorCount() {
16896
17065
  return this.vstore.count();
16897
17066
  }
16898
- /** List contradiction candidates (`resolved` 0 = open, 1 = closed with a recorded verdict). */
17067
+ /**
17068
+ * List contradiction candidates (`resolved` 0 = open, 1 = closed with a recorded verdict).
17069
+ *
17070
+ * `detected_at` is mapped to the model face's instant shape here (the DAO hands back the raw UTC
17071
+ * column): this payload is what the 记忆 tab's 未处理矛盾 table and the CLI both render, and it must
17072
+ * not be the one surface still printing a zone-less stamp.
17073
+ */
16899
17074
  async listContradictions(opts) {
16900
- return await this.contradictions.list(opts?.resolved ?? 0, opts?.kind, opts?.threshold, opts?.limit ?? 10);
17075
+ return (await this.contradictions.list(opts?.resolved ?? 0, opts?.kind, opts?.threshold, opts?.limit ?? 10)).map((row) => ({
17076
+ ...row,
17077
+ detected_at: modelInstant(row.detected_at) ?? row.detected_at
17078
+ }));
16901
17079
  }
16902
17080
  /**
16903
17081
  * Adjudicate one logged conflict — the outlet the detector never had.
@@ -16943,7 +17121,6 @@ var MemoryStore = class {
16943
17121
  reason: "loser_not_in_pair"
16944
17122
  };
16945
17123
  await this.archive(loserFactId, "contradiction");
16946
- await this.facts.markValidTo(loserFactId, formatUtcTs(Date.now()), Date.now());
16947
17124
  await this.contradictions.resolve(id, resolution, loserFactId);
16948
17125
  return {
16949
17126
  resolved: true,
@@ -17330,13 +17507,21 @@ var MemoryStore = class {
17330
17507
  * ({@link timePage}, `mode:"time"`): the window is the whole question, so there is nothing to
17331
17508
  * match, plan or rank.
17332
17509
  *
17333
- * The exact, facts and relation passes reinforce the facts they return, exactly like the fuzzy
17334
- * pass does for its hits: all are recalls the caller actually reads. A caller that must stay
17335
- * read-only passes `track: false` (the conditional hint's probe does). Exact, facts, relation and
17336
- * time also honour the caller's output budget (their text is the caller's to bound). The one
17337
- * difference is the DEFAULT: a time window applies no budget unless the caller names one — its
17338
- * page size (`limit`) is what bounds the list, and the budget would only rewrite rows the caller
17339
- * asked for (see {@link timePage}).
17510
+ * What a pass does to the rows it returns is one decision, and it is deliberately NOT uniform:
17511
+ *
17512
+ * - the RELATION and FUZZY passes REINFORCE them ({@link reinforce}): each answers a question
17513
+ * the caller asked, so the rows are what it read;
17514
+ * - the TIME, EXACT and FACTS passes record USAGE ONLY ({@link touchOnly}): they hand back a
17515
+ * LIST the caller browses — page 2 of a listing is not a second recall — so "it was looked
17516
+ * at" still moves `retrieval_count` / `last_retrieved_at` (the dormancy clock) while
17517
+ * `trust_score` / `bonus_count` stay where they are.
17518
+ *
17519
+ * A caller that must stay read-only passes `track: false` (the conditional hint's probe does);
17520
+ * that branch writes nothing at all, usage included. Exact, facts, relation and time also honour
17521
+ * the caller's output budget (their text is the caller's to bound). The one difference is the
17522
+ * DEFAULT: a time window applies no budget unless the caller names one — its page size (`limit`)
17523
+ * is what bounds the list, and the budget would only rewrite rows the caller asked for (see
17524
+ * {@link timePage}).
17340
17525
  *
17341
17526
  * It does NOT emit a retrieval-health event. Those counters describe the retrieval PIPELINE
17342
17527
  * (legs, fusion, semantic-live rate); none of an exact, facts, relation or time answer ran a leg,
@@ -17394,8 +17579,9 @@ var MemoryStore = class {
17394
17579
  * list", and the budget stays a knob for a RANKED result (exact/facts/relation/hybrid unchanged).
17395
17580
  * `max_tokens` (including the contract's `0` = unlimited) is passed through unchanged.
17396
17581
  *
17397
- * It honours `kind`/`source`, and reinforces the rows it returns unless `track: false` — a window
17398
- * the caller reads is a recall like any other.
17582
+ * It honours `kind`/`source`, and records usage ONLY for the rows it returns unless `track: false`
17583
+ * ({@link touchOnly}). A window is listed so the caller can look through it, not recalled into an
17584
+ * answer: the dormancy clock (`retrieval_count` / `last_retrieved_at`) moves, trust does not.
17399
17585
  */
17400
17586
  async timePage(input) {
17401
17587
  if (input.time === void 0) throw new Error("mem_recall.search 需要 `query`,或只给 `time`(纯时间召回);两者都没有时无法检索。");
@@ -17415,7 +17601,7 @@ var MemoryStore = class {
17415
17601
  offset
17416
17602
  });
17417
17603
  const { kept } = applyOutputBudget(this.config, await this.factHits(ids, () => 1), input.maxTokens ?? 0);
17418
- if (input.track !== false) await this.reinforce(kept.map((hit) => hit.ref_id));
17604
+ if (input.track !== false) await this.touchOnly(kept.map((hit) => hit.ref_id));
17419
17605
  return {
17420
17606
  mode: "time",
17421
17607
  total,
@@ -17453,7 +17639,7 @@ var MemoryStore = class {
17453
17639
  offset
17454
17640
  });
17455
17641
  const { kept } = applyOutputBudget(this.config, await this.factHits(ids, () => 1), input.maxTokens);
17456
- if (input.track !== false) await this.reinforce(kept.map((hit) => hit.ref_id));
17642
+ if (input.track !== false) await this.touchOnly(kept.map((hit) => hit.ref_id));
17457
17643
  return {
17458
17644
  mode: "exact",
17459
17645
  total,
@@ -17490,8 +17676,8 @@ var MemoryStore = class {
17490
17676
  * malformed window is the caller's error exactly as it is in the exact page;
17491
17677
  * - the page is FACT-level (`total`/`offset`/`limit`/`truncated`), and the caller's output budget
17492
17678
  * only shortens `content` — dropping a row would make `total` disagree with the page;
17493
- * - the returned facts are reinforced unless `track: false`, like every other recall the caller
17494
- * actually reads.
17679
+ * - the returned facts get usage recorded but NO reinforcement unless `track: false`: a facts
17680
+ * page is a list the caller browses, so only the dormancy clock moves ({@link touchOnly}).
17495
17681
  */
17496
17682
  async factPage(input, rawQuery) {
17497
17683
  const query = rawQuery.trim();
@@ -17510,7 +17696,7 @@ var MemoryStore = class {
17510
17696
  });
17511
17697
  if (page.total === 0) return null;
17512
17698
  const facts = this.budgetFactEvidence(page.rows.map(factEvidenceOf), input.maxTokens);
17513
- if (input.track !== false) await this.reinforce(facts.map((entry) => entry.fact_id));
17699
+ if (input.track !== false) await this.touchOnly(facts.map((entry) => entry.fact_id));
17514
17700
  return {
17515
17701
  mode: "facts",
17516
17702
  total: page.total,
@@ -17607,8 +17793,8 @@ var MemoryStore = class {
17607
17793
  text: String(row["content"] ?? ""),
17608
17794
  memory_kind: mapLegacyKind(String(row["kind"] ?? "note")),
17609
17795
  ...title === void 0 ? {} : { title },
17610
- created_at: row["created_at"] === null || row["created_at"] === void 0 ? "" : String(row["created_at"]),
17611
- updated_at: row["updated_at"] === null || row["updated_at"] === void 0 ? null : String(row["updated_at"])
17796
+ created_at: modelInstant(row["created_at"]) ?? "",
17797
+ updated_at: modelInstant(row["updated_at"])
17612
17798
  });
17613
17799
  }
17614
17800
  return evidence;
@@ -17836,10 +18022,9 @@ var MemoryStore = class {
17836
18022
  score: scoreOf(id),
17837
18023
  source_ref: factSourceRef(id),
17838
18024
  entities: bags.get(id) ?? [],
17839
- created_at: time?.created_at ?? "",
17840
- updated_at: time?.updated_at ?? null,
18025
+ created_at: modelInstant(time?.created_at) ?? "",
18026
+ updated_at: modelInstant(time?.updated_at),
17841
18027
  ...time?.valid_from === null || time?.valid_from === void 0 ? {} : { valid_from: time.valid_from },
17842
- ...time?.valid_to === null || time?.valid_to === void 0 ? {} : { valid_to: time.valid_to },
17843
18028
  ...time?.title === null || time?.title === void 0 ? {} : { title: time.title }
17844
18029
  };
17845
18030
  });
@@ -17904,6 +18089,19 @@ var MemoryStore = class {
17904
18089
  } catch {}
17905
18090
  }
17906
18091
  /**
18092
+ * Record the USAGE of these facts and nothing else — no trust bonus, no quota, no
18093
+ * `last_reinforced_at`.
18094
+ *
18095
+ * This is the paginated passes' half of {@link reinforce}: `timePage` / `exactContentPage` /
18096
+ * `factPage` return a list the caller browses rather than a recall it acted on, so "someone
18097
+ * looked at this row" still moves `retrieval_count` / `last_retrieved_at` (observation and
18098
+ * export only — no lifecycle step reads that clock) while `trust_score` / `bonus_count` stay
18099
+ * put.
18100
+ */
18101
+ async touchOnly(ids) {
18102
+ if (ids.length) await this.touchUsage(ids);
18103
+ }
18104
+ /**
17907
18105
  * Semantic leg. `queryVector` short-circuits the encode when the caller already
17908
18106
  * encoded this exact query (the cross-store router does, for both stores at once);
17909
18107
  * `onVector` publishes the vector this leg actually used, so the relaxed retry pass can reuse it
@@ -18012,25 +18210,22 @@ var MemoryStore = class {
18012
18210
  /**
18013
18211
  * Insert (or resolve to) a fact row.
18014
18212
  *
18015
- * `kind` / `ttlDays` are `undefined` when the CALLER specified nothing — the
18016
- * distinction is load-bearing. A duplicate-content hit must not clobber the existing
18017
- * row's values with the insert defaults: re-adding a fact without a kind used to
18018
- * reset it to `general`, and reviving an archived one silently cleared an explicit TTL.
18019
- * The rule for all three branches is therefore: an explicitly provided value lands, an
18020
- * omitted one keeps what the row already has, and a brand-new row falls back to the
18021
- * defaults (`general` / `0`).
18213
+ * `kind` is `undefined` when the CALLER specified nothing — the distinction is load-bearing.
18214
+ * A duplicate-content hit must not clobber the existing row's value with the insert default:
18215
+ * re-adding a fact without a kind used to reset it to `general`. The rule for all three
18216
+ * branches is therefore: an explicitly provided value lands, an omitted one keeps what the row
18217
+ * already has, and a brand-new row falls back to the default (`note`).
18022
18218
  */
18023
- async persistFact(content, kind, ttlDays, entities, triples, opts, explicitEdges = void 0) {
18219
+ async persistFact(content, kind, entities, triples, opts, explicitEdges = void 0) {
18024
18220
  const hrr = hrrToBytes(encodeHrrEntityVector(entities.map((e) => e.name)));
18025
18221
  const trust = this.config.trust;
18026
18222
  const clock = await this.lifecycle.clock();
18027
18223
  const nowIso = formatUtcTs(Date.now());
18028
18224
  const replaced = opts?.supersedesId === void 0 ? void 0 : await this.getRow(opts.supersedesId);
18029
18225
  const superseded = trust.inherit_trust_on_update ? replaced : void 0;
18030
- const startTrust = superseded === void 0 ? trust.start : effectiveTrust(asTrustRow(superseded), clock, trust);
18226
+ const startTrust = superseded === void 0 ? BASE_TRUST : effectiveTrust(asTrustRow(superseded), clock, trust);
18031
18227
  const startPinned = superseded !== void 0 && isPinned(asTrustRow(superseded)) ? 1 : 0;
18032
18228
  const newKind = kind ?? (replaced === void 0 ? "note" : mapLegacyKind(replaced.kind));
18033
- const newTtl = ttlDays ?? (replaced === void 0 ? 0 : Number(replaced.ttl_days ?? 0));
18034
18229
  /**
18035
18230
  * The title this revision would carry (JOURNAL_TITLE_SEARCH §2.2/§11.2, v17's correction).
18036
18231
  *
@@ -18062,13 +18257,11 @@ var MemoryStore = class {
18062
18257
  }
18063
18258
  const createdAt = replaced === void 0 || replaced.created_at === null ? nowIso : String(replaced.created_at);
18064
18259
  const validFrom = opts?.eventDate === void 0 ? replaced === void 0 ? null : nullableEventTime(replaced.valid_from, replaced.valid_from_ms) : normalizeEventTime(opts.eventDate, "event_date");
18065
- const validTo = opts?.validUntil === void 0 ? replaced === void 0 ? null : nullableEventTime(replaced.valid_to, replaced.valid_to_ms) : normalizeEventTime(opts.validUntil, "valid_until");
18066
18260
  const { supersededId, ...result } = await this.lifecycle.transaction(async () => {
18067
18261
  const cur = await this.facts.insertRevision({
18068
18262
  content,
18069
18263
  kind: newKind,
18070
18264
  title: desiredTitle,
18071
- ttlDays: newTtl,
18072
18265
  supersedesId: opts?.supersedesId ?? null,
18073
18266
  hrr,
18074
18267
  trust: clamp01(startTrust),
@@ -18078,9 +18271,7 @@ var MemoryStore = class {
18078
18271
  windowAt: nowIso,
18079
18272
  createdAt,
18080
18273
  validFrom: validFrom?.text ?? null,
18081
- validTo: validTo?.text ?? null,
18082
- validFromMs: validFrom?.ms ?? null,
18083
- validToMs: validTo?.ms ?? null
18274
+ validFromMs: validFrom?.ms ?? null
18084
18275
  });
18085
18276
  let fact_id;
18086
18277
  let is_new;
@@ -18090,18 +18281,16 @@ var MemoryStore = class {
18090
18281
  fact_id = Number(row.fact_id);
18091
18282
  is_new = false;
18092
18283
  const rowKind = mapLegacyKind(row.kind);
18093
- const rowTtl = Number(row.ttl_days ?? 0);
18094
18284
  const keepKind = kind ?? rowKind;
18095
- const keepTtl = ttlDays ?? rowTtl;
18096
18285
  const rowTitle = storedTitle(row.title);
18097
18286
  const selfRetitle = retitle && opts?.supersedesId !== void 0 && Number(opts.supersedesId) === fact_id;
18098
18287
  if (opts?.title !== void 0 && opts.title !== null && rowTitle !== null && opts.title !== rowTitle && !selfRetitle) throw new Error(TITLE_CONFLICT);
18099
18288
  const keepTitle = selfRetitle ? desiredTitle : rowTitle ?? desiredTitle;
18100
18289
  if (opts?.requireJournalTitle === true && keepKind === "journal" && keepTitle === null) throw new Error(TITLE_REQUIRED);
18101
- await this.facts.applyValidity(fact_id, opts?.eventDate === void 0 ? void 0 : validFrom ?? void 0, opts?.validUntil === void 0 ? void 0 : validTo ?? void 0);
18290
+ await this.facts.applyValidity(fact_id, opts?.eventDate === void 0 ? void 0 : validFrom ?? void 0);
18102
18291
  const settled = effectiveTrust(asTrustRow(row), clock, trust);
18103
- const revived = await this.facts.revive(fact_id, keepKind, keepTtl, Math.max(settled, trust.recall_floor), clock) > 0;
18104
- if (!revived && (keepKind !== rowKind || keepTtl !== rowTtl)) await this.facts.applyValues(fact_id, keepKind, keepTtl);
18292
+ const revived = await this.facts.revive(fact_id, keepKind, Math.max(settled, BASE_TRUST), clock) > 0;
18293
+ if (!revived && keepKind !== rowKind) await this.facts.applyValues(fact_id, keepKind);
18105
18294
  if (keepTitle !== null && keepTitle !== rowTitle) {
18106
18295
  await this.facts.setTitle(fact_id, keepTitle);
18107
18296
  if (selfRetitle) {
@@ -18189,10 +18378,7 @@ var MemoryStore = class {
18189
18378
  if (!opts?.supersedesId || opts.supersedesId === fact_id) return null;
18190
18379
  await this.facts.linkSupersede(fact_id, opts.supersedesId);
18191
18380
  if (opts.archiveOldIfActive) {
18192
- if (await this.facts.archiveSuperseded(opts.supersedesId, await this.lifecycle.clock()) > 0) {
18193
- await this.contradictions.resolveForFact(opts.supersedesId);
18194
- await this.facts.markValidTo(opts.supersedesId, formatUtcTs(Date.now()), Date.now());
18195
- }
18381
+ if (await this.facts.archiveSuperseded(opts.supersedesId, await this.lifecycle.clock()) > 0) await this.contradictions.resolveForFact(opts.supersedesId);
18196
18382
  }
18197
18383
  await this.triples.deleteForFact(opts.supersedesId);
18198
18384
  await this.relations.deleteEdgesForFact(opts.supersedesId);
@@ -18349,8 +18535,8 @@ var MemoryStore = class {
18349
18535
  pinned: isPinned(row),
18350
18536
  remaining_days: remainingDays(row, clock, this.config.trust),
18351
18537
  helpful_count: Number(r.helpful_count),
18352
- created_at: String(r.created_at),
18353
- archived_at: r.archived_at ? String(r.archived_at) : null,
18538
+ created_at: modelInstant(r.created_at) ?? "",
18539
+ archived_at: modelInstant(r.archived_at),
18354
18540
  archive_reason: r.archive_reason ? String(r.archive_reason) : null
18355
18541
  };
18356
18542
  }
@@ -18363,14 +18549,13 @@ var MemoryStore = class {
18363
18549
  entities,
18364
18550
  triples,
18365
18551
  settle_clock: Number(r.settle_clock),
18366
- pinned_at: r.pinned_at ? String(r.pinned_at) : null,
18367
- last_reinforced_at: r.last_reinforced_at ? String(r.last_reinforced_at) : null,
18368
- updated_at: r.updated_at ? String(r.updated_at) : null,
18552
+ pinned_at: modelInstant(r.pinned_at),
18553
+ last_reinforced_at: modelInstant(r.last_reinforced_at),
18554
+ updated_at: modelInstant(r.updated_at),
18369
18555
  bonus_count: Number(r.bonus_count ?? 0),
18370
- bonus_window_at: r.bonus_window_at ? String(r.bonus_window_at) : null,
18556
+ bonus_window_at: modelInstant(r.bonus_window_at),
18371
18557
  sources: await this.sources.listForFact(factId),
18372
18558
  valid_from: nullableString(r.valid_from),
18373
- valid_to: nullableString(r.valid_to),
18374
18559
  superseded_by: await this.facts.supersededBy(factId),
18375
18560
  assert_count: Number(r.assert_count ?? 1),
18376
18561
  relations: await this.relations.explicitRelationsForFact(factId)
@@ -18476,10 +18661,11 @@ function nullableEventTime(text, ms) {
18476
18661
  * inserts SQL rows.
18477
18662
  *
18478
18663
  * TWO CONSUMERS, TWO SEMANTICS. `import`/`merge` remain a CONTENT-IDENTITY UNION and consume only
18479
- * a subset: kind, ttl_days, embedded source ref, valid_from → event_date and valid_to →
18480
- * valid_until. They do NOT restore created_at/updated_at/trust_score/assert_count/helpful_count/
18481
- * pinned, archived status or the supersedes chain — `metadata_dropped` counts each value they
18482
- * knowingly drop instead of pretending. `restore` is the REBUILD (see `store/facts_restore.ts`):
18664
+ * a subset: kind, embedded source ref and valid_from → event_date. They do NOT restore
18665
+ * created_at/updated_at/trust_score/assert_count/helpful_count/pinned, archived status, the
18666
+ * supersedes chain, or the retired `ttl_days` / `valid_to` values (v2: the columns stay in the
18667
+ * document for format stability, but nothing reads them on the way in) — `metadata_dropped` counts
18668
+ * each value they knowingly drop instead of pretending. `restore` is the REBUILD (see `store/facts_restore.ts`):
18483
18669
  * only into an EMPTY facts table, keeping fact_id, status, timestamps, trust, pin, counters, every
18484
18670
  * provenance row and the supersedes chain, then re-deriving entities. The union path never revives
18485
18671
  * an archived row; restore is the only path that brings archived rows back. The vector, entity,
@@ -18575,7 +18761,6 @@ const FACT_COLUMNS = [
18575
18761
  "helpful_count",
18576
18762
  "last_reinforced_at",
18577
18763
  "valid_from",
18578
- "valid_to",
18579
18764
  "supersedes_id",
18580
18765
  "archived_at",
18581
18766
  "archive_reason",
@@ -18725,7 +18910,7 @@ function readFactsDocument(dbPath, options) {
18725
18910
  last_reinforced_at: textOrNull(cell(row, "last_reinforced_at")),
18726
18911
  sources: sources.get(sourceId) ?? [],
18727
18912
  valid_from: textOrNull(cell(row, "valid_from")),
18728
- valid_to: textOrNull(cell(row, "valid_to")),
18913
+ valid_to: null,
18729
18914
  supersedes_source_id: supersedes === null || supersedes === void 0 ? null : numberOf(supersedes, 0),
18730
18915
  archive: status === "archived" ? {
18731
18916
  at: textOrNull(cell(row, "archived_at")),
@@ -18921,7 +19106,9 @@ function emptyMetadataDropped() {
18921
19106
  assert_count: 0,
18922
19107
  helpful_count: 0,
18923
19108
  pinned: 0,
18924
- supersedes: 0
19109
+ supersedes: 0,
19110
+ ttl_days: 0,
19111
+ valid_to: 0
18925
19112
  };
18926
19113
  }
18927
19114
  /** What a Phase-2 restore WOULD have carried but v1 does not. */
@@ -18932,7 +19119,9 @@ function metadataDroppedOf(row) {
18932
19119
  assert_count: numberOf(cell(row, "assert_count"), 1) > 1 ? 1 : 0,
18933
19120
  helpful_count: numberOf(cell(row, "helpful_count"), 0) > 0 ? 1 : 0,
18934
19121
  pinned: boolOf(cell(row, "pinned")) ? 1 : 0,
18935
- supersedes: cell(row, "supersedes_source_id") === null || cell(row, "supersedes_source_id") === void 0 ? 0 : 1
19122
+ supersedes: cell(row, "supersedes_source_id") === null || cell(row, "supersedes_source_id") === void 0 ? 0 : 1,
19123
+ ttl_days: numberOf(cell(row, "ttl_days"), 0) > 0 ? 1 : 0,
19124
+ valid_to: cell(row, "valid_to") === null || cell(row, "valid_to") === void 0 ? 0 : 1
18936
19125
  };
18937
19126
  }
18938
19127
  function accumulateMetadataDropped(total, one) {
@@ -18942,6 +19131,8 @@ function accumulateMetadataDropped(total, one) {
18942
19131
  total.helpful_count += one.helpful_count;
18943
19132
  total.pinned += one.pinned;
18944
19133
  total.supersedes += one.supersedes;
19134
+ total.ttl_days += one.ttl_days;
19135
+ total.valid_to += one.valid_to;
18945
19136
  }
18946
19137
  function sourceIdOf(row) {
18947
19138
  const value = cell(row, "source_id");
@@ -18953,12 +19144,6 @@ function optionalString(row, key, label) {
18953
19144
  if (typeof value !== "string") throw new Error(`${label} 不是字符串`);
18954
19145
  return value === "" ? void 0 : value;
18955
19146
  }
18956
- function optionalTtlDays(row) {
18957
- const value = cell(row, "ttl_days");
18958
- if (value === null || value === void 0) return void 0;
18959
- if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error("ttl_days 无效(需要非负整数)");
18960
- return value;
18961
- }
18962
19147
  function optionalDate(row, key, label) {
18963
19148
  const value = cell(row, key);
18964
19149
  if (value === null || value === void 0) return void 0;
@@ -19020,19 +19205,15 @@ function addRequestFor(identity, row, fallbackSourceRef, preferEmbeddedSources)
19020
19205
  const sourceRef = (preferEmbeddedSources ? embeddedSourceRef(row) : void 0) ?? fallbackSourceRef;
19021
19206
  const kind = mapLegacyKind(optionalString(row, "kind", "kind") ?? optionalString(row, "category", "category"));
19022
19207
  const title = optionalString(row, "title", "title");
19023
- const ttlDays = optionalTtlDays(row);
19024
19208
  const eventDate = optionalDate(row, "valid_from", "valid_from");
19025
- const validUntil = optionalDate(row, "valid_to", "valid_to");
19026
19209
  const relations = relationsOf(row, kind);
19027
19210
  return {
19028
19211
  action: "add",
19029
19212
  content: identity,
19030
19213
  kind,
19031
19214
  ...title === void 0 ? {} : { title },
19032
- ...ttlDays === void 0 ? {} : { ttl_days: ttlDays },
19033
19215
  ...sourceRef === void 0 ? {} : { source_ref: sourceRef },
19034
19216
  ...eventDate === void 0 ? {} : { event_date: eventDate },
19035
- ...validUntil === void 0 ? {} : { valid_until: validUntil },
19036
19217
  ...relations === void 0 ? {} : { relations }
19037
19218
  };
19038
19219
  }
@@ -19427,13 +19608,12 @@ function walHealth(dbPath) {
19427
19608
  * P-08 / P-13 / JOURNAL_TITLE_SEARCH: the `mem_remember` extras, lifted off the request and onto the
19428
19609
  * store's write options. Only the keys actually present are carried, so an omitted argument stays
19429
19610
  * "omitted" all the way down (the store's "unset keeps what the row has" rule — an update inherits
19430
- * an omitted title, TTL, event time — depends on that distinction).
19611
+ * an omitted title or event time — depends on that distinction).
19431
19612
  */
19432
19613
  function writeOptions(req, rules) {
19433
19614
  return {
19434
19615
  ...req.source_ref === void 0 ? {} : { sourceRef: req.source_ref },
19435
19616
  ...req.event_date === void 0 ? {} : { eventDate: req.event_date },
19436
- ...req.valid_until === void 0 ? {} : { validUntil: req.valid_until },
19437
19617
  ...req.title === void 0 ? {} : { title: req.title },
19438
19618
  ...req.retitle === true ? { retitle: true } : {},
19439
19619
  ...req.relations === void 0 ? {} : { relations: req.relations },
@@ -19557,11 +19737,9 @@ async function buildRuntime(opts) {
19557
19737
  },
19558
19738
  async remember(req, rules) {
19559
19739
  switch (req.action) {
19560
- case "add": return this.memory.add(req.content, req.kind, req.ttl_days, writeOptions(req, rules));
19561
- case "update": return this.memory.update(req.fact_id, req.content, req.kind, req.ttl_days, writeOptions(req, rules));
19740
+ case "add": return this.memory.add(req.content, req.kind, writeOptions(req, rules));
19741
+ case "update": return this.memory.update(req.fact_id, req.content, req.kind, writeOptions(req, rules));
19562
19742
  case "remove": return this.memory.remove(req.fact_id, req.reason);
19563
- case "helpful": return this.memory.helpful(req.fact_id);
19564
- case "unhelpful": return this.memory.unhelpful(req.fact_id);
19565
19743
  }
19566
19744
  },
19567
19745
  async recall(req) {
@@ -19591,14 +19769,19 @@ async function buildRuntime(opts) {
19591
19769
  },
19592
19770
  async admin(req) {
19593
19771
  switch (req.action) {
19594
- case "stats": return {
19595
- ...await this.memory.countByStatus(),
19596
- retrieval: retrievalHealthSummary(),
19597
- vectors: await this.memory.vectorSpaceHealth(),
19598
- wal: walHealth(memDb),
19599
- sources: await this.memory.sourceCoverage(),
19600
- validity: await this.memory.validityCoverage()
19601
- };
19772
+ case "stats":
19773
+ const retrieval = retrievalHealthSummary();
19774
+ return {
19775
+ ...await this.memory.countByStatus(),
19776
+ retrieval: {
19777
+ ...retrieval,
19778
+ updated_at: formatOffsetIso(retrieval.updated_at)
19779
+ },
19780
+ vectors: await this.memory.vectorSpaceHealth(),
19781
+ wal: walHealth(memDb),
19782
+ sources: await this.memory.sourceCoverage(),
19783
+ validity: await this.memory.validityCoverage()
19784
+ };
19602
19785
  case "list": return await this.memory.list(req.kind, req.status ?? "active", req.limit ?? 50, req.offset ?? 0, req.source, req.time);
19603
19786
  case "detail": return await this.memory.get(req.fact_id) ?? { error: `fact_id=${req.fact_id} not found` };
19604
19787
  case "archive": return await this.memory.archive(req.fact_id, req.reason);
@@ -19749,11 +19932,9 @@ const descriptors = [
19749
19932
  content: optionalText.optional(),
19750
19933
  fact_id: count.optional(),
19751
19934
  kind: optionalText.optional(),
19752
- ttl_days: count.optional(),
19753
19935
  reason: optionalText.optional(),
19754
19936
  source_ref: optionalText.optional(),
19755
19937
  event_date: optionalText.optional(),
19756
- valid_until: optionalText.optional(),
19757
19938
  title: optionalText.optional(),
19758
19939
  retitle: bool.optional(),
19759
19940
  relations: z.array(z.object({
@@ -21577,7 +21758,7 @@ function tokenize(rawInput) {
21577
21758
  return tokens;
21578
21759
  }
21579
21760
  function metadataDroppedLine(dropped) {
21580
- return " metadata_dropped: timestamps=" + String(dropped.timestamps) + ", trust=" + String(dropped.trust) + ", assert_count=" + String(dropped.assert_count) + ", helpful_count=" + String(dropped.helpful_count) + ", pinned=" + String(dropped.pinned) + ", supersedes=" + String(dropped.supersedes);
21761
+ return " metadata_dropped: timestamps=" + String(dropped.timestamps) + ", trust=" + String(dropped.trust) + ", assert_count=" + String(dropped.assert_count) + ", helpful_count=" + String(dropped.helpful_count) + ", pinned=" + String(dropped.pinned) + ", supersedes=" + String(dropped.supersedes) + ", ttl_days=" + String(dropped.ttl_days) + ", valid_to=" + String(dropped.valid_to);
21581
21762
  }
21582
21763
  /** Failures are rendered in full up to {@link FAILURES_SHOWN}; the rest is a count, never silence. */
21583
21764
  const FAILURES_SHOWN = 5;