@avantf/dsh-mem 0.8.1 → 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.
Files changed (57) hide show
  1. package/README.md +42 -43
  2. package/lib/client.js +1181 -327
  3. package/lib/engine/graph-recall/index.d.ts +2 -2
  4. package/lib/engine/graph-recall/traverse.d.ts +2 -2
  5. package/lib/engine/graph-recall/types.d.ts +1 -1
  6. package/lib/engine/mem/config/config_files.d.ts +1 -1
  7. package/lib/engine/mem/db/dao/facts.d.ts +80 -82
  8. package/lib/engine/mem/dbdrivers/sqlite/facts.d.ts +6 -13
  9. package/lib/engine/mem/defaults.d.ts +4 -1
  10. package/lib/engine/mem/entities/extract.d.ts +34 -14
  11. package/lib/engine/mem/facts_io.d.ts +4 -0
  12. package/lib/engine/mem/index.d.ts +1 -0
  13. package/lib/engine/mem/interfaces/repositories.d.ts +5 -19
  14. package/lib/engine/mem/lifecycle/maintenance.d.ts +8 -4
  15. package/lib/engine/mem/lifecycle/tick.d.ts +11 -10
  16. package/lib/engine/mem/lifecycle/trust.d.ts +24 -37
  17. package/lib/engine/mem/modelBootstrap.d.ts +29 -0
  18. package/lib/engine/mem/runtime.d.ts +4 -8
  19. package/lib/engine/mem/store/facts_restore.d.ts +8 -6
  20. package/lib/engine/mem/store/graph_reader.d.ts +2 -2
  21. package/lib/engine/mem/store/memory.d.ts +98 -37
  22. package/lib/engine/mem/store/recall_policy.d.ts +10 -0
  23. package/lib/engine/mem/store/time_filter.d.ts +1 -1
  24. package/lib/engine/mem/trustHeartbeat.d.ts +1 -1
  25. package/lib/engine/mem-contract/config.d.ts +144 -9
  26. package/lib/engine/mem-contract/index.d.ts +2 -0
  27. package/lib/engine/mem-contract/time.d.ts +75 -0
  28. package/lib/engine/mem-contract/tools.d.ts +9 -15
  29. package/lib/engine/mem-contract/trust_curve.d.ts +119 -0
  30. package/lib/engine/mem-contract/types.d.ts +73 -12
  31. package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +10 -0
  32. package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +28 -0
  33. package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +48 -6
  34. package/lib/engine/mem-retrieval/interfaces.d.ts +22 -2
  35. package/lib/engine/mem-retrieval/text_budget.d.ts +31 -0
  36. package/lib/index.js +2349 -665
  37. package/lib/interface-version.json +2 -2
  38. package/lib/types/client/index.d.ts.map +1 -1
  39. package/lib/types/client/paging.d.ts +58 -8
  40. package/lib/types/client/paging.d.ts.map +1 -1
  41. package/lib/types/client/time_filter.d.ts +54 -0
  42. package/lib/types/client/time_filter.d.ts.map +1 -0
  43. package/lib/types/command.d.ts.map +1 -1
  44. package/lib/types/deps_audit.d.ts +88 -0
  45. package/lib/types/deps_audit.d.ts.map +1 -0
  46. package/lib/types/envinit-bootstrap.d.ts +1 -1
  47. package/lib/types/envinit.d.ts +224 -4
  48. package/lib/types/envinit.d.ts.map +1 -1
  49. package/lib/types/hints.d.ts +58 -0
  50. package/lib/types/hints.d.ts.map +1 -1
  51. package/lib/types/index.d.ts.map +1 -1
  52. package/lib/types/remote.d.ts.map +1 -1
  53. package/lib/types/render.d.ts.map +1 -1
  54. package/lib/types/runtime_deps_resource.d.ts +29 -0
  55. package/lib/types/runtime_deps_resource.d.ts.map +1 -0
  56. package/package.json +26 -24
  57. package/res/runtime-deps.yaml +99 -0
package/lib/index.js CHANGED
@@ -12,9 +12,9 @@ import { createHash, randomBytes } from "node:crypto";
12
12
  import { Readable } from "node:stream";
13
13
  import { pipeline } from "node:stream/promises";
14
14
  import { brotliCompressSync, brotliDecompressSync, constants, gunzipSync, gzipSync } from "node:zlib";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
16
  import { AsyncLocalStorage } from "node:async_hooks";
16
17
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
17
- import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  //#region ../contract/lib/types.js
19
19
  /** Shared domain types for avantf-mem. Single source of truth in @avantf/mem-contract. */
20
20
  /**
@@ -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
@@ -174,6 +314,186 @@ const DEGRADED_LEG_NOTE = "本次仅词法腿,语义腿未就绪(语义后
174
314
  * with no error, so the adapter bounds the text first and warns once (DESIGN §20).
175
315
  */
176
316
  const maxInputTokens = z.number().int().nonnegative().default(0);
317
+ /**
318
+ * One registry/cache endpoint a self-heal section's attempts may name.
319
+ *
320
+ * SHARED by `semantic.self_heal` (transformers.js) and `semantic.native_self_heal` (the native
321
+ * packages): the base `runtime-deps` provider takes ONE source vocabulary, and two spellings of it
322
+ * would be two ways to get the same install wrong.
323
+ *
324
+ * Mirrors the provider's `DependencySource` (interface v5) without importing it: this package is
325
+ * also used by the CLI/MCP processes, which never load the base. An empty `cache` on a source with
326
+ * no registry means "the family root's npm cache" — the plugin fills in `<home>/cache/npm` when it
327
+ * builds the item, because this schema does not know the family root.
328
+ */
329
+ const selfHealSourceSchema = z.object({
330
+ id: z.string().min(1),
331
+ registry: z.string().default(""),
332
+ cache: z.string().default("")
333
+ });
334
+ /**
335
+ * One install attempt, in the base provider's vocabulary. SHARED by both self-heal sections.
336
+ *
337
+ * `env` exists mainly for `ONNXRUNTIME_NODE_INSTALL=skip`: `onnxruntime-node`'s postinstall download
338
+ * returns 302 on some mirrors, and skipping that check is enough for transformers.js to load its
339
+ * WASM/CPU fallback.
340
+ *
341
+ * `allowScripts` is explicit in this plugin's defaults: the base provider defaults it to `false`
342
+ * (lifecycle scripts from a registry package are a caller decision), and `onnxruntime-node` needs its
343
+ * postinstall unless the attempt sets `ONNXRUNTIME_NODE_INSTALL=skip`. Leaving it unset here would
344
+ * turn every default attempt into an `--ignore-scripts` install and break the native binary. The same
345
+ * holds — more strongly — for the native section: `nodejieba` and `hnswlib-node` are node-gyp builds
346
+ * whose ONLY artefact is produced by the install script.
347
+ */
348
+ const selfHealAttemptSchema = z.object({
349
+ source: z.string().min(1),
350
+ manager: z.enum(["npm", "pnpm"]).optional(),
351
+ env: z.record(z.string(), z.string()).default({}),
352
+ allowScripts: z.boolean().optional()
353
+ });
354
+ /**
355
+ * One entry of the STARTUP DEPENDENCY AUDIT (`audit:` in `res/runtime-deps.yaml`): what to observe,
356
+ * how loud to say it, and under which condition — a session notice, plus the probe that decides
357
+ * whether it is due.
358
+ *
359
+ * WHY IT IS DATA, NOT CODE. The interesting question at the end of a mount is "which of the optional
360
+ * things this build is made of can this process actually load?" — and the set differs per RELEASE (a
361
+ * native binding added or dropped, a mirror changed, a sentence reworded). Writing those names and
362
+ * sentences in code would make the implementation carry a deployment's vocabulary. So the plugin
363
+ * implements two GENERIC probe kinds, and the shipped declaration says which pieces exist and what a
364
+ * session is told about them. No package name, body, severity or condition is written in code.
365
+ *
366
+ * `kind` selects the probe:
367
+ *
368
+ * - `module` — resolve AND LOAD `specifier` (a `require`/`import`, so a native addon's binding is
369
+ * really loaded, not just found), from a ready self-heal root first and from this tree otherwise.
370
+ * States: `available` (loaded), `failed` (resolved, but loading it threw — the classic node-gyp
371
+ * build that exists and does not work), `missing` (nothing resolved).
372
+ * - `runtime-deps` — the STATE of the named `item` (`mem:semantic-deps`, `mem:native-deps`, or any
373
+ * other runtime-deps item): `available` (ready), `failed`, `missing`, `skipped`, `pending`.
374
+ *
375
+ * `when` is matched against that state, and the word means the same thing for both kinds:
376
+ * `failed` → `failed`; `missing` → `missing` or `skipped`; `degraded` → any of the three. An entry
377
+ * that is `pending` matches NOTHING: a line latched while a background install is still running would
378
+ * outlive the success it is waiting for. (`pending` only occurs for `runtime-deps`, whose item may be
379
+ * mid-install; a `module` probe is a single synchronous load and cannot be pending.)
380
+ *
381
+ * `min_vectors` (optional, `0`/omitted = every corpus) holds the line back until the live vector
382
+ * index reaches that size — the corpus-size knob an ANN entry needs (below the switch-over point the
383
+ * brute-force store is the intended backend, so a missing ANN binding is not a defect). It is a
384
+ * literal in the declaration, deliberately not derived from `vectorStore.auto_thresholds.hnswlib`:
385
+ * code that computed it would be branching on a package, which is what this section exists to avoid.
386
+ *
387
+ * `id` must be namespaced (`mem:…`) — it is the unit of the once-per-session latch, so an id shared
388
+ * with another plugin would silently suppress one of the two lines. The base registry warns and
389
+ * ignores a malformed one instead of throwing, and so this schema rejects it up front.
390
+ */
391
+ const dependencyAuditFields = {
392
+ id: z.string().min(1),
393
+ severity: z.enum([
394
+ "info",
395
+ "warning",
396
+ "degraded"
397
+ ]),
398
+ when: z.enum([
399
+ "failed",
400
+ "missing",
401
+ "degraded"
402
+ ]),
403
+ min_vectors: z.number().int().nonnegative().optional(),
404
+ body: z.string().min(1)
405
+ };
406
+ /** One audit entry. `kind` decides which probe runs, and which of `specifier`/`item` is required. */
407
+ const DependencyAuditEntrySchema = z.discriminatedUnion("kind", [z.object({
408
+ kind: z.literal("module"),
409
+ /** The npm specifier to load (`nodejieba`, `hnswlib-node`, `@huggingface/transformers`, …). */
410
+ specifier: z.string().min(1),
411
+ ...dependencyAuditFields
412
+ }), z.object({
413
+ kind: z.literal("runtime-deps"),
414
+ /** The runtime-deps item whose state is observed (`mem:semantic-deps`, `mem:native-deps`, …). */
415
+ item: z.string().min(1),
416
+ ...dependencyAuditFields
417
+ })]);
418
+ /**
419
+ * One `runtime-deps` declaration as SHIPPED WITH THE PLUGIN (`packages/plugin/res/runtime-deps.yaml`).
420
+ *
421
+ * WHY IT IS A RESOURCE AND NOT USER CONFIG. The package set, the sources, the attempts, the verify
422
+ * list and the audit sentences are facts about THIS BUILD: which module the local backend imports,
423
+ * which native bindings this release looks for, what a session should be told when one of them is
424
+ * absent. Leaving them to `configs/common.yaml` made every deployment responsible for keeping that
425
+ * list correct — a stale copy would install a module no code loads, or silently drop the audit — and
426
+ * the shipped defaults were only ever the plugin's own declaration anyway. The operator keeps the one
427
+ * thing that really is a per-deployment decision (the two `enabled` switches); the DATA travels in the
428
+ * package, next to the code that reads it.
429
+ *
430
+ * Validation is strict on purpose: the file is read from the INSTALL directory at mount, and a
431
+ * malformed one is a packaging bug. The plugin logs it and degrades (no self-heal item, no audit)
432
+ * rather than throwing out of the mount — see `packages/plugin/src/runtime_deps_resource.ts`.
433
+ *
434
+ * The schema is the SINGLE definition of the file's shape: the loader validates with it, and the
435
+ * plugin maps it to the base provider's objects field by field (the base never reads YAML).
436
+ */
437
+ const RuntimeDepsResourceSchema = z.object({
438
+ /** Format version, so a future shape can be recognized instead of misread. */
439
+ version: z.literal(1),
440
+ /** `@huggingface/transformers` — the semantic leg's optional module. */
441
+ semantic: z.object({
442
+ packages: z.record(z.string(), z.string()),
443
+ sources: z.array(selfHealSourceSchema),
444
+ attempts: z.array(selfHealAttemptSchema),
445
+ verify: z.array(z.string())
446
+ }),
447
+ /** The native optional dependencies (`nodejieba`, `hnswlib-node`). */
448
+ native: z.object({
449
+ packages: z.record(z.string(), z.string()),
450
+ sources: z.array(selfHealSourceSchema),
451
+ attempts: z.array(selfHealAttemptSchema),
452
+ verify: z.array(z.string())
453
+ }),
454
+ /**
455
+ * What the startup dependency audit observes, and what a session is told. Each entry is one probe
456
+ * (`kind`) plus its delivery decision — see {@link DependencyAuditEntrySchema}.
457
+ */
458
+ audit: z.array(DependencyAuditEntrySchema)
459
+ });
460
+ /**
461
+ * Semantic dependency self-heal (`@huggingface/transformers` + transitive `onnxruntime-node`).
462
+ *
463
+ * **OFF by default** on purpose: enabling it means the plugin may run a package-manager install at
464
+ * startup, i.e. network access before anyone asked for it. A profile that wants the repair opts in
465
+ * (`semantic.self_heal.enabled: true`); without it the runtime keeps its existing optional-dependency
466
+ * resolution and degrades to FTS+entity when the package is absent.
467
+ *
468
+ * The SPEC itself (packages / sources / attempts / verify) is NOT here: it ships in
469
+ * `res/runtime-deps.yaml` (`RuntimeDepsResourceSchema`). This section is the operator's policy — the
470
+ * only per-deployment decision left, because it costs a startup install.
471
+ */
472
+ const semanticSelfHealSchema = z.object({ enabled: z.boolean().default(false) });
473
+ /**
474
+ * NATIVE dependency self-heal (`nodejieba`, `hnswlib-node`) — the optional npm dependencies whose
475
+ * absence degrades a FEATURE rather than the plugin:
476
+ *
477
+ * - `nodejieba` → POS tagging; without it entity/relation extraction falls back to a regex pass
478
+ * (weaker entity + jaccard legs, still functional),
479
+ * - `hnswlib-node` → the ANN vector index; without it queries stay on the brute-force store (exact,
480
+ * slower once the corpus grows).
481
+ *
482
+ * Both are node-gyp packages, so their shipped `attempts` allow install scripts (see
483
+ * `res/runtime-deps.yaml`): an `--ignore-scripts` install of either produces a `require()` that fails
484
+ * at run time, which is the one outcome a "self-heal" must not produce.
485
+ *
486
+ * One item, two packages, ONE store root per spec: the base's `runtime-deps` item is
487
+ * content-addressed, so this spec (a different package set from the semantic one) lands in a
488
+ * DIFFERENT immutable root under `<home>/deps/runtime/<key>/` — never the transformers root, whose
489
+ * `node_modules` does not carry either native build.
490
+ *
491
+ * OFF by default, for the same reason as `self_heal`: enabling it means a package-manager install
492
+ * (and a native compile) at startup. What a session is told about a missing package is an `audit`
493
+ * entry in the same resource file, judged on the package itself and therefore working with or without
494
+ * a repair.
495
+ */
496
+ const nativeSelfHealSchema = z.object({ enabled: z.boolean().default(false) });
177
497
  const semanticSchema = z.object({
178
498
  backend: z.string().default("local_bge"),
179
499
  local_model: z.string().default("Xenova/bge-base-zh-v1.5"),
@@ -193,7 +513,14 @@ const semanticSchema = z.object({
193
513
  * stay, but nothing re-encodes on its own. Set to `false` on a metered/embedded host that must not
194
514
  * spend CPU on background re-encoding.
195
515
  */
196
- auto_migrate: z.boolean().default(true)
516
+ auto_migrate: z.boolean().default(true),
517
+ /** Repair a missing `@huggingface/transformers` install through the base's shared runtime store. */
518
+ self_heal: semanticSelfHealSchema.prefault({}),
519
+ /**
520
+ * Repair a missing NATIVE optional dependency (`nodejieba` / `hnswlib-node`) through the same
521
+ * base mechanism, in its OWN store root (see {@link nativeSelfHealSchema}).
522
+ */
523
+ native_self_heal: nativeSelfHealSchema.prefault({})
197
524
  });
198
525
  const vectorStoreSchema = z.object({
199
526
  backend: z.string().default("auto"),
@@ -341,40 +668,83 @@ const presenceSchema = z.object({
341
668
  /** Long-lived surfaces (plugin / MCP) tick this often; 0 = startup pass only. */
342
669
  heartbeat_minutes: z.number().int().nonnegative().default(60)
343
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
+ */
344
679
  const trustSchema = z.object({
345
- /** 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). */
346
681
  enabled: z.boolean().default(true),
347
- /** Initial trust of a new fact. */
348
- start: z.number().min(0).max(1).default(.5),
349
- /** Linear decay per active day: `0.5 / 90` ⇒ 90 active days from start to 0. */
350
- 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),
351
707
  /** Trust ≤ this ⇒ forgotten (archived 'forgot'). 0 = "reaching zero is forgetting". */
352
708
  forget_threshold: z.number().min(0).max(1).default(0),
353
- /** Reaching this via explicit feedback promotes to permanent (snapped to 1.0). */
354
- permanent_threshold: z.number().min(0).max(1).default(.9),
355
- /** A recall of something at/below this raises it straight back to this value. */
356
- recall_floor: z.number().min(0).max(1).default(.5),
357
- /** Per-recall gain (before marginal decay). */
358
- 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),
359
717
  /** Effective reinforcement events per fact per 24h (R10: zero-gain ones don't count). */
360
718
  recall_daily_cap: z.number().int().nonnegative().default(3),
361
- /** 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 … */
362
720
  recall_marginal_decay: z.number().min(0).max(1).default(1),
363
- /** Recall alone can never exceed this (and never pins — see D11). */
364
- recall_ceiling: z.number().min(0).max(1).default(.85),
365
- /** Explicit `helpful` / `unhelpful` step. */
366
- feedback_delta: z.number().min(0).max(1).default(.05),
367
- /** 0 = unlimited; >0 also caps feedback reinforcement per fact per 24h. */
368
- feedback_daily_cap: z.number().int().nonnegative().default(0),
369
721
  /** A revised fact inherits the settled trust (and pin) of the row it replaces. */
370
722
  inherit_trust_on_update: z.boolean().default(true),
371
723
  /** Pinned (permanent) facts are never physically purged. */
372
724
  purge_skips_pinned: z.boolean().default(true),
373
- /** Calendar fallback: unused for this many days ⇒ archived 'idle'. */
374
- idle_calendar_days: z.number().int().positive().default(365),
375
725
  /** Per-tick budget for the settle sweep (the other sweeps only touch critical rows). */
376
726
  tick_max_facts: z.number().int().positive().default(5e3),
377
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
+ }
378
748
  });
379
749
  const lifecycleSchema = z.object({
380
750
  /** Purge window, measured in ACTIVE days since `archived_clock`. */
@@ -571,8 +941,6 @@ const KIND_FILTER_DESCRIPTION = `限定记忆层;可选(取值:${MEMORY_KI
571
941
  const SOURCE_REF_DESCRIPTION = "这条事实的来源(可选):未给 = 未知来源,引擎不会臆造。用 `kind:` 前缀标明来源类型——`session:<会话 id>`(会话供述)、`tool:<工具名>`(工具产出)、无前缀则记为 `manual`(调用方手工给出),前缀之后的文本原样保存。可用 `mem_admin` 的 `list`(`source=`)反查同一来源产生了哪些事实。";
572
942
  /** P-13 `event_date`: the event time, written into `valid_from`. */
573
943
  const EVENT_DATE_DESCRIPTION = "这件事发生的时间(可选,ISO 日期或日期时间,如 2026-10-06 或 2026-10-06T09:30:00+08:00):写入事实的事件时间,供时间窗检索与展示使用;不参与打分。未给 = 未知(不写)。";
574
- /** P-13 `valid_until`: a known end, written into `valid_to` WITHOUT archiving the row. */
575
- const VALID_UNTIL_DESCRIPTION = "这条事实有效到什么时候(可选,ISO 日期或日期时间):写入有效期终点,**不会归档该事实**——「仍然有效、但已知截止于某时」的事实照常参与检索。未给 = 未知(不写)。";
576
944
  /**
577
945
  * `title`: the short, human-readable handle of a journal entry (JOURNAL_TITLE_SEARCH §2.4/§11.1).
578
946
  *
@@ -696,9 +1064,12 @@ function modelFacingToolResult(toolKey, action, value) {
696
1064
  * - `source_ref` — the citation the answer must carry;
697
1065
  * - `created_at` / `updated_at` — memory MAINTENANCE: when it was written and when it was last
698
1066
  * changed is what tells the model whether a fact is fresh enough to act on (and `updated_at`
699
- * differing from `created_at` is the only visible sign that it was revised);
700
- * - `valid_from` / `valid_to` — when the fact was true (P-07), which is what an "as of when"
701
- * 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;
702
1073
  * - `title` — the journal entry's short handle, for recognition and for the follow-up write flow
703
1074
  * (an update inherits it). Display only: it never moved this hit's `score`;
704
1075
  * - `scores` / `final` — only ever present when the caller explicitly asked for the evidence.
@@ -718,7 +1089,6 @@ const MODEL_HIT_FIELDS = [
718
1089
  "created_at",
719
1090
  "updated_at",
720
1091
  "valid_from",
721
- "valid_to",
722
1092
  "scores",
723
1093
  "final"
724
1094
  ];
@@ -726,14 +1096,17 @@ const MODEL_HIT_FIELDS = [
726
1096
  const NULLABLE_HIT_FIELDS = /* @__PURE__ */ new Set([
727
1097
  "updated_at",
728
1098
  "valid_from",
729
- "valid_to",
730
1099
  "title"
731
1100
  ]);
732
1101
  /**
733
1102
  * The envelope a recall result carries besides its rows. Two of these are only meaningful in one
734
- * mode — `total`/`offset`/`limit`/`truncated` describe an EXACT page, `degraded` describes a fuzzy
735
- * pass — and the projection keeps whichever are present, so one function serves both shapes
736
- * (RECALL_INTERFACE §5).
1103
+ * mode — `total`/`offset`/`limit`/`remaining`/`truncated` describe a paged answer (`remaining` only
1104
+ * exists on a pure time window, which pages at 50), `degraded` describes a fuzzy pass — and the
1105
+ * projection keeps whichever are present, so one function serves every shape (RECALL_INTERFACE §5).
1106
+ *
1107
+ * `remaining` is here for a specific reason: "how much of the window did I not see" is a fact the
1108
+ * model must act on (narrow the window, or page on), and leaving it out left only `truncated: true`
1109
+ * with the size of the gap to be guessed.
737
1110
  *
738
1111
  * `weights`, `floors`, `dropped_by_floor` and `used_tokens` are the operator's diagnostics: a
739
1112
  * threshold the caller cannot set and a drop count it cannot act on are pure tokens in the model's
@@ -744,6 +1117,7 @@ const RESULT_ENVELOPE_FIELDS = [
744
1117
  "total",
745
1118
  "offset",
746
1119
  "limit",
1120
+ "remaining",
747
1121
  "truncated"
748
1122
  ];
749
1123
  /** Copy the envelope fields a payload actually carries, in a fixed order. */
@@ -975,10 +1349,8 @@ const RememberUnion = z.discriminatedUnion("action", [
975
1349
  content: z.string().min(1).describe("要写入或替换的事实内容:一句自包含的陈述。action=add 时必填。"),
976
1350
  kind: z.enum(MEMORY_KINDS).optional().describe(KIND_WRITE_DESCRIPTION),
977
1351
  title: z.string().min(1).max(120).optional().describe(TITLE_WRITE_DESCRIPTION),
978
- ttl_days: z.number().int().nonnegative().optional().describe("有效期天数(0 或省略 = 不设有效期;正整数 = 自写入起该天数后自动归档)。update 未给则继承被改写事实的 TTL。"),
979
1352
  source_ref: z.string().min(1).optional().describe(SOURCE_REF_DESCRIPTION),
980
1353
  event_date: z.string().min(1).optional().describe(EVENT_DATE_DESCRIPTION),
981
- valid_until: z.string().min(1).optional().describe(VALID_UNTIL_DESCRIPTION),
982
1354
  relations: z.array(RelationInputSchema).max(20).optional().describe(RELATIONS_DESCRIPTION)
983
1355
  }),
984
1356
  z.object({
@@ -988,24 +1360,14 @@ const RememberUnion = z.discriminatedUnion("action", [
988
1360
  kind: z.enum(MEMORY_KINDS).optional().describe(`改写后的${KIND_WRITE_DESCRIPTION}`),
989
1361
  title: z.string().min(1).max(120).optional().describe(`改写后的${TITLE_WRITE_DESCRIPTION}`),
990
1362
  retitle: z.literal(true).optional().describe(RETITLE_DESCRIPTION),
991
- ttl_days: z.number().int().nonnegative().optional().describe("新的有效期天数(0 = 取消有效期);可选,未给则继承被改写事实的 TTL。"),
992
1363
  source_ref: z.string().min(1).optional().describe(SOURCE_REF_DESCRIPTION),
993
1364
  event_date: z.string().min(1).optional().describe(EVENT_DATE_DESCRIPTION),
994
- valid_until: z.string().min(1).optional().describe(VALID_UNTIL_DESCRIPTION),
995
1365
  relations: z.array(RelationInputSchema).max(20).optional().describe(`改写后的${RELATIONS_DESCRIPTION}`)
996
1366
  }),
997
1367
  z.object({
998
1368
  action: z.literal("remove"),
999
1369
  fact_id: z.number().int().positive().describe("要删除的事实 ID。action=remove 时必填。"),
1000
1370
  reason: z.string().optional().describe("删除原因;可选。")
1001
- }),
1002
- z.object({
1003
- action: z.literal("helpful"),
1004
- fact_id: z.number().int().positive().describe("对回答有帮助的事实 ID。action=helpful 时必填。")
1005
- }),
1006
- z.object({
1007
- action: z.literal("unhelpful"),
1008
- fact_id: z.number().int().positive().describe("对回答无帮助的事实 ID。action=unhelpful 时必填。")
1009
1371
  })
1010
1372
  ]);
1011
1373
  /**
@@ -1028,9 +1390,13 @@ function modelSubset(union, actions) {
1028
1390
  return z.discriminatedUnion("action", options);
1029
1391
  }
1030
1392
  /**
1031
- * The writes the model may make. `helpful` / `unhelpful` are operator feedback: the model cannot
1032
- * observe the retention clocks they move, and a model that could would be able to "refresh" a fact by
1033
- * 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.
1034
1400
  */
1035
1401
  const MODEL_REMEMBER_ACTIONS = [
1036
1402
  "add",
@@ -1068,9 +1434,16 @@ const TITLE_INCLUDE_EXCERPT_DESCRIPTION = "是否返回内容摘要(可选,
1068
1434
  * The exact page's own field descriptions. Their numbers are the documented ones (page 10, cap 50)
1069
1435
  * and they apply ONLY to an exact result — the fuzzy pass is a fixed shortlist of five, so a caller
1070
1436
  * that got no exact hit cannot page it.
1437
+ *
1438
+ * `limit`'s DEFAULT is the one place the pure time window differs: a window is a LIST, so it is
1439
+ * paged at the contract's maximum (50) instead of the exact page's ten (core `timePage`,
1440
+ * `TIME_DEFAULT_LIMIT`), and its result reports `remaining` — how much of the window the page does
1441
+ * not show. The schema's `.max(50)` is unchanged: an omitted `limit` never goes through it, so the
1442
+ * default is a number the ENGINE picks, not a value the caller could send. `titles` keeps its own
1443
+ * description (unchanged).
1071
1444
  */
1072
1445
  const SEARCH_OFFSET_DESCRIPTION = "分页偏移(从 0 开始);可选,默认 0。只作用于分页类结果(search 的 exact/time/facts);模糊检索不接受分页。";
1073
- const SEARCH_LIMIT_DESCRIPTION = "返回条数上限(1-50);可选,默认 10。只作用于分页类结果(search 的 exact/time/facts);模糊检索固定返回 5 条。";
1446
+ const SEARCH_LIMIT_DESCRIPTION = "返回条数上限(1-50);可选,默认 10(纯时间召回即只给 `time` 时默认 50;该方法的结果另带 `remaining`=窗口内还有多少条未显示)。只作用于分页类结果(search 的 exact/time/facts);模糊检索固定返回 5 条。";
1074
1447
  const TITLES_LIMIT_DESCRIPTION = "返回条数上限(1-50);可选,默认 10。只作用于标题完整相等的精确结果;模糊标题检索固定返回 5 条。";
1075
1448
  const SEARCH_QUERY_DESCRIPTION = "检索文本(1–" + String(MAX_QUERY_CHARS) + " 字符)。已给 `time` 时可省略——只给 `time` 就是纯时间召回(`mode=time`:按 `time.field` 从新到旧,不再做字面/事实/关系/模糊检索)。";
1076
1449
  /**
@@ -1089,7 +1462,7 @@ const SearchRequestSchema = z.object({
1089
1462
  source: z.string().optional().describe("限定来源(`fact_sources.ref`):来源等于该值的事实才参与本次检索;可选,不参与打分(未给 = 不过滤)。"),
1090
1463
  offset: z.number().int().nonnegative().optional().describe(SEARCH_OFFSET_DESCRIPTION),
1091
1464
  limit: z.number().int().positive().max(50).optional().describe(SEARCH_LIMIT_DESCRIPTION),
1092
- max_tokens: z.number().int().nonnegative().optional().describe("本次结果的总 token 上限;0=不限制。缺省用配置 retrieval.max_output_tokens。"),
1465
+ max_tokens: z.number().int().nonnegative().optional().describe("本次结果的总 token 上限;0=不限制。缺省:exact/facts/模糊用配置 retrieval.max_output_tokens;纯时间召回不套预算。"),
1093
1466
  floors: z.enum(FLOOR_PROFILES).optional().describe(FLOORS_FIELD_DESCRIPTION),
1094
1467
  include_scores: z.boolean().optional().describe(INCLUDE_SCORES_DESCRIPTION),
1095
1468
  time: TimeFilterSchema.optional().describe("时间过滤(可选):内容 ∩ 时间。硬过滤——窗口内的事实不会被窗口外的高分结果挤掉;给了它就按它,不再叠隐式时间窗。没给 `query` 时必填:时间窗就是整个问题(纯时间召回)。")
@@ -1194,7 +1567,7 @@ const ModelAdminUnion = modelSubset(AdminUnion, [
1194
1567
  const REMEMBER_TOOL = {
1195
1568
  key: "remember",
1196
1569
  name: "mem_remember",
1197
- 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 纠正一次——旧标题保留为别名,用旧标题检索仍能命中。",
1198
1571
  input: ModelRememberUnion
1199
1572
  };
1200
1573
  const RECALL_TOOL = {
@@ -1220,7 +1593,7 @@ Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER;
1220
1593
  *
1221
1594
  * Descriptions are authored per action ("action=update 时必填"), so taking the
1222
1595
  * first branch's text verbatim told a model that `fact_id` only matters for
1223
- * `update` although `remove/helpful/unhelpful` require it too. The action-clause
1596
+ * `update` although `remove` requires it too. The action-clause
1224
1597
  * is stripped and replaced by the derived set of actions that make the field
1225
1598
  * required.
1226
1599
  *
@@ -1534,6 +1907,96 @@ function adoptWellFormed(kit) {
1534
1907
  };
1535
1908
  }
1536
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
1537
2000
  //#region lib/types/prompt.js
1538
2001
  /**
1539
2002
  * The one system-prompt section this plugin contributes (DESIGN §10).
@@ -1743,7 +2206,7 @@ function warn$1(log, message) {
1743
2206
  log?.warn(message);
1744
2207
  } catch {}
1745
2208
  }
1746
- function reasonOf$1(error) {
2209
+ function reasonOf$3(error) {
1747
2210
  return error instanceof Error ? error.message : String(error);
1748
2211
  }
1749
2212
  /**
@@ -1775,7 +2238,7 @@ async function loadGuarded(options, loadOnce, log, words) {
1775
2238
  try {
1776
2239
  return await loadOnce(options);
1777
2240
  } catch (error) {
1778
- warn$1(log, `${words.prefix} WARNING — environment initialisation failed (${reasonOf$1(error)}); ${words.loadFailure}`);
2241
+ warn$1(log, `${words.prefix} WARNING — environment initialisation failed (${reasonOf$3(error)}); ${words.loadFailure}`);
1779
2242
  return;
1780
2243
  }
1781
2244
  }
@@ -1844,6 +2307,12 @@ function stillActive(ctx) {
1844
2307
  * `agent/pre-step` listener), and the harness shows such a message in the trace as coming from the
1845
2308
  * user. Without an author, a line like "记忆里有相关内容" reads as something the user said. The
1846
2309
  * `[avantf-mem]` prefix is what keeps the provenance honest.
2310
+ *
2311
+ * The step's injection is not only this hint: the plugin's DEPENDENCY NOTICES (the `audit` entries of
2312
+ * the shipped `res/runtime-deps.yaml`) ride in the same message ({@link stepInjection}). They are a
2313
+ * different producer of text with the same delivery rule — at most once per owner session, only on a
2314
+ * step that already carried a user message — so merging them keeps ONE message per step instead of
2315
+ * two competing ones.
1847
2316
  */
1848
2317
  const RELEVANCE_HINT = "[avantf-mem] 记忆里有与上条用户消息相关的内容;需要时用 `mem_recall` 检索。";
1849
2318
  /**
@@ -1879,6 +2348,92 @@ function messageText(message) {
1879
2348
  return b?.type === "text" && typeof b.text === "string" ? b.text : "";
1880
2349
  }).filter(Boolean).join("\n");
1881
2350
  }
2351
+ /**
2352
+ * The text of the ONE message this step injects: the session notices, then the memory hint.
2353
+ *
2354
+ * `''` means "inject nothing" — the loop's turn-end guard is `decision.messages.length === 0`, so an
2355
+ * empty injection has to stay empty rather than become an empty user message.
2356
+ *
2357
+ * Order: notices first. They report a capability that is not working (a repair the operator asked for
2358
+ * did not finish), which is the louder statement; the hint is an invitation. Neither repeats within a
2359
+ * session: the notice registry latches per session id, and the hint is one line by construction.
2360
+ *
2361
+ * Each body is used AS CONFIGURED, with one exception that is about YAML and not about editing: a
2362
+ * block scalar (`body: |`) ends with a newline, and keeping it would insert a blank line between the
2363
+ * notice and the hint. Trailing whitespace is therefore dropped; nothing else is rewritten, and a
2364
+ * whitespace-only body is skipped rather than rendered as an empty paragraph.
2365
+ */
2366
+ function stepInjection(hint, notices) {
2367
+ const lines = [];
2368
+ for (const line of notices) {
2369
+ if (typeof line !== "string") continue;
2370
+ const body = line.trimEnd();
2371
+ if (body.trim() === "") continue;
2372
+ lines.push(body);
2373
+ }
2374
+ if (hint) lines.push(RELEVANCE_HINT);
2375
+ return lines.join("\n");
2376
+ }
2377
+ /**
2378
+ * The session-notice lines for this step, or `[]` when there are none to deliver.
2379
+ *
2380
+ * Three rules, all of them the caller's to make and this plugin's to keep:
2381
+ *
2382
+ * 1. **`ownerOnly` is a CLAIM, not a lookup.** The base registry cannot know which session owns the
2383
+ * plugin, so it serves only a call that claims it ({@link isOwnerSession} decides), and a
2384
+ * non-claiming call latches nothing — a worker/subagent session therefore never consumes the line
2385
+ * out from under the owner session.
2386
+ * 2. **Nothing is cached here.** `consume()` re-reads the item's real state on every step, so a
2387
+ * dependency that healed stops matching and a NEW session simply sees nothing — "fixed ⇒ silent"
2388
+ * needs no invalidation of its own.
2389
+ * 3. **Never throws.** This runs on the prompt path of a live agent: a hostile/mismatched registry
2390
+ * costs one skipped line, never the step.
2391
+ */
2392
+ function noticeLines(registry, sessionId, owner) {
2393
+ if (registry === void 0 || sessionId === "") return [];
2394
+ try {
2395
+ const lines = registry.consume(sessionId, { ownerOnly: owner });
2396
+ if (!Array.isArray(lines)) return [];
2397
+ return lines.filter((line) => typeof line === "string");
2398
+ } catch {
2399
+ return [];
2400
+ }
2401
+ }
2402
+ /**
2403
+ * Whether `sessionId` is the OWNER session — a TOP-LEVEL agent, not a worker/subagent.
2404
+ *
2405
+ * Read STRUCTURALLY off the host's agent registry (`ctx.agents`), deliberately without a dependency
2406
+ * on `@deepseek-ai/dsh-agent`: the only question is "is this id one of the roots", and the answer
2407
+ * decides a claim the base registry would otherwise have to invent a role for. A subagent session is
2408
+ * created under its parent's context, so it is never a root — and a mission executor is a subagent
2409
+ * too. Those sessions keep working (memory tools, retrieval), they just do not receive the owner's
2410
+ * repair notice.
2411
+ *
2412
+ * "Cannot tell" DELIVERS (`true`): a host with no agent registry, an older registry without `roots()`,
2413
+ * a throwing read — none of those prove the session is a worker, and swallowing a real notice on a
2414
+ * guess would be the worse failure. This mirrors the family rule that only a PROVEN condition may
2415
+ * take a capability away.
2416
+ */
2417
+ function isOwnerSession(agents, sessionId) {
2418
+ if (sessionId === "") return false;
2419
+ let roots;
2420
+ try {
2421
+ roots = agents?.roots;
2422
+ } catch {
2423
+ return true;
2424
+ }
2425
+ if (typeof roots !== "function") return true;
2426
+ try {
2427
+ const list = roots.call(agents);
2428
+ if (!Array.isArray(list)) return true;
2429
+ return list.some((agent) => {
2430
+ const id = agent?.id;
2431
+ return id !== void 0 && id !== null && String(id) === sessionId;
2432
+ });
2433
+ } catch {
2434
+ return true;
2435
+ }
2436
+ }
1882
2437
  //#endregion
1883
2438
  //#region ../core/lib/config/paths.js
1884
2439
  /**
@@ -1966,6 +2521,11 @@ const DEFAULT_COMMON_CONFIG = `# avantf-mem 公共配置(分层第②层)。
1966
2521
  # dim: 768
1967
2522
  # auto_download: true
1968
2523
  # auto_migrate: true # 换模型/宽度后自动分批重算旧空间的向量(默认开);关掉只剩启动告警 + vectors --fix
2524
+ # self_heal: # 语义依赖自愈(默认关):允许启动期安装缺失的 @huggingface/transformers
2525
+ # enabled: false # 打开后走底座 runtime-deps 共享根(base ≥ 0.7.0 / interface v6);失败只降级到 FTS+实体
2526
+ # # 装哪个包 / 从哪装 / 提示说什么:见插件自带的 res/runtime-deps.yaml(随包发布)
2527
+ # native_self_heal: # 原生可选依赖自愈:nodejieba / hnswlib-node(默认关)
2528
+ # enabled: false # 装哪个包、从哪装、提示说什么,都由插件自带的 res/runtime-deps.yaml 决定
1969
2529
  # ⚠️ cache_dir / mirror 由家族底座 @avantf/dsh-plugin-base 管理:写在这里会被忽略并告警。
1970
2530
  # 缓存目录用环境变量 AVANTF_MEM_MODEL_CACHE,镜像用 AVANTF_MEM_MODEL_MIRROR(或 HF_ENDPOINT)。
1971
2531
 
@@ -2003,8 +2563,13 @@ const DEFAULT_COMMON_CONFIG = `# avantf-mem 公共配置(分层第②层)。
2003
2563
  # purge_after_archived_days: 365 # 归档事实的物理清理窗口(活跃日)
2004
2564
  # contradiction_threshold: 0.6
2005
2565
 
2006
- # trust:
2007
- # 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)
2008
2573
  # presence:
2009
2574
  # heartbeat_minutes: 60 # 常驻进程的心跳(活跃日时钟与生命周期扫描)
2010
2575
 
@@ -2107,16 +2672,60 @@ function zodShape(schema) {
2107
2672
  }
2108
2673
  }
2109
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
+ /**
2110
2715
  * Warn once about unknown config keys — at the ROOT level and inside known sections
2111
2716
  * (TRUST_MODEL.md §7 / review R15+R23). This is generic typo protection, not legacy
2112
2717
  * compatibility: `vector_store` or `semantics` are exactly the mistakes it catches.
2113
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
+ *
2114
2722
  * Goes to the injected logger, not the process-wide default: this is the one message
2115
2723
  * that tells an operator their config file is being partly ignored, and the DSH host
2116
2724
  * only surfaces what the plugin's logger carries.
2117
2725
  */
2118
2726
  function warnUnknownKeys(source, raw, rootShape, logger) {
2119
2727
  const unknown = [];
2728
+ const explained = [];
2120
2729
  for (const [key, value] of Object.entries(raw)) {
2121
2730
  const section = rootShape[key];
2122
2731
  if (section === void 0) {
@@ -2125,8 +2734,15 @@ function warnUnknownKeys(source, raw, rootShape, logger) {
2125
2734
  }
2126
2735
  const childShape = zodShape(section);
2127
2736
  if (childShape === void 0 || !isPlainObject(value)) continue;
2128
- 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
+ }
2129
2744
  }
2745
+ for (const message of explained) logger.warn(`${source}: ${message}`);
2130
2746
  if (unknown.length > 0) logger.warn(`${source}: unknown config key(s) ignored: ${unknown.join(", ")} — check the spelling against docs/DESIGN.md §3`);
2131
2747
  }
2132
2748
  /** Section-aware merge: top-level sections merge one level deep, scalars/arrays replace. */
@@ -2688,6 +3304,49 @@ function ensureAllAsync(options) {
2688
3304
  //#endregion
2689
3305
  //#region ../core/lib/entities/extract.js
2690
3306
  /**
3307
+ * Entity extraction: POS tagging and the noun-ish names that become `entities` rows.
3308
+ *
3309
+ * Uses nodejieba (optional dependency) for POS; degrades gracefully to a character/regex fallback
3310
+ * when unavailable, exactly like the `jieba_available()` guard.
3311
+ *
3312
+ * Note: nodejieba's `tag()` returns `{ word, tag }` objects, and its POS flag set
3313
+ * differs from Python jieba (e.g. proper nouns → `nr`, 名动词 → `vn`).
3314
+ *
3315
+ * WHAT THIS MODULE IS NOT. It owns what is an entity NAME — nothing about relations. The relation
3316
+ * half (SVO candidate triples, explicit patterns, the name/statement/confidence gates and the
3317
+ * predicate vocabulary) lives in `relations/`; the two halves share exactly one thing, the tagging
3318
+ * pass ({@link tagText}), so a write pays for the dictionary once and drives both.
3319
+ *
3320
+ * WHERE THE MODULE COMES FROM. `nodejieba` is an optional npm dependency, so it is normally resolved
3321
+ * from this tree. A host that provisions it instead (the DSH plugin's `runtime-deps` self-heal) sets
3322
+ * the shared root with {@link useTokenizerModuleDir}; the load then resolves from THAT root first and
3323
+ * falls back to this tree, which is the pre-existing behaviour and also the recovery path for a root
3324
+ * that was garbage-collected or damaged. Nothing here decides whether a root should exist.
3325
+ */
3326
+ /**
3327
+ * The shared module root the tokenizer should be loaded from, when the caller provisioned one.
3328
+ *
3329
+ * `undefined` (the default, and every CLI/MCP/test caller) = this tree's own optional dependency.
3330
+ * Set by {@link useTokenizerModuleDir} from the DSH plugin once the native self-heal item is READY;
3331
+ * a failed resolution from it falls back to this tree rather than degrading the tokenizer.
3332
+ */
3333
+ let moduleRoot$1;
3334
+ /**
3335
+ * Point the optional tokenizer at a shared module root (the base `runtime-deps` handle's directory).
3336
+ *
3337
+ * The memo follows the ROOT: changing it forgets whatever the previous root produced, so the next
3338
+ * load resolves from the new one — that is the whole point of setting a root. The cost is at most one
3339
+ * extra dictionary parse (~1 s, once) when a root arrives after a lazily loaded copy from this tree;
3340
+ * Node's own module cache keeps the import itself cheap. The plugin only sets this once, when its
3341
+ * native self-heal item settles.
3342
+ */
3343
+ function useTokenizerModuleDir(dir) {
3344
+ const next = dir === "" ? void 0 : dir;
3345
+ if (next === moduleRoot$1) return;
3346
+ moduleRoot$1 = next;
3347
+ jiebaLoad = void 0;
3348
+ }
3349
+ /**
2691
3350
  * The load, memoized as a PROMISE — not as its result.
2692
3351
  *
2693
3352
  * `mod.load()` parses nodejieba's dictionary synchronously on the main thread (~1 s measured), and
@@ -2698,28 +3357,67 @@ function ensureAllAsync(options) {
2698
3357
  * time — measured 2143 ms for two concurrent callers against 1000 ms for one.
2699
3358
  *
2700
3359
  * A FAILED load is cached too (the same one-attempt policy as before): a process whose jieba cannot
2701
- * be imported uses the regex fallback for its lifetime rather than retrying on every write.
3360
+ * be imported uses the regex fallback for its lifetime rather than retrying on every write. The one
3361
+ * exception is a module root that arrives afterwards — see {@link useTokenizerModuleDir}.
2702
3362
  */
2703
3363
  let jiebaLoad;
2704
3364
  /** How many times the dictionary was actually parsed in this process (see `jiebaLoadAttempts`). */
2705
3365
  let jiebaAttempts = 0;
3366
+ /** The root the LAST attempt resolved from (`undefined` = this tree); reported by {@link jiebaStatus}. */
3367
+ let jiebaAttemptedRoot;
3368
+ /** Why the last attempt failed, one short line — what makes an "unavailable" log line diagnosable. */
3369
+ let jiebaError;
2706
3370
  function loadJieba() {
2707
3371
  jiebaLoad ??= loadJiebaOnce();
2708
3372
  return jiebaLoad;
2709
3373
  }
3374
+ /**
3375
+ * Import `nodejieba` from the shared root, else from this tree.
3376
+ *
3377
+ * The shared root's own `package.json` is the resolution anchor (the same rule the semantic adapter
3378
+ * uses for transformers.js): its `node_modules` is what the provider installed, and importing by FILE
3379
+ * URL means this root's copy is the one loaded even when this tree also carries the package. A root
3380
+ * that no longer resolves is not an error to report here — the fallback below is the behaviour, and
3381
+ * the caller's dependency audit is what tells an operator about it.
3382
+ */
3383
+ async function importTokenizer(root) {
3384
+ if (root !== void 0) try {
3385
+ return await import(pathToFileURL(createRequire(join(root, "package.json")).resolve("nodejieba")).href);
3386
+ } catch {}
3387
+ return await import("nodejieba");
3388
+ }
3389
+ /** nodejieba's export shape, normalised (CJS through `import()` may land on `default`). */
3390
+ function tokenizerOf(raw) {
3391
+ const mod = typeof raw?.tag === "function" ? raw : raw?.default ?? raw;
3392
+ return mod !== null && typeof mod?.tag === "function" ? mod : null;
3393
+ }
2710
3394
  async function loadJiebaOnce() {
2711
3395
  jiebaAttempts += 1;
3396
+ const root = moduleRoot$1;
3397
+ jiebaAttemptedRoot = root;
2712
3398
  try {
2713
- const raw = await import("nodejieba");
2714
- const mod = typeof raw.tag === "function" ? raw : raw.default ?? raw;
3399
+ const mod = tokenizerOf(await importTokenizer(root));
3400
+ if (mod === null) throw new Error("nodejieba 的 tag() 不可用");
2715
3401
  if (typeof mod.load === "function") mod.load();
3402
+ jiebaError = void 0;
2716
3403
  return mod;
2717
- } catch {
3404
+ } catch (error) {
3405
+ jiebaError = error instanceof Error ? error.message : String(error);
3406
+ if (moduleRoot$1 !== root) jiebaLoad = void 0;
2718
3407
  return null;
2719
3408
  }
2720
3409
  }
2721
- async function jiebaAvailable() {
2722
- return await loadJieba() !== null;
3410
+ /** The status of the memoized load, starting one only when none has run yet. */
3411
+ async function jiebaProbe() {
3412
+ await loadJieba();
3413
+ const load = jiebaLoad;
3414
+ return {
3415
+ available: load !== void 0 && await load !== null,
3416
+ attempts: jiebaAttempts,
3417
+ attemptedRoot: jiebaAttemptedRoot,
3418
+ root: moduleRoot$1,
3419
+ error: jiebaError
3420
+ };
2723
3421
  }
2724
3422
  const NOUN_FLAGS = /* @__PURE__ */ new Set([
2725
3423
  "n",
@@ -2899,6 +3597,32 @@ async function awaitStartupGate(options = {}) {
2899
3597
  return await whenEventLoopIdle();
2900
3598
  }
2901
3599
  /**
3600
+ * The line one tokenizer observation deserves.
3601
+ *
3602
+ * `previous` is what makes the pair readable: a `ready` after a `degraded` is a RECOVERY (the
3603
+ * earlier probe ran before a loadable copy existed — the provisioned root had not been handed over,
3604
+ * or the package was not installed yet), not a change of story. A `degraded` line never claims more
3605
+ * than it knows: it names the reason and the two ways to fix it, and says that a loadable copy
3606
+ * arriving later is picked up automatically.
3607
+ *
3608
+ * Exported for the test that pins those transitions; it has no side effects.
3609
+ */
3610
+ function tokenizerReport(status, elapsedMs, previous) {
3611
+ if (status.available) return {
3612
+ level: "info",
3613
+ message: `model bootstrap: tokenizer ready in ${elapsedMs}ms (nodejieba${status.attemptedRoot === void 0 ? "" : ", provisioned root"})${previous === "degraded" ? " — recovered: the earlier \"unavailable\" line described a probe that ran before a loadable copy existed, not this state" : ""}`,
3614
+ next: "ready"
3615
+ };
3616
+ const reason = status.error === void 0 || status.error === "" ? "nodejieba could not be loaded" : status.error;
3617
+ return {
3618
+ level: "warn",
3619
+ message: `model bootstrap: nodejieba unavailable in ${elapsedMs}ms — ${status.attemptedRoot === void 0 ? `no loadable nodejieba in this tree (${reason})` : `the provisioned root ${status.attemptedRoot} cannot load nodejieba (${reason})`}${previous === "ready" ? " (the copy that was loadable earlier is no longer resolvable — a provisioned root replaced it, or it was removed)" : ""}; entity/triple extraction runs on the regex path (weaker entity + jaccard legs) until a loadable copy exists — install nodejieba, or enable semantic.native_self_heal to have the native item provision it (a provisioned root that arrives later is picked up automatically)`,
3620
+ next: "degraded"
3621
+ };
3622
+ }
3623
+ /** The last verdict reported in THIS process; see {@link TokenizerVerdict}. */
3624
+ let tokenizerVerdict = "unknown";
3625
+ /**
2902
3626
  * Warm ONLY the tokenizer, after the host-readiness signal and (when asked) an idle event loop.
2903
3627
  *
2904
3628
  * nodejieba loads its dictionary lazily on the FIRST `tag()` (measured ~950 ms, after which a call
@@ -2914,8 +3638,10 @@ async function warmTokenizer(rt, options = {}) {
2914
3638
  const waited = await awaitStartupGate(options);
2915
3639
  if (options.deferTokenizerUntilIdle === true && waited >= 200) rt.logger.info(`model bootstrap: tokenizer warm waited ${waited}ms for an idle event loop`);
2916
3640
  const jiebaStarted = Date.now();
2917
- if (await jiebaAvailable()) rt.logger.info(`model bootstrap: tokenizer ready in ${Date.now() - jiebaStarted}ms (nodejieba)`);
2918
- else rt.logger.warn(`model bootstrap: nodejieba unavailable in ${Date.now() - jiebaStarted}ms — entity/triple extraction falls back to the regex path (weaker entity + jaccard legs)`);
3641
+ const report = tokenizerReport(await jiebaProbe(), Date.now() - jiebaStarted, tokenizerVerdict);
3642
+ tokenizerVerdict = report.next;
3643
+ if (report.level === "warn") rt.logger.warn(report.message);
3644
+ else rt.logger.info(report.message);
2919
3645
  }
2920
3646
  /**
2921
3647
  * Non-blocking embedding warm, for a caller that knows the model files are already on disk.
@@ -3097,7 +3823,7 @@ async function runToolSpec$1(rt, spec, args) {
3097
3823
  //#region ../core/lib/trustHeartbeat.js
3098
3824
  /**
3099
3825
  * The trust heartbeat: the interval beat that advances the active-day clock and performs the
3100
- * 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
3101
3827
  * server.
3102
3828
  *
3103
3829
  * The two surfaces used to install it separately, and the copies drifted in exactly the places a
@@ -3525,6 +4251,69 @@ function truncateToTokens(text, maxTokens, opts = {}) {
3525
4251
  };
3526
4252
  }
3527
4253
  /**
4254
+ * Slice `text` into windows of at most `maxTokens` estimated tokens instead of truncating it.
4255
+ *
4256
+ * WHY THIS EXISTS. The window belongs to the loaded model and the call site cannot widen it, so a
4257
+ * text past it has exactly one of two fates: cut (what {@link truncateToTokens} does, and what
4258
+ * transformers.js does silently on its own) or split. A knowledge chunk of ~1000 characters against
4259
+ * a 512-token model lost half its content from the vector that way — measured in production as
4260
+ * "503 tokens dropped here". Splitting keeps every token in the representation; the adapter that
4261
+ * can only return ONE vector then pools the window vectors (`LocalBgeBackend.encode`).
4262
+ *
4263
+ * Every returned window satisfies `estimate(window) <= maxTokens` (a one-character window is the
4264
+ * floor; the estimator cannot exceed a limit of 1 token for a single character). Cuts land on a
4265
+ * sentence/clause boundary when one is in the window's final quarter, so a window does not start or
4266
+ * end mid-word.
4267
+ *
4268
+ * @param text - the input.
4269
+ * @param maxTokens - the per-window budget (already net of special tokens, as the adapter computes it).
4270
+ * @param opts - estimator override (tests use a flat one); defaults to {@link estimateTokens}.
4271
+ */
4272
+ function splitToWindows(text, maxTokens, opts = {}) {
4273
+ const estimate = opts.estimate ?? estimateTokens;
4274
+ const limit = Math.max(1, Math.floor(maxTokens));
4275
+ const tokens = estimate(text);
4276
+ const chars = Array.from(text);
4277
+ if (chars.length === 0) return {
4278
+ windows: [text],
4279
+ tokens,
4280
+ windowTokens: [tokens]
4281
+ };
4282
+ if (tokens <= limit) return {
4283
+ windows: [text],
4284
+ tokens,
4285
+ windowTokens: [tokens]
4286
+ };
4287
+ const windows = [];
4288
+ const windowTokens = [];
4289
+ let start = 0;
4290
+ while (start < chars.length) {
4291
+ let used = 0;
4292
+ let end = start;
4293
+ while (end < chars.length) {
4294
+ const cost = CJK.test(chars[end]) ? 1 : chars[end].trim() === "" ? 0 : .25;
4295
+ if (used + cost > limit && end > start) break;
4296
+ used += cost;
4297
+ end += 1;
4298
+ }
4299
+ const floor = start + Math.floor((end - start) * .75);
4300
+ for (let i = end - 1; i >= floor; i--) if (BREAK.has(chars[i])) {
4301
+ end = i + 1;
4302
+ break;
4303
+ }
4304
+ while (end - start > 1 && estimate(chars.slice(start, end).join("")) > limit) end -= 1;
4305
+ const window = chars.slice(start, end).join("");
4306
+ windows.push(window);
4307
+ windowTokens.push(estimate(window));
4308
+ start = end;
4309
+ }
4310
+ return {
4311
+ windows,
4312
+ tokens,
4313
+ windowTokens
4314
+ };
4315
+ }
4316
+ /**
3528
4317
  * Resolve the effective input window for a loaded model.
3529
4318
  *
3530
4319
  * @param configured - `0` (or negative) means "auto: use the model's declared window".
@@ -3548,6 +4337,14 @@ function declaredWindowOf(pipe) {
3548
4337
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > SENTINEL_WINDOW) return void 0;
3549
4338
  return Math.floor(value);
3550
4339
  }
4340
+ /**
4341
+ * Characters of a chunk that fit the window for `window` tokens, minus the per-chunk
4342
+ * overhead (`specialTokens` for CLS/SEP). Used to derive `knowledge.chunk_size` so the
4343
+ * default chunking is justified by the shipped model's window instead of by feel.
4344
+ */
4345
+ function windowChars(window, specialTokens = 2) {
4346
+ return Math.max(1, Math.floor((window - specialTokens) / 1));
4347
+ }
3551
4348
  //#endregion
3552
4349
  //#region ../retrieval-core/lib/budget.js
3553
4350
  /**
@@ -3760,6 +4557,10 @@ var LocalBgeBackend = class {
3760
4557
  };
3761
4558
  return this._representation;
3762
4559
  }
4560
+ /** Point the loader at a shared runtime-dependency root before the first warmup. */
4561
+ useModuleDir(dir) {
4562
+ this.env.moduleDir = dir;
4563
+ }
3763
4564
  /**
3764
4565
  * Fire-and-forget retry for call sites that only observe availability. No-op
3765
4566
  * while the model is ready or an attempt is already in flight; otherwise
@@ -3808,42 +4609,118 @@ var LocalBgeBackend = class {
3808
4609
  return this._window;
3809
4610
  }
3810
4611
  /**
3811
- * Bound `text` to the window BEFORE it reaches transformers.js.
4612
+ * The window-sized slices `text` is encoded in (see `text_budget.splitToWindows`).
3812
4613
  *
3813
- * This is the only place the guard can live: the feature-extraction pipeline's `_call`
3814
- * accepts `{pooling, normalize, quantize, precision}` and nothing else, so `max_length`
3815
- * cannot be raised or lowered from the call site — past `model_max_length` the tokenizer
3816
- * truncates and says nothing.
4614
+ * WHERE THE BOUNDARY CAN LIVE. The feature-extraction pipeline's `_call` accepts
4615
+ * `{pooling, normalize, quantize, precision}` and nothing else, so `max_length` cannot be raised
4616
+ * from the call site — past `model_max_length` transformers.js truncates and says nothing. This
4617
+ * adapter is therefore the only place that can apply the model's own window, and it SPLITS rather
4618
+ * than cuts: a text past the window is a document chunk (or a long fact) whose tail is content,
4619
+ * and cutting it dropped 503 of ~1015 tokens out of the vector in production.
3817
4620
  */
3818
- bound(text) {
3819
- const out = truncateToTokens(text, Math.max(1, this.window() - SPECIAL_TOKENS));
3820
- if (out.truncated) {
3821
- recordTruncation("embedding");
3822
- if (!this._lengthWarned) {
3823
- this._lengthWarned = true;
3824
- retrievalLogger().warn(`semantic: input exceeded the ${this.window()}-token window of ${this.model} — text is truncated before encoding (${out.omittedTokens} tokens dropped here); check knowledge.chunk_size and semantic.max_input_tokens`);
3825
- }
3826
- }
3827
- return out;
4621
+ windows(text) {
4622
+ return splitToWindows(text, Math.max(1, this.window() - SPECIAL_TOKENS));
3828
4623
  }
3829
- async encode(text) {
3830
- await this.warmUp();
3831
- if (!this._available || !this._pipe) throw new Error("local_bge:语义后端不可用(请安装 @huggingface/transformers 并等模型下载完成)");
3832
- const bounded = this.bound(text);
3833
- const out = await this._pipe(bounded.text, {
3834
- pooling: this.pooling,
3835
- normalize: this.normalize
3836
- });
3837
- const arr = Array.from(out.data);
3838
- if (arr.length !== this.dim && !this._dimWarned) {
4624
+ /**
4625
+ * Report an input that needed more than one window — once per process, and as an INFO line.
4626
+ *
4627
+ * Not a warning any more: nothing is lost (every window is pooled into the one vector), so the
4628
+ * line is a tuning hint, not a degradation. It carries the actionable number: `chunk_size` is
4629
+ * measured in CHARACTERS and {@link windowChars} converts the model's token window into exactly
4630
+ * that, which is the thing an operator (or a chunker's default) has to change to keep one window
4631
+ * per input. `semantic.max_input_tokens` is deliberately NOT suggested: `resolveWindow` clamps it
4632
+ * to the model's declared window, so raising it cannot buy a single extra token.
4633
+ */
4634
+ reportSplit(count, tokens) {
4635
+ recordTruncation("embedding");
4636
+ if (this._lengthWarned) return;
4637
+ this._lengthWarned = true;
4638
+ retrievalLogger().info(`semantic: input of ~${tokens} tokens exceeds the ${this.window()}-token window of ${this.model} — encoded as ${count} windows and pooled, nothing dropped (one pass per input needs knowledge.chunk_size within ${windowChars(this.window())} characters for this model)`);
4639
+ }
4640
+ /** The configured width's vector: model output padded/truncated, with the one-shot dim warning. */
4641
+ toVector(values) {
4642
+ if (values.length !== this.dim && !this._dimWarned) {
3839
4643
  this._dimWarned = true;
3840
- retrievalLogger().warn(`semantic: model ${this.model} outputs dim=${arr.length} but config semantic.dim=${this.dim} — vectors are truncated/padded; set semantic.dim to ${arr.length} and reindex`);
4644
+ retrievalLogger().warn(`semantic: model ${this.model} outputs dim=${values.length} but config semantic.dim=${this.dim} — vectors are truncated/padded; set semantic.dim to ${values.length} and reindex`);
3841
4645
  }
3842
4646
  const vec = new Float32Array(this.dim);
3843
- for (let i = 0; i < this.dim && i < arr.length; i++) vec[i] = arr[i];
4647
+ for (let i = 0; i < this.dim && i < values.length; i++) vec[i] = values[i];
3844
4648
  return vec;
3845
4649
  }
3846
4650
  /**
4651
+ * ONE vector for several windows: token-weighted mean of the window vectors, re-normalized when the
4652
+ * representation normalizes.
4653
+ *
4654
+ * Weighted rather than a plain mean because the last window is usually short, and a plain mean
4655
+ * would over-represent a fragment. Mean-of-normalized-vectors then re-normalized is the mean
4656
+ * DIRECTION of the windows — the same coordinate space the model's own mean pooling produces, so
4657
+ * `representationKey` (`pooling`/`normalize`/window) does not move and stored vectors stay
4658
+ * comparable with the ones this writes.
4659
+ */
4660
+ pool(vectors, weights) {
4661
+ const width = vectors[0]?.length ?? 0;
4662
+ if (width === 0 || vectors.length === 0) return new Float32Array(this.dim);
4663
+ const total = weights.reduce((sum, w) => sum + Math.max(0, w), 0);
4664
+ const out = new Float64Array(width);
4665
+ for (let i = 0; i < vectors.length; i++) {
4666
+ const weight = total > 0 ? Math.max(0, weights[i] ?? 0) / total : 1 / vectors.length;
4667
+ const vec = vectors[i];
4668
+ for (let d = 0; d < width; d++) out[d] += (vec[d] ?? 0) * weight;
4669
+ }
4670
+ if (this.normalize) {
4671
+ let norm = 0;
4672
+ for (let d = 0; d < width; d++) norm += out[d] * out[d];
4673
+ norm = Math.sqrt(norm);
4674
+ if (norm > 0) for (let d = 0; d < width; d++) out[d] /= norm;
4675
+ }
4676
+ return this.toVector(Array.from(out));
4677
+ }
4678
+ /** Encode the windows of one text in ONE model call, falling back to one call per window. */
4679
+ async encodeWindows(pipe, windows, weights) {
4680
+ const opts = {
4681
+ pooling: this.pooling,
4682
+ normalize: this.normalize
4683
+ };
4684
+ try {
4685
+ const out = await pipe(windows, opts);
4686
+ const rows = out.dims?.[0];
4687
+ const flat = out.data;
4688
+ if (rows === windows.length) {
4689
+ const width = Math.floor(flat.length / rows);
4690
+ if (width > 0) {
4691
+ const vecs = [];
4692
+ for (let i = 0; i < rows; i++) vecs.push(Array.from(flat.slice(i * width, (i + 1) * width), Number));
4693
+ return this.pool(vecs, weights);
4694
+ }
4695
+ }
4696
+ } catch {}
4697
+ const vecs = [];
4698
+ for (const window of windows) {
4699
+ const out = await pipe(window, opts);
4700
+ vecs.push(Array.from(out.data));
4701
+ }
4702
+ return this.pool(vecs, weights);
4703
+ }
4704
+ /** Encode one plan: a single window verbatim, several pooled. */
4705
+ async encodePlan(pipe, plan) {
4706
+ const first = plan.windows[0];
4707
+ if (plan.windows.length <= 1) {
4708
+ const out = await pipe(first ?? "", {
4709
+ pooling: this.pooling,
4710
+ normalize: this.normalize
4711
+ });
4712
+ return this.toVector(Array.from(out.data));
4713
+ }
4714
+ this.reportSplit(plan.windows.length, plan.tokens);
4715
+ return await this.encodeWindows(pipe, plan.windows, plan.windowTokens);
4716
+ }
4717
+ async encode(text) {
4718
+ await this.warmUp();
4719
+ const pipe = this._pipe;
4720
+ if (!this._available || pipe === null) throw new Error("local_bge:语义后端不可用(请安装 @huggingface/transformers 并等模型下载完成)");
4721
+ return await this.encodePlan(pipe, this.windows(text));
4722
+ }
4723
+ /**
3847
4724
  * Encode several texts in ONE model call.
3848
4725
  *
3849
4726
  * This used to be a serial loop over `encode()`, and production code never called it — so
@@ -3856,11 +4733,22 @@ var LocalBgeBackend = class {
3856
4733
  if (texts.length === 0) return [];
3857
4734
  if (texts.length === 1) return [await this.encode(texts[0])];
3858
4735
  await this.warmUp();
3859
- if (!this._available || !this._pipe) throw new Error("local_bge:语义后端不可用(请安装 @huggingface/transformers 并等模型下载完成)");
3860
- const sorted = texts.map((text, index) => ({
3861
- text,
3862
- index
3863
- })).sort((a, b) => a.text.length - b.text.length);
4736
+ const pipe = this._pipe;
4737
+ if (!this._available || pipe === null) throw new Error("local_bge:语义后端不可用(请安装 @huggingface/transformers 并等模型下载完成)");
4738
+ const plans = texts.map((text) => this.windows(text));
4739
+ const singles = [];
4740
+ const longs = [];
4741
+ plans.forEach((plan, index) => {
4742
+ if (plan.windows.length <= 1) singles.push({
4743
+ text: plan.windows[0] ?? texts[index],
4744
+ index
4745
+ });
4746
+ else longs.push({
4747
+ plan,
4748
+ index
4749
+ });
4750
+ });
4751
+ const sorted = singles.sort((a, b) => a.text.length - b.text.length);
3864
4752
  const result = new Array(texts.length);
3865
4753
  for (let i = 0; i < sorted.length; i += BATCH_WINDOW) {
3866
4754
  const group = sorted.slice(i, i + BATCH_WINDOW);
@@ -3869,15 +4757,15 @@ var LocalBgeBackend = class {
3869
4757
  result[g.index] = vecs[j];
3870
4758
  });
3871
4759
  }
4760
+ for (const { plan, index } of longs) result[index] = await this.encodePlan(pipe, plan);
3872
4761
  return result;
3873
4762
  }
3874
4763
  /** One model call for a length-homogeneous group; falls back to per-text calls on failure. */
3875
4764
  async encodeGroup(texts) {
3876
4765
  const pipe = this._pipe;
3877
4766
  if (pipe === null) throw new Error("local_bge:语义后端不可用");
3878
- const bounded = texts.map((t) => this.bound(t).text);
3879
4767
  try {
3880
- const out = await pipe(bounded, {
4768
+ const out = await pipe(texts, {
3881
4769
  pooling: this.pooling,
3882
4770
  normalize: this.normalize
3883
4771
  });
@@ -3904,12 +4792,32 @@ var LocalBgeBackend = class {
3904
4792
  }
3905
4793
  };
3906
4794
  async function createFeatureExtraction(model, env) {
3907
- const mod = await import("@huggingface/transformers");
4795
+ const mod = await loadTransformers(env.moduleDir);
3908
4796
  if (!mod.pipeline) throw new Error("transformers.js 的 pipeline 不可用");
3909
4797
  applyModelEnv(mod, env);
3910
4798
  return await mod.pipeline("feature-extraction", model);
3911
4799
  }
3912
4800
  /**
4801
+ * Load transformers.js from the shared runtime-dependency root, else from this tree.
4802
+ *
4803
+ * The shared root is the base provider's content-addressed install (`<home>/deps/runtime/<key>`), and
4804
+ * its `package.json` is the anchor `createRequire` needs: resolving `@huggingface/transformers` from
4805
+ * there picks the copy the provider verified, together with its transitive `onnxruntime-node` — no
4806
+ * second copy, and no dependence on this bundle's own node_modules. The import is by file URL because
4807
+ * the resolved entry is an ESM file inside the root, not a bare specifier this module could resolve.
4808
+ *
4809
+ * A root that no longer resolves (GC, damaged tree, a package whose entry moved) is a WARNING and the
4810
+ * pre-existing optional resolution: a broken self-heal must not be worse than no self-heal.
4811
+ */
4812
+ async function loadTransformers(moduleDir) {
4813
+ if (moduleDir !== void 0 && moduleDir.trim() !== "") try {
4814
+ return await import(pathToFileURL(createRequire(join(moduleDir, "package.json")).resolve("@huggingface/transformers")).href);
4815
+ } catch (error) {
4816
+ retrievalLogger().warn(`semantic: could not load @huggingface/transformers from the shared runtime root ${moduleDir} (${describeError$1(error)}); falling back to this tree's optional dependency`);
4817
+ }
4818
+ return await import("@huggingface/transformers");
4819
+ }
4820
+ /**
3913
4821
  * Apply this project's cache/mirror policy to transformers.js.
3914
4822
  *
3915
4823
  * `mod.env` is PROCESS-GLOBAL and last-writer-wins. Setting the keys from a single place is what
@@ -4007,6 +4915,60 @@ function dot(a, b) {
4007
4915
  //#region ../retrieval-core/lib/adapters/hnswlib.js
4008
4916
  const require = createRequire(import.meta.url);
4009
4917
  /**
4918
+ * The shared module root `hnswlib-node` should be loaded from, when the caller provisioned one.
4919
+ *
4920
+ * `undefined` (every CLI/MCP/direct caller) = this tree's own optional dependency. The DSH plugin
4921
+ * sets it from the native self-heal item's `handle.dir` once that item is ready; a root that no
4922
+ * longer resolves falls back to this tree, which is the pre-existing behaviour and must never be
4923
+ * worse than no self-heal.
4924
+ */
4925
+ let moduleRoot;
4926
+ /**
4927
+ * The loaded binding, memoized (`undefined` = not attempted, `null` = the last attempt failed).
4928
+ *
4929
+ * Native addons are expensive to `dlopen` and there is exactly one per process; memoizing also makes
4930
+ * every store in the process shares one load. The cache follows the ROOT (see
4931
+ * {@link useHnswlibModuleDir}), because a root that arrives later is the one event that can turn a
4932
+ * failed load into a working index.
4933
+ */
4934
+ let loaded;
4935
+ /**
4936
+ * Point the loader at a shared module root (the base `runtime-deps` handle's directory).
4937
+ *
4938
+ * The cache follows the ROOT: a change forgets what was loaded, because the next `require` must come
4939
+ * from the new root (that is the whole point of setting one). It costs nothing in practice — Node's
4940
+ * own `require` cache makes re-entering the same file cheap, and a live native index is untouched
4941
+ * (see `HnswlibVectorStore.useModuleDir`, which only rebuilds when it has no native index at all).
4942
+ */
4943
+ function useHnswlibModuleDir(dir) {
4944
+ const next = dir === "" ? void 0 : dir;
4945
+ if (next === moduleRoot) return;
4946
+ moduleRoot = next;
4947
+ loaded = void 0;
4948
+ }
4949
+ /** Does this module actually carry the constructor this adapter needs? */
4950
+ function usable(mod) {
4951
+ return typeof mod?.HierarchicalNSW === "function";
4952
+ }
4953
+ /** `require('hnswlib-node')` from the shared root, else from this tree; `null` when neither works. */
4954
+ function loadHnswlib() {
4955
+ if (loaded !== void 0) return loaded;
4956
+ let mod = null;
4957
+ if (moduleRoot !== void 0) try {
4958
+ const entry = createRequire(join(moduleRoot, "package.json")).resolve("hnswlib-node");
4959
+ const candidate = require(entry);
4960
+ if (usable(candidate)) mod = candidate;
4961
+ } catch {}
4962
+ if (mod === null) try {
4963
+ const candidate = require("hnswlib-node");
4964
+ mod = usable(candidate) ? candidate : null;
4965
+ } catch {
4966
+ mod = null;
4967
+ }
4968
+ loaded = mod;
4969
+ return mod;
4970
+ }
4971
+ /**
4010
4972
  * When to COMPACT tombstones away, as a fraction of the live count.
4011
4973
  *
4012
4974
  * Deleted points stay in the graph (excluded from results, but still traversed), so they cost
@@ -4065,15 +5027,41 @@ var HnswlibVectorStore = class {
4065
5027
  this.dim = dim;
4066
5028
  this.efSearch = Math.max(16, Math.floor(efSearch));
4067
5029
  this.fallback = new LocalNumpyVectorStore(dim);
5030
+ this.lib = this.openNative(Math.max(1024, dim * 2));
5031
+ }
5032
+ /**
5033
+ * Build a native index at `max` capacity, or `null` when the binding cannot be loaded.
5034
+ *
5035
+ * One place for "make an index": the constructor, `reindex()` and `rebuild()` each used to spell
5036
+ * the `require` out themselves, which is how a module root set after construction could never
5037
+ * reach them. `null` is the caller's signal to serve from {@link fallback} — a missing binding
5038
+ * degrades, it never throws.
5039
+ */
5040
+ openNative(max) {
5041
+ const mod = loadHnswlib();
5042
+ if (mod === null) return null;
4068
5043
  try {
4069
- const { HierarchicalNSW } = require("hnswlib-node");
4070
- const lib = new HierarchicalNSW("cosine", dim);
4071
- lib.initIndex(Math.max(1024, dim * 2), 16, 100);
4072
- this.lib = lib;
5044
+ const lib = new mod.HierarchicalNSW("cosine", this.dim);
5045
+ lib.initIndex(Math.max(1024, max), 16, 100);
5046
+ return lib;
4073
5047
  } catch {
4074
- this.lib = null;
5048
+ return null;
4075
5049
  }
4076
5050
  }
5051
+ /**
5052
+ * Point this store's native side at a shared module root and, when the binding was unavailable
5053
+ * until now, rebuild the index from the tracked vectors under it.
5054
+ *
5055
+ * This is the case the whole hook exists for: `mem` builds its vector store during mount, while the
5056
+ * native self-heal item is still installing, so the constructor may have fallen back. When the item
5057
+ * settles, the plugin calls this — and a store that already serves natively is left alone (the
5058
+ * binding is the same one).
5059
+ */
5060
+ useModuleDir(dir) {
5061
+ useHnswlibModuleDir(dir);
5062
+ if (this.lib !== null) return;
5063
+ this.rebuild(this.liveRows());
5064
+ }
4077
5065
  add(id, vec) {
4078
5066
  if (vec.length !== this.dim) throw new Error(`向量维度不匹配:得到 ${vec.length},需要 ${this.dim}`);
4079
5067
  if (this.lib) try {
@@ -4229,10 +5217,8 @@ var HnswlibVectorStore = class {
4229
5217
  /** Recreate the serving index (native when possible) from the tracked vectors. */
4230
5218
  reindex() {
4231
5219
  const stored = [...this.vectors.entries()];
4232
- if (this.lib) try {
4233
- const { HierarchicalNSW } = require("hnswlib-node");
4234
- const lib = new HierarchicalNSW("cosine", this.dim);
4235
- lib.initIndex(Math.max(1024, (stored.length || 1) * 2), 16, 100);
5220
+ const lib = this.openNative(Math.max(1024, (stored.length || 1) * 2));
5221
+ if (lib !== null) {
4236
5222
  this.lib = lib;
4237
5223
  this.efOn = null;
4238
5224
  for (const [i, v] of stored) this.lib.addPoint(Array.from(v), i, false);
@@ -4240,9 +5226,8 @@ var HnswlibVectorStore = class {
4240
5226
  this.rebuilds += 1;
4241
5227
  this.persist();
4242
5228
  return;
4243
- } catch {
4244
- this.lib = null;
4245
5229
  }
5230
+ this.lib = null;
4246
5231
  this.fallback = new LocalNumpyVectorStore(this.dim);
4247
5232
  for (const [i, v] of stored) this.fallback.add(i, v);
4248
5233
  this.graphElements = stored.length;
@@ -4268,15 +5253,8 @@ var HnswlibVectorStore = class {
4268
5253
  for (const r of list) this.vectors.set(r.id, r.vec);
4269
5254
  return;
4270
5255
  }
4271
- if (this.lib) try {
4272
- const { HierarchicalNSW } = require("hnswlib-node");
4273
- const lib = new HierarchicalNSW("cosine", this.dim);
4274
- lib.initIndex(Math.max(1024, (list.length || 1) * 2), 16, 100);
4275
- this.lib = lib;
4276
- this.efOn = null;
4277
- } catch {
4278
- this.lib = null;
4279
- }
5256
+ this.lib = this.openNative(Math.max(1024, (list.length || 1) * 2));
5257
+ if (this.lib !== null) this.efOn = null;
4280
5258
  for (const r of list) this.add(r.id, r.vec);
4281
5259
  this.rebuilds += 1;
4282
5260
  this.persist();
@@ -4454,6 +5432,19 @@ var AutoVectorStore = class {
4454
5432
  this.pendingPersistence = path;
4455
5433
  this.delegate.attachPersistence?.(path);
4456
5434
  }
5435
+ /**
5436
+ * Point the ANN side at a shared module root (the plugin's native self-heal item) — for THIS store
5437
+ * when it already migrated, and for the one it may migrate to later.
5438
+ *
5439
+ * The delegate call covers the first case; the module-level root (set by
5440
+ * `HnswlibVectorStore.useModuleDir` / `useHnswlibModuleDir`) is what a FUTURE
5441
+ * `new HnswlibVectorStore` in `maybeUpgrade` reads. Below the threshold nothing changes: an
5442
+ * unmigrated store keeps serving from brute force, which is the intended backend at that size.
5443
+ */
5444
+ useModuleDir(dir) {
5445
+ if (this.delegate.useModuleDir !== void 0) this.delegate.useModuleDir(dir);
5446
+ else useHnswlibModuleDir(dir);
5447
+ }
4457
5448
  flush() {
4458
5449
  this.delegate.flush?.();
4459
5450
  }
@@ -4821,12 +5812,18 @@ const DDL = [
4821
5812
  conflict_checked INTEGER NOT NULL DEFAULT 0,
4822
5813
  archived_at TIMESTAMP,
4823
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).
4824
5818
  ttl_days INTEGER DEFAULT 0,
4825
5819
  -- P-07/P-13: when the fact became true and when it stopped being true. NULL = unknown, and no
4826
- -- retrieval leg reads either column (the envelope only carries them, and only when non-null).
4827
- -- valid_to is written by mem_remember valid_until WITHOUT archiving the row, which is what
4828
- -- makes "still active, known to end at T" representable at all; the two archival paths
4829
- -- (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.
4830
5827
  valid_from TIMESTAMP,
4831
5828
  valid_to TIMESTAMP,
4832
5829
  -- The INSTANTS those two bounds name, in epoch milliseconds (migration 12).
@@ -4853,10 +5850,8 @@ const DDL = [
4853
5850
  `CREATE INDEX IF NOT EXISTS idx_facts_supersedes ON facts(supersedes_id)`,
4854
5851
  `CREATE INDEX IF NOT EXISTS idx_facts_trust ON facts(status, pinned, settle_clock)`,
4855
5852
  `CREATE INDEX IF NOT EXISTS idx_facts_updated ON facts(status, updated_at)`,
4856
- `CREATE INDEX IF NOT EXISTS idx_facts_ttl ON facts(status, ttl_days)`,
4857
5853
  `CREATE INDEX IF NOT EXISTS idx_facts_purge ON facts(status, pinned, archived_clock)`,
4858
5854
  `CREATE INDEX IF NOT EXISTS idx_facts_forget ON facts(status, pinned, trust_score)`,
4859
- `CREATE INDEX IF NOT EXISTS idx_facts_idle_cutoff ON facts(status, pinned, julianday(COALESCE(last_retrieved_at, created_at)))`,
4860
5855
  `CREATE INDEX IF NOT EXISTS idx_facts_bonus_window ON facts(bonus_window_at)`,
4861
5856
  `CREATE TABLE IF NOT EXISTS fact_sources (
4862
5857
  fact_id INTEGER NOT NULL REFERENCES facts(fact_id) ON DELETE CASCADE,
@@ -7197,7 +8192,7 @@ function timeParams(range) {
7197
8192
  return timeSql(range)?.params ?? [];
7198
8193
  }
7199
8194
  /**
7200
- * 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.
7201
8196
  *
7202
8197
  * This is what the write path stamps into `valid_from_ms` and what migration 12's backfill applies to
7203
8198
  * existing rows, so "the event time a caller wrote" and "the event time a window compares" can never
@@ -8091,6 +9086,14 @@ const MEMORY_SCHEMA = {
8091
9086
  db.exec(TITLE_ALIASES_TABLE_DDL);
8092
9087
  for (const sql of TITLE_ALIASES_INDEXES) db.exec(sql);
8093
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
+ }
8094
9097
  }
8095
9098
  ]
8096
9099
  };
@@ -8343,6 +9346,102 @@ function entityBags(db, ids, q) {
8343
9346
  return out;
8344
9347
  }
8345
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
8346
9445
  //#region ../core/lib/db/dao/facts.js
8347
9446
  /**
8348
9447
  * The column a pure time window orders by — the axis the caller filtered on.
@@ -8380,7 +9479,7 @@ const FACT_COLUMNS_NO_BLOB = `fact_id, content, kind, title, tags, trust_score,
8380
9479
  pinned, pinned_at, bonus_count, bonus_window_at, last_reinforced_at, archived_clock,
8381
9480
  retrieval_count, helpful_count, last_retrieved_at, embedding_model, vector_store, status,
8382
9481
  supersedes_id, entities_version, conflict_checked, archived_at, archive_reason, ttl_days,
8383
- 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`;
8384
9483
  /**
8385
9484
  * The FTS leg's exact SQL, exported so the plan guard (`test/fts_plan.spec.ts`) and the bench
8386
9485
  * control group EXPLAIN the production text itself instead of a copy that can silently drift.
@@ -8698,12 +9797,11 @@ var FactsDao = class FactsDao {
8698
9797
  const out = /* @__PURE__ */ new Map();
8699
9798
  if (!ids.length) return out;
8700
9799
  const { placeholders, values } = inList(ids);
8701
- 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);
8702
9801
  for (const row of rows) out.set(row.fact_id, {
8703
9802
  created_at: row.created_at === null ? "" : String(row.created_at),
8704
9803
  updated_at: row.updated_at === null ? null : String(row.updated_at),
8705
9804
  valid_from: row.valid_from === null || row.valid_from === void 0 ? null : String(row.valid_from),
8706
- valid_to: row.valid_to === null || row.valid_to === void 0 ? null : String(row.valid_to),
8707
9805
  title: row.title === null || row.title === void 0 || row.title === "" ? null : String(row.title)
8708
9806
  });
8709
9807
  return out;
@@ -8743,31 +9841,33 @@ var FactsDao = class FactsDao {
8743
9841
  * `createdAt` is named EXPLICITLY rather than left to the column DEFAULT, because a
8744
9842
  * superseding revision must INHERIT the archived row's `created_at` (the memory's first
8745
9843
  * assertion date, distinct from when its text was rewritten). `updated_at` and
8746
- * `last_retrieved_at` are stamped `CURRENT_TIMESTAMP` in the same statement: a revision
8747
- * whose `created_at` is old must not be judged idle by the next tick, where "idle" reads
8748
- * `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.
8749
9850
  */
8750
9851
  insertRevision(v) {
8751
9852
  return this.db.prepare(`INSERT OR IGNORE INTO facts
8752
- (content, kind, title, ttl_days, supersedes_id, hrr_vector,
9853
+ (content, kind, title, supersedes_id, hrr_vector,
8753
9854
  trust_score, settle_clock, pinned, pinned_at, bonus_count, bonus_window_at,
8754
- entities_version, conflict_checked, valid_from, valid_to, valid_from_ms, valid_to_ms,
9855
+ entities_version, conflict_checked, valid_from, valid_from_ms,
8755
9856
  created_at, updated_at, last_retrieved_at)
8756
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, ?, ?, ?, ?,
8757
- ?, 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);
8758
9859
  }
8759
9860
  /**
8760
- * P-10: land an explicitly given validity on a row that already exists (a duplicate `add` whose
8761
- * caller named `event_date` / `valid_until`). Omitted arguments keep what the row has, exactly
8762
- * 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).
8763
9864
  */
8764
- applyValidity(factId, validFrom, validTo) {
8765
- if (validFrom === void 0 && validTo === void 0) return;
9865
+ applyValidity(factId, validFrom) {
9866
+ if (validFrom === void 0) return;
8766
9867
  this.db.prepare(`UPDATE facts
8767
9868
  SET valid_from = COALESCE(?, valid_from), valid_from_ms = COALESCE(?, valid_from_ms),
8768
- valid_to = COALESCE(?, valid_to), valid_to_ms = COALESCE(?, valid_to_ms),
8769
9869
  updated_at = CURRENT_TIMESTAMP
8770
- 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);
8771
9871
  }
8772
9872
  /** P-10: the row's assertion counter (`1` when the column is somehow NULL on a legacy row). */
8773
9873
  assertCount(factId) {
@@ -8787,17 +9887,6 @@ var FactsDao = class FactsDao {
8787
9887
  return Number(row?.n ?? 1);
8788
9888
  }
8789
9889
  /**
8790
- * P-07 audit: stamp `valid_to` on a row that is leaving the active corpus (supersede / a
8791
- * contradiction verdict). `COALESCE` keeps an end the caller already declared (`valid_until`):
8792
- * overwriting it with the archival instant would replace a real answer with an administrative one.
8793
- */
8794
- markValidTo(factId, at, atMs = null) {
8795
- this.db.prepare(`UPDATE facts
8796
- SET valid_to = COALESCE(valid_to, ?),
8797
- valid_to_ms = CASE WHEN valid_to IS NULL THEN ? ELSE valid_to_ms END
8798
- WHERE fact_id = ?`).run(at, atMs, factId);
8799
- }
8800
- /**
8801
9890
  * P-07: "who superseded this fact", derived from the existing `supersedes_id` link — no
8802
9891
  * redundant column. `MIN` is deterministic when several revisions named the same predecessor
8803
9892
  * (which a merge can produce); the caller renders it as a single direct successor.
@@ -8958,17 +10047,22 @@ var FactsDao = class FactsDao {
8958
10047
  }
8959
10048
  return changes;
8960
10049
  }
8961
- /** Revive an archived row (R18/R19: refresh the idle clock, clear the archive bookkeeping). */
8962
- 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) {
8963
10057
  return this.db.prepare(`UPDATE facts
8964
10058
  SET status = 'active', archived_at = NULL, archive_reason = NULL, archived_clock = NULL,
8965
- kind = ?, ttl_days = ?, trust_score = ?, settle_clock = ?,
10059
+ kind = ?, trust_score = ?, settle_clock = ?,
8966
10060
  last_retrieved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
8967
- 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;
8968
10062
  }
8969
- /** Land explicit kind/TTL on a row that already exists (pure duplicate). */
8970
- applyValues(factId, kind, ttlDays) {
8971
- 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);
8972
10066
  }
8973
10067
  /**
8974
10068
  * Land a title on a row that already exists — the ONE write behind every "backfill" path
@@ -9045,13 +10139,12 @@ var FactsDao = class FactsDao {
9045
10139
  this.db.prepare("UPDATE facts SET supersedes_id = ? WHERE fact_id = ? AND supersedes_id IS NULL").run(supersedesId, factId);
9046
10140
  }
9047
10141
  /**
9048
- * 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).
9049
10143
  *
9050
10144
  * `AND status = 'archived'` is load-bearing, like the guard on every sibling above: this UPDATE
9051
10145
  * clears the archive bookkeeping and refreshes `last_retrieved_at`, so ungated it would take an
9052
- * ACTIVE row and (a) push its idle clock forward — silently extending the life of a fact the next
9053
- * tick was about to re-archive — and (b) reset `trust_score` to the settled value it computed. It
9054
- * 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.
9055
10148
  */
9056
10149
  restore(factId, trust, clock) {
9057
10150
  return this.db.prepare(`UPDATE facts
@@ -9059,32 +10152,6 @@ var FactsDao = class FactsDao {
9059
10152
  trust_score = ?, settle_clock = ?, last_retrieved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
9060
10153
  WHERE fact_id = ? AND status = 'archived'`).run(trust, clock, factId).changes;
9061
10154
  }
9062
- bumpHelpful(factId, delta) {
9063
- this.db.prepare("UPDATE facts SET helpful_count = MAX(0, helpful_count + ?) WHERE fact_id = ?").run(delta, factId);
9064
- }
9065
- /** Feedback outcome: pin at the permanent threshold (trust snaps to 1). */
9066
- pinOutcome(factId, clock, bonusCount, windowAt) {
9067
- this.db.prepare(`UPDATE facts
9068
- SET trust_score = 1, pinned = 1, pinned_at = COALESCE(pinned_at, CURRENT_TIMESTAMP),
9069
- settle_clock = ?, bonus_count = ?, bonus_window_at = ?, archived_clock = NULL,
9070
- last_reinforced_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
9071
- WHERE fact_id = ?`).run(clock, bonusCount, windowAt, factId);
9072
- }
9073
- /** Feedback outcome: the forget line was reached — archive in the same statement. */
9074
- forgetOutcome(factId, trust, clock, bonusCount, windowAt) {
9075
- this.db.prepare(`UPDATE facts
9076
- SET trust_score = ?, settle_clock = ?, bonus_count = ?, bonus_window_at = ?,
9077
- status = 'archived', archived_at = CURRENT_TIMESTAMP, archived_clock = ?,
9078
- archive_reason = 'forgot', updated_at = CURRENT_TIMESTAMP
9079
- WHERE fact_id = ?`).run(trust, clock, bonusCount, windowAt, clock, factId);
9080
- }
9081
- /** Feedback outcome: reinforced (or decayed) but still active. */
9082
- reinforceOutcome(factId, trust, clock, bonusCount, windowAt) {
9083
- this.db.prepare(`UPDATE facts
9084
- SET trust_score = ?, settle_clock = ?, bonus_count = ?, bonus_window_at = ?,
9085
- last_reinforced_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
9086
- WHERE fact_id = ?`).run(trust, clock, bonusCount, windowAt, factId);
9087
- }
9088
10155
  pin(factId, clock) {
9089
10156
  return this.db.prepare(`UPDATE facts
9090
10157
  SET pinned = 1, pinned_at = COALESCE(pinned_at, CURRENT_TIMESTAMP), trust_score = 1,
@@ -9173,17 +10240,25 @@ var FactsDao = class FactsDao {
9173
10240
  return Number(this.db.prepare("SELECT COUNT(*) AS n FROM facts WHERE status = 'active' AND pinned = 1").get()?.n ?? 0);
9174
10241
  }
9175
10242
  /**
9176
- * ACTIVE facts whose decayed trust would reach zero within `withinDays` ACTIVE days —
9177
- * 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.
9178
10256
  */
9179
10257
  countForgettingWithin(p) {
9180
- return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM facts
9181
- WHERE status = 'active' AND pinned = 0
9182
- AND (trust_score - :step * (:clock - settle_clock)) <= :step * :soon`).get({
9183
- step: p.step,
9184
- clock: p.clock,
9185
- soon: p.withinDays
9186
- })?.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;
9187
10262
  }
9188
10263
  /** Facts with a still-live (calendar 24h) reinforcement window — the per-fact quota. */
9189
10264
  /**
@@ -9203,31 +10278,15 @@ var FactsDao = class FactsDao {
9203
10278
  granted: Number(row?.granted ?? 0)
9204
10279
  };
9205
10280
  }
9206
- /** ACTIVE, unpinned facts unused for `days` CALENDAR days (the idle-archival fallback). */
9207
- countIdleCandidates(days) {
9208
- return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM facts
9209
- WHERE status = 'active' AND pinned = 0
9210
- AND julianday(COALESCE(last_retrieved_at, created_at)) < julianday('now', :modifier)`).get({ modifier: `-${days} days` })?.n ?? 0);
9211
- }
9212
10281
  /**
9213
- * 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.
9214
10283
  *
9215
- * One statement over the three predicates (they are alternatives, and the sets are disjoint:
9216
- * a row has either a TTL, a forgotten trust score, or an idle clock). Only called when a step
9217
- * 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.
9218
10287
  */
9219
10288
  countArchiveBacklog(p) {
9220
- const params = {
9221
- forgetThreshold: p.forgetThreshold,
9222
- idleModifier: `-${p.idleCalendarDays} days`
9223
- };
9224
- return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM (
9225
- SELECT fact_id FROM facts WHERE status = 'active' AND ttl_days > 0
9226
- AND julianday('now') - julianday(created_at) > ttl_days
9227
- UNION SELECT fact_id FROM facts WHERE status = 'active' AND pinned = 0 AND trust_score <= :forgetThreshold
9228
- UNION SELECT fact_id FROM facts WHERE status = 'active' AND pinned = 0
9229
- AND julianday(COALESCE(last_retrieved_at, created_at)) < julianday('now', :idleModifier)
9230
- )`).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);
9231
10290
  }
9232
10291
  /**
9233
10292
  * Drop unusable (stale-dim) vectors so a re-encode can replace them — and re-queue the same
@@ -9566,48 +10625,60 @@ var FactsDao = class FactsDao {
9566
10625
  * row, and `settle_clock < c` is a seek + range scan of exactly the pending rows.
9567
10626
  */
9568
10627
  static SETTLE_PREDICATE = "status = 'active' AND pinned = 0 AND settle_clock < CAST(:clock AS INTEGER)";
9569
- /** ① 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
+ */
9570
10654
  settleBudgeted(p) {
9571
- return this.db.prepare(`UPDATE facts
9572
- SET trust_score = MAX(0, MIN(1, trust_score - :step * (:clock - settle_clock))),
9573
- settle_clock = :clock
10655
+ const rows = this.db.prepare(`SELECT fact_id, trust_score, settle_clock FROM facts
9574
10656
  WHERE ${FactsDao.SETTLE_PREDICATE}
9575
- 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);
9576
10662
  }
9577
10663
  /** ① materialize decay for every pending row (no budget). */
9578
10664
  settleAll(p) {
9579
- return this.db.prepare(`UPDATE facts
9580
- SET trust_score = MAX(0, MIN(1, trust_score - :step * (:clock - settle_clock))),
9581
- settle_clock = :clock
9582
- 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;
9583
10675
  }
9584
10676
  /** Rows still awaiting ① (the budget ran out). */
9585
10677
  countPendingSettle(p) {
9586
10678
  return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM facts WHERE ${FactsDao.SETTLE_PREDICATE}`).get(p)?.n ?? 0);
9587
10679
  }
9588
10680
  /**
9589
- * ② calendar TTL — explicit instruction, so `pinned` is NOT exempt.
9590
- *
9591
- * `budget` (0 = none) caps how many rows ONE tick may archive: the tick is a single IMMEDIATE
9592
- * transaction shared by several processes, so an unbudgeted pass over a large backlog held the
9593
- * write lock for as long as it took (measured 14400 rows in one tick). The remainder stays due
9594
- * and is reported as a deferred count, then archived by the next tick.
9595
- */
9596
- archiveExpiredByTtl(p) {
9597
- return this.db.prepare(`UPDATE facts
9598
- SET status = 'archived', archived_at = CURRENT_TIMESTAMP, archived_clock = :clock, archive_reason = 'ttl'
9599
- WHERE status = 'active' AND ttl_days > 0
9600
- AND julianday('now') - julianday(created_at) > ttl_days
9601
- AND (:budget = 0 OR fact_id IN (
9602
- SELECT fact_id FROM facts WHERE status = 'active' AND ttl_days > 0
9603
- AND julianday('now') - julianday(created_at) > ttl_days LIMIT :budget))
9604
- RETURNING fact_id`).all({
9605
- clock: p.clock,
9606
- budget: p.budget ?? 0
9607
- }).map((row) => row.fact_id);
9608
- }
9609
- /**
9610
- * ③ trust-driven forgetting (① already clamped trust to the threshold).
10681
+ * ② trust-driven forgetting (① already clamped trust to the threshold).
9611
10682
  *
9612
10683
  * Sargable as written (`status`/`pinned` equality + `trust_score <= ?`), and served by
9613
10684
  * `idx_facts_forget(status, pinned, trust_score)`: before that index this statement read every
@@ -9616,9 +10687,9 @@ var FactsDao = class FactsDao {
9616
10687
  archiveForgotten(p) {
9617
10688
  return this.db.prepare(`UPDATE facts
9618
10689
  SET status = 'archived', archived_at = CURRENT_TIMESTAMP, archived_clock = :clock, archive_reason = 'forgot'
9619
- WHERE status = 'active' AND pinned = 0 AND trust_score <= :forgetThreshold
10690
+ WHERE ${FactsDao.FORGET_PREDICATE}
9620
10691
  AND (:budget = 0 OR fact_id IN (
9621
- 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))
9622
10693
  RETURNING fact_id`).all({
9623
10694
  clock: p.clock,
9624
10695
  forgetThreshold: p.forgetThreshold,
@@ -9626,29 +10697,7 @@ var FactsDao = class FactsDao {
9626
10697
  }).map((row) => row.fact_id);
9627
10698
  }
9628
10699
  /**
9629
- * ④ calendar fallback: never touched for `idle_calendar_days`.
9630
- *
9631
- * The comparison is written as `<expression> < julianday('now', :modifier)` — the same
9632
- * expression `idx_facts_idle_cutoff` indexes — so it is a range seek. The old form
9633
- * (`julianday('now') - <expression> > :days`) called `julianday` on EVERY active row and could
9634
- * not use any index (measured 33–212 ms at 180k).
9635
- */
9636
- archiveIdle(p) {
9637
- return this.db.prepare(`UPDATE facts
9638
- SET status = 'archived', archived_at = CURRENT_TIMESTAMP, archived_clock = :clock, archive_reason = 'idle'
9639
- WHERE status = 'active' AND pinned = 0
9640
- AND julianday(COALESCE(last_retrieved_at, created_at)) < julianday('now', :idleModifier)
9641
- AND (:budget = 0 OR fact_id IN (
9642
- SELECT fact_id FROM facts WHERE status = 'active' AND pinned = 0
9643
- AND julianday(COALESCE(last_retrieved_at, created_at)) < julianday('now', :idleModifier) LIMIT :budget))
9644
- RETURNING fact_id`).all({
9645
- clock: p.clock,
9646
- idleModifier: `-${p.idleCalendarDays} days`,
9647
- budget: p.budget ?? 0
9648
- }).map((row) => row.fact_id);
9649
- }
9650
- /**
9651
- * ⑤ physical delete of archived rows past the retention window.
10700
+ * ③ physical delete of archived rows past the retention window.
9652
10701
  *
9653
10702
  * TWO statements in this order, sharing one predicate:
9654
10703
  *
@@ -9657,7 +10706,7 @@ var FactsDao = class FactsDao {
9657
10706
  * DELETE below failed with `SQLITE_CONSTRAINT_FOREIGNKEY` whenever a revision chain
9658
10707
  * crossed `purge_after_archived_days`. Because the whole tick is ONE transaction and it
9659
10708
  * runs from the store's constructor, that failure did not merely skip the purge: it rolled
9660
- * 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.
9661
10710
  * 2. delete them.
9662
10711
  *
9663
10712
  * The predicate is built once and interpolated into both statements, so "which rows are
@@ -9702,7 +10751,7 @@ var FactsDao = class FactsDao {
9702
10751
  purgeModifier: `-${p.purgeAfterDays} days`
9703
10752
  };
9704
10753
  }
9705
- /** 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). */
9706
10755
  countPurgeBacklog(p) {
9707
10756
  const [a, b] = FactsDao.purgeBranches(p);
9708
10757
  return Number(this.db.prepare(`SELECT COUNT(*) AS n FROM (
@@ -9795,7 +10844,7 @@ function pathRecency(path) {
9795
10844
  for (const edge of path.edges) newest = Math.max(newest, edgeTime(edge));
9796
10845
  return newest;
9797
10846
  }
9798
- /** Dedup key of a path: the ordered edge ids (§5 "路径去重按 (edge ids)"). */
10847
+ /** Dedup key of a path: the ordered edge ids ("路径去重按 (edge ids)"). */
9799
10848
  function pathKey(path) {
9800
10849
  return path.edges.map((edge) => edge.id).join("\0");
9801
10850
  }
@@ -10799,156 +11848,6 @@ var FactSourcesDao = class {
10799
11848
  }
10800
11849
  };
10801
11850
  //#endregion
10802
- //#region ../core/lib/lifecycle/trust.js
10803
- const DAY_MS$1 = 864e5;
10804
- /** The reinforcement quota window is a CALENDAR day ("每天" is a calendar notion, §2.3). */
10805
- const QUOTA_WINDOW_MS = DAY_MS$1;
10806
- function clamp01(v) {
10807
- if (!Number.isFinite(v)) return 0;
10808
- return Math.min(1, Math.max(0, v));
10809
- }
10810
- function isPinned(row) {
10811
- return Number(row.pinned) === 1;
10812
- }
10813
- /**
10814
- * `'YYYY-MM-DD HH:MM:SS'` (SQLite UTC) or ISO → epoch ms; null/garbage → null.
10815
- *
10816
- * A stamp with NO zone designator is read as UTC, not as local time. `Date.parse` treats a zone-less
10817
- * ISO string as LOCAL, and everything in this store is written in UTC (`CURRENT_TIMESTAMP`, and
10818
- * {@link formatUtcTs}), so handing one over as-is would shift every quota window and every decay
10819
- * measurement by the host's UTC offset — a fact reinforced at 23:00 UTC+8 would look reinforced 8
10820
- * hours earlier, and the 24h cap would open or close at the wrong moment. An explicit `Z` or
10821
- * `±HH:MM` is left alone.
10822
- */
10823
- function parseUtcTs(ts) {
10824
- if (ts === null || ts === void 0 || ts === "") return null;
10825
- const iso = ts.includes("T") ? ts : ts.replace(" ", "T");
10826
- const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(iso) ? iso : `${iso}Z`;
10827
- const ms = Date.parse(zoned);
10828
- return Number.isFinite(ms) ? ms : null;
10829
- }
10830
- /** Epoch ms → `'YYYY-MM-DD HH:MM:SS'` (the SQLite `CURRENT_TIMESTAMP` shape, UTC). */
10831
- function formatUtcTs(ms) {
10832
- return new Date(ms).toISOString().slice(0, 19).replace("T", " ");
10833
- }
10834
- /** eff(f) — the trust a caller should act on right now (spec §2.2). */
10835
- function effectiveTrust(row, clock, cfg, enabled = cfg.enabled) {
10836
- if (!enabled) return clamp01(row.trust_score);
10837
- if (isPinned(row)) return 1;
10838
- return clamp01(row.trust_score - cfg.decay_per_day * (clock - row.settle_clock));
10839
- }
10840
- /**
10841
- * What a caller should SEE for a row — the R11 display rule (spec §6):
10842
- * `active` → `eff()`; **non-active → the stored value verbatim** (the decay
10843
- * formula keeps running mathematically for archived rows, so the projection must
10844
- * not use it), and `enabled=false` → stored value (D12).
10845
- */
10846
- function displayTrust(row, clock, cfg, enabled = cfg.enabled) {
10847
- if (row.status !== "active") return clamp01(row.trust_score);
10848
- return effectiveTrust(row, clock, cfg, enabled);
10849
- }
10850
- /**
10851
- * Remaining active days: `eff / step`. `null` for pinned rows, non-active rows and
10852
- * when trust is disabled — the contract's `remaining_days` (spec §6 / R11).
10853
- */
10854
- function remainingDays(row, clock, cfg, enabled = cfg.enabled) {
10855
- if (row.status !== "active" || isPinned(row) || !enabled || cfg.decay_per_day <= 0) return null;
10856
- return effectiveTrust(row, clock, cfg, enabled) / cfg.decay_per_day;
10857
- }
10858
- function rollWindow(row, nowMs) {
10859
- const at = row.bonus_window_at;
10860
- const started = parseUtcTs(at);
10861
- if (at === null || started === null || nowMs - started >= QUOTA_WINDOW_MS) return {
10862
- count: 0,
10863
- at: formatUtcTs(nowMs),
10864
- reset: true
10865
- };
10866
- return {
10867
- count: row.bonus_count ?? 0,
10868
- at,
10869
- reset: false
10870
- };
10871
- }
10872
- /**
10873
- * Apply one recall to a fact: settle, then grant a bonus inside the daily quota.
10874
- * Returns `next === settled` when the quota is exhausted, the ceiling is reached,
10875
- * the fact is pinned, or the gain would be zero (R2/R10/D11).
10876
- */
10877
- function grantRecallBonus(row, clock, nowMs, cfg, enabled = cfg.enabled) {
10878
- const settled = effectiveTrust(row, clock, cfg, enabled);
10879
- const window = rollWindow(row, nowMs);
10880
- if (row.status !== "active" || isPinned(row) || !enabled || window.count >= cfg.recall_daily_cap) return {
10881
- settled,
10882
- next: settled,
10883
- granted: false,
10884
- bonusCount: window.count,
10885
- windowAt: window.at
10886
- };
10887
- const gain = cfg.recall_delta * Math.pow(cfg.recall_marginal_decay, window.count);
10888
- const next = settled <= cfg.recall_floor ? Math.max(settled, cfg.recall_floor) : Math.max(settled, Math.min(cfg.recall_ceiling, settled + gain));
10889
- if (next <= settled + 1e-9) return {
10890
- settled,
10891
- next: settled,
10892
- granted: false,
10893
- bonusCount: window.count,
10894
- windowAt: window.at
10895
- };
10896
- return {
10897
- settled,
10898
- next,
10899
- granted: true,
10900
- bonusCount: window.count + 1,
10901
- windowAt: window.at
10902
- };
10903
- }
10904
- /**
10905
- * Apply one explicit `helpful` (+1) / `unhelpful` (-1) step.
10906
- *
10907
- * Pinned and archived rows are untouched (R7): only `helpful_count` changes for
10908
- * them, so a pinned fact can never end up storing 0.95 while displaying 1.0.
10909
- * When `feedback_daily_cap > 0` feedback shares the per-fact 24h window counter.
10910
- */
10911
- function applyFeedbackDelta(row, clock, delta, nowMs, cfg, enabled = cfg.enabled) {
10912
- const settled = effectiveTrust(row, clock, cfg, enabled);
10913
- const shared = cfg.feedback_daily_cap > 0;
10914
- const window = shared ? rollWindow(row, nowMs) : {
10915
- count: row.bonus_count ?? 0,
10916
- at: row.bonus_window_at ?? formatUtcTs(nowMs),
10917
- reset: false
10918
- };
10919
- if (row.status !== "active" || isPinned(row) || !enabled) return {
10920
- settled,
10921
- next: row.trust_score,
10922
- pin: false,
10923
- forget: false,
10924
- untouched: true,
10925
- bonusCount: window.count,
10926
- windowAt: window.at
10927
- };
10928
- if (shared && window.count >= cfg.feedback_daily_cap) return {
10929
- settled,
10930
- next: settled,
10931
- pin: false,
10932
- forget: false,
10933
- untouched: false,
10934
- bonusCount: window.count,
10935
- windowAt: window.at
10936
- };
10937
- let next = clamp01(settled + delta * cfg.feedback_delta);
10938
- const pin = next >= cfg.permanent_threshold;
10939
- const forget = !pin && next <= cfg.forget_threshold + 1e-9;
10940
- if (forget) next = cfg.forget_threshold;
10941
- return {
10942
- settled,
10943
- next,
10944
- pin,
10945
- forget,
10946
- untouched: false,
10947
- bonusCount: shared ? window.count + 1 : window.count,
10948
- windowAt: window.at
10949
- };
10950
- }
10951
- //#endregion
10952
11851
  //#region ../core/lib/lifecycle/presence.js
10953
11852
  const CLOCK_KEY = "trust_clock";
10954
11853
  const LAST_SEEN_KEY = "trust_last_seen";
@@ -10996,39 +11895,27 @@ function advancePresence(db, opts) {
10996
11895
  function runTrustTick(db, cfg, opts) {
10997
11896
  const clock = opts.clock;
10998
11897
  const budget = opts.budget ?? cfg.trust.tick_max_facts;
10999
- const step = cfg.trust.decay_per_day;
11898
+ const curve = trustCurveOf(cfg.trust);
11000
11899
  const enabled = cfg.trust.enabled ? 1 : 0;
11001
11900
  const trustEnabled = cfg.trust.enabled;
11002
11901
  const facts = new FactsDao(db);
11003
11902
  return db.transaction(() => {
11004
11903
  const settled = !trustEnabled ? 0 : budget > 0 ? facts.settleBudgeted({
11005
- step,
11904
+ curve,
11006
11905
  clock,
11007
11906
  budget
11008
11907
  }) : facts.settleAll({
11009
- step,
11908
+ curve,
11010
11909
  clock
11011
11910
  });
11012
11911
  const skipped = trustEnabled && budget > 0 ? facts.countPendingSettle({ clock }) : 0;
11013
11912
  const archiveBudget = budget > 0 ? budget : 0;
11014
- const ttlIds = facts.archiveExpiredByTtl({
11015
- clock,
11016
- budget: archiveBudget
11017
- });
11018
11913
  const forgotIds = trustEnabled ? facts.archiveForgotten({
11019
11914
  clock,
11020
11915
  forgetThreshold: cfg.trust.forget_threshold,
11021
11916
  budget: archiveBudget
11022
11917
  }) : [];
11023
- const idleIds = facts.archiveIdle({
11024
- clock,
11025
- idleCalendarDays: cfg.trust.idle_calendar_days,
11026
- budget: archiveBudget
11027
- });
11028
- const deferred = archiveBudget > 0 && (ttlIds.length === archiveBudget || forgotIds.length === archiveBudget || idleIds.length === archiveBudget) ? facts.countArchiveBacklog({
11029
- forgetThreshold: cfg.trust.forget_threshold,
11030
- idleCalendarDays: cfg.trust.idle_calendar_days
11031
- }) : 0;
11918
+ const deferred = archiveBudget > 0 && forgotIds.length === archiveBudget ? facts.countArchiveBacklog({ forgetThreshold: cfg.trust.forget_threshold }) : 0;
11032
11919
  const purgeArgs = {
11033
11920
  clock,
11034
11921
  enabled,
@@ -11043,16 +11930,10 @@ function runTrustTick(db, cfg, opts) {
11043
11930
  return {
11044
11931
  clock,
11045
11932
  settled,
11046
- archived_ttl: ttlIds.length,
11047
11933
  archived_forgot: forgotIds.length,
11048
- archived_idle: idleIds.length,
11049
11934
  purged: purgedIds.length,
11050
11935
  purged_ids: purgedIds,
11051
- archived_ids: [
11052
- ...ttlIds,
11053
- ...forgotIds,
11054
- ...idleIds
11055
- ],
11936
+ archived_ids: [...forgotIds],
11056
11937
  skipped,
11057
11938
  archived_deferred: deferred,
11058
11939
  purged_deferred: purgedDeferred
@@ -11076,15 +11957,15 @@ function runMaintenance(db, cfg, opts) {
11076
11957
  });
11077
11958
  return {
11078
11959
  decayed: tick.settled,
11079
- archived_ttl: tick.archived_ttl,
11080
- archived_age: tick.archived_forgot + tick.archived_idle,
11960
+ archived_ttl: 0,
11961
+ archived_age: tick.archived_forgot,
11081
11962
  purged: tick.purged,
11082
11963
  purged_ids: tick.purged_ids,
11083
11964
  archived_ids: tick.archived_ids,
11084
11965
  settled: tick.settled,
11085
11966
  clock: tick.clock,
11086
11967
  archived_forgot: tick.archived_forgot,
11087
- archived_idle: tick.archived_idle,
11968
+ archived_idle: 0,
11088
11969
  skipped: tick.skipped,
11089
11970
  archived_deferred: tick.archived_deferred,
11090
11971
  purged_deferred: tick.purged_deferred
@@ -11483,7 +12364,6 @@ const RESTORE_METADATA_FIELDS = [
11483
12364
  "last_reinforced_at",
11484
12365
  "ttl_days",
11485
12366
  "valid_from",
11486
- "valid_to",
11487
12367
  "supersedes_source_id",
11488
12368
  "archive",
11489
12369
  "sources",
@@ -11629,9 +12509,7 @@ function rowOf(raw, metadataMissing, nowSql) {
11629
12509
  lastRetrievedAt: textOrNull$1(row["last_retrieved_at"]),
11630
12510
  ttlDays: Math.max(0, integerOrNull(row["ttl_days"]) ?? 0),
11631
12511
  validFrom: textOrNull$1(row["valid_from"]),
11632
- validTo: textOrNull$1(row["valid_to"]),
11633
12512
  validFromMs: eventTimeMs(textOrNull$1(row["valid_from"])) ?? null,
11634
- validToMs: eventTimeMs(textOrNull$1(row["valid_to"])) ?? null,
11635
12513
  supersedesId: integerOrNull(row["supersedes_source_id"]),
11636
12514
  mirrorSource: textOrNull$1(row["mirror_source"]),
11637
12515
  mirrorTarget: textOrNull$1(row["mirror_target"]),
@@ -11674,10 +12552,11 @@ const INSERT_FACT_SQL = `INSERT INTO facts (
11674
12552
  fact_id, content, kind, title, legacy_category, status, archived_at, archive_reason,
11675
12553
  created_at, updated_at, trust_score, settle_clock, assert_count, helpful_count,
11676
12554
  pinned, pinned_at, last_reinforced_at, bonus_count, bonus_window_at,
11677
- retrieval_count, last_retrieved_at, ttl_days, valid_from, valid_to, valid_from_ms, valid_to_ms,
11678
- 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,
11679
12557
  hrr_vector, semantic_vector, embedding_model, entities_version, conflict_checked
11680
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, 0, 1)`;
12558
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?,
12559
+ NULL, NULL, NULL, 0, 1)`;
11681
12560
  function countOf(db, sql) {
11682
12561
  return Number(db.prepare(sql).get()?.n ?? 0);
11683
12562
  }
@@ -11728,7 +12607,7 @@ async function restoreFactsDocument(target, doc, options = {}) {
11728
12607
  const insertSource = target.db.prepare("INSERT INTO fact_sources (fact_id, kind, ref) VALUES (?, ?, ?)");
11729
12608
  for (const row of rows) {
11730
12609
  try {
11731
- 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);
11732
12611
  for (const source of row.sources) insertSource.run(row.factId, source.kind, source.ref);
11733
12612
  if (row.relations.length > 0) {
11734
12613
  const report = writeExplicitEdgesForFact(target.db, row.factId, row.relations);
@@ -11826,9 +12705,6 @@ var SqliteMemoryRepository = class {
11826
12705
  countForgettingWithin(p) {
11827
12706
  return this.dao.countForgettingWithin(p);
11828
12707
  }
11829
- countIdleCandidates(days) {
11830
- return this.dao.countIdleCandidates(days);
11831
- }
11832
12708
  countArchiveBacklog(p) {
11833
12709
  return this.dao.countArchiveBacklog(p);
11834
12710
  }
@@ -11911,11 +12787,11 @@ var SqliteMemoryRepository = class {
11911
12787
  factId: Number(existing.fact_id)
11912
12788
  };
11913
12789
  }
11914
- applyValidity(factId, validFrom, validTo) {
11915
- this.dao.applyValidity(factId, validFrom, validTo);
12790
+ applyValidity(factId, validFrom) {
12791
+ this.dao.applyValidity(factId, validFrom);
11916
12792
  }
11917
- applyValues(factId, kind, ttlDays) {
11918
- this.dao.applyValues(factId, kind, ttlDays);
12793
+ applyValues(factId, kind) {
12794
+ this.dao.applyValues(factId, kind);
11919
12795
  }
11920
12796
  setTitle(factId, title) {
11921
12797
  this.dao.setTitle(factId, title);
@@ -11932,17 +12808,14 @@ var SqliteMemoryRepository = class {
11932
12808
  incrementAssertCount(factId) {
11933
12809
  return this.dao.incrementAssertCount(factId);
11934
12810
  }
11935
- markValidTo(factId, at, atMs) {
11936
- this.dao.markValidTo(factId, at, atMs);
11937
- }
11938
12811
  linkSupersede(factId, supersedesId) {
11939
12812
  this.dao.linkSupersede(factId, supersedesId);
11940
12813
  }
11941
12814
  archiveSuperseded(factId, clock) {
11942
12815
  return this.dao.archiveSuperseded(factId, clock);
11943
12816
  }
11944
- revive(factId, kind, ttlDays, trust, clock) {
11945
- return this.dao.revive(factId, kind, ttlDays, trust, clock);
12817
+ revive(factId, kind, trust, clock) {
12818
+ return this.dao.revive(factId, kind, trust, clock);
11946
12819
  }
11947
12820
  archive(factId, clock, reason) {
11948
12821
  return this.dao.archive(factId, clock, reason);
@@ -11950,24 +12823,12 @@ var SqliteMemoryRepository = class {
11950
12823
  restore(factId, trust, clock) {
11951
12824
  return this.dao.restore(factId, trust, clock);
11952
12825
  }
11953
- bumpHelpful(factId, delta) {
11954
- this.dao.bumpHelpful(factId, delta);
11955
- }
11956
12826
  pin(factId, clock) {
11957
12827
  return this.dao.pin(factId, clock);
11958
12828
  }
11959
12829
  unpin(factId, clock) {
11960
12830
  return this.dao.unpin(factId, clock);
11961
12831
  }
11962
- pinOutcome(factId, clock, bonusCount, windowAt) {
11963
- this.dao.pinOutcome(factId, clock, bonusCount, windowAt);
11964
- }
11965
- forgetOutcome(factId, trust, clock, bonusCount, windowAt) {
11966
- this.dao.forgetOutcome(factId, trust, clock, bonusCount, windowAt);
11967
- }
11968
- reinforceOutcome(factId, trust, clock, bonusCount, windowAt) {
11969
- this.dao.reinforceOutcome(factId, trust, clock, bonusCount, windowAt);
11970
- }
11971
12832
  touchUsage(ids) {
11972
12833
  this.dao.touchUsage(ids);
11973
12834
  }
@@ -15618,6 +16479,22 @@ const NO_CHECK = {
15618
16479
  */
15619
16480
  const RETRIEVAL_HEALTH_KEY = "retrieval_health";
15620
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
+ /**
15621
16498
  * The ONE stored-title-row → `TitleCandidate` mapper, shared by the exact page and the fuzzy
15622
16499
  * shortlist so a candidate cannot describe the same row two ways.
15623
16500
  *
@@ -15635,8 +16512,8 @@ function titleCandidatesOf(rows, includeExcerpt) {
15635
16512
  matched_title: row.matched_title === null || row.matched_title === void 0 ? String(row.title) : String(row.matched_title),
15636
16513
  memory_kind: mapLegacyKind(row.kind),
15637
16514
  status: row.status === "archived" ? "archived" : "active",
15638
- created_at: row.created_at === null ? "" : String(row.created_at),
15639
- 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),
15640
16517
  ...row.valid_from === null || row.valid_from === void 0 ? {} : { event_date: String(row.valid_from) },
15641
16518
  ...includeExcerpt && row.excerpt !== null ? { excerpt: String(row.excerpt) } : {}
15642
16519
  }));
@@ -15663,8 +16540,8 @@ function factEvidenceOf(row) {
15663
16540
  status: row["status"] === "archived" ? "archived" : "active",
15664
16541
  ...title === null || title === void 0 || title === "" ? {} : { title: String(title) },
15665
16542
  ...row["valid_from"] === null || row["valid_from"] === void 0 ? {} : { event_date: String(row["valid_from"]) },
15666
- created_at: row["created_at"] === null || row["created_at"] === void 0 ? "" : String(row["created_at"]),
15667
- 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"])
15668
16545
  };
15669
16546
  }
15670
16547
  var MemoryStore = class {
@@ -15816,7 +16693,7 @@ var MemoryStore = class {
15816
16693
  dim: this.vstore.dim
15817
16694
  });
15818
16695
  }
15819
- async add(content, kind, ttlDays, opts) {
16696
+ async add(content, kind, opts) {
15820
16697
  const normalized = normalizeWrite(content).trim();
15821
16698
  if (!normalized) throw new Error("内容不能为空");
15822
16699
  const secret = detectSecretShape(normalized);
@@ -15829,7 +16706,7 @@ var MemoryStore = class {
15829
16706
  if (checkedKind !== void 0 && requiresEventDate(checkedKind) && opts?.eventDate === void 0) throw new Error(JOURNAL_NEEDS_EVENT_DATE);
15830
16707
  const explicitTriples = opts?.relations === void 0 ? void 0 : await this.relations.explicitRelationTriples(checkedKind ?? "note", opts.relations);
15831
16708
  const title = this.resolveWriteTitle(opts?.title, checkedKind);
15832
- 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, {
15833
16710
  ...opts,
15834
16711
  title,
15835
16712
  relations: void 0
@@ -15920,7 +16797,7 @@ var MemoryStore = class {
15920
16797
  }], this.vectorWriteTarget(this.vectorSpace()));
15921
16798
  return encoded === 1;
15922
16799
  }
15923
- async update(fact_id, content, kind, ttlDays, opts) {
16800
+ async update(fact_id, content, kind, opts) {
15924
16801
  const row = await this.getRow(fact_id);
15925
16802
  if (!row) throw new Error(`找不到 fact_id=${fact_id}`);
15926
16803
  const normalized = normalizeWrite(content).trim();
@@ -15936,7 +16813,7 @@ var MemoryStore = class {
15936
16813
  if (requiresEventDate(effectiveKind) && opts?.eventDate === void 0 && row.valid_from == null) throw new Error(JOURNAL_NEEDS_EVENT_DATE);
15937
16814
  const title = this.resolveWriteTitle(opts?.title, effectiveKind);
15938
16815
  const explicitTriples = opts?.relations === void 0 ? void 0 : await this.relations.explicitRelationTriples(effectiveKind, opts.relations);
15939
- 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, {
15940
16817
  ...opts,
15941
16818
  supersedesId: fact_id,
15942
16819
  archiveOldIfActive: true,
@@ -15970,9 +16847,9 @@ var MemoryStore = class {
15970
16847
  return changed > 0;
15971
16848
  }
15972
16849
  /**
15973
- * Bring an archived fact back (spec §2.6): a `forgot` fact is lifted to
15974
- * `recall_floor` (otherwise the very next tick would re-archive it, R4), the
15975
- * 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),
15976
16853
  * and `settle_clock` moves to now so the downtime is not charged.
15977
16854
  */
15978
16855
  async restore(fact_id) {
@@ -15981,7 +16858,7 @@ var MemoryStore = class {
15981
16858
  const trust = this.config.trust;
15982
16859
  const clock = await this.lifecycle.clock();
15983
16860
  const stored = Number(row.trust_score);
15984
- 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;
15985
16862
  if (await this.facts.restore(fact_id, next, clock) === 0) return false;
15986
16863
  const vec = bytesToFloat32(row.semantic_vector ?? null);
15987
16864
  if (vec && vec.length === this.vstore.dim) this.vstore.add(fact_id, vec);
@@ -15989,42 +16866,6 @@ var MemoryStore = class {
15989
16866
  if ((await this.detectContradictions(fact_id)).complete) await this.facts.markConflictChecked([fact_id]);
15990
16867
  return true;
15991
16868
  }
15992
- async helpful(fact_id) {
15993
- return await this.applyFeedback(fact_id, 1);
15994
- }
15995
- async unhelpful(fact_id) {
15996
- return await this.applyFeedback(fact_id, -1);
15997
- }
15998
- /**
15999
- * Explicit feedback (spec §2.4). Pinned/archived rows only move `helpful_count`
16000
- * (R7); otherwise the decay is settled first, then `±feedback_delta`, pin at the
16001
- * permanent threshold (snap to 1.0) or archive immediately at the forget line.
16002
- */
16003
- async applyFeedback(fact_id, delta) {
16004
- if (await this.getRow(fact_id) === void 0) return null;
16005
- const trust = this.config.trust;
16006
- const evict = await this.lifecycle.immediateTransaction(async () => {
16007
- const row = await this.getRow(fact_id);
16008
- if (!row) return null;
16009
- const clock = await this.lifecycle.clock();
16010
- const outcome = applyFeedbackDelta(asTrustRow(row), clock, delta, Date.now(), trust);
16011
- await this.facts.bumpHelpful(fact_id, delta);
16012
- if (outcome.untouched) return null;
16013
- if (outcome.pin) {
16014
- await this.facts.pinOutcome(fact_id, clock, outcome.bonusCount, outcome.windowAt);
16015
- return null;
16016
- }
16017
- if (outcome.forget) {
16018
- await this.facts.forgetOutcome(fact_id, outcome.next, clock, outcome.bonusCount, outcome.windowAt);
16019
- await this.contradictions.resolveForFact(fact_id);
16020
- return fact_id;
16021
- }
16022
- await this.facts.reinforceOutcome(fact_id, outcome.next, clock, outcome.bonusCount, outcome.windowAt);
16023
- return null;
16024
- });
16025
- if (evict !== null) evictVectors(this.vstore, [evict]);
16026
- return (await this.getRow(fact_id))?.helpful_count ?? 0;
16027
- }
16028
16869
  /** Permanent-memory administration (spec §6). */
16029
16870
  async pin(fact_id) {
16030
16871
  return await this.facts.pin(fact_id, await this.lifecycle.clock()) > 0;
@@ -16042,15 +16883,15 @@ var MemoryStore = class {
16042
16883
  const clock = await this.lifecycle.clock();
16043
16884
  const active = await this.facts.countActive();
16044
16885
  const pinned = await this.facts.countPinnedActive();
16045
- const forgettingSoon = trust.decay_per_day > 0 ? await this.facts.countForgettingWithin({
16046
- step: trust.decay_per_day,
16886
+ const curve = curveOf(trust);
16887
+ const forgettingSoon = await this.facts.countForgettingWithin({
16888
+ threshold: ceilingForDays(FORGETTING_SOON_DAYS, curve),
16047
16889
  clock,
16048
- withinDays: FORGETTING_SOON_DAYS
16049
- }) : 0;
16890
+ curve
16891
+ });
16050
16892
  const bonus = await this.facts.bonusStatedToday();
16051
16893
  const reinforcedToday = bonus.count;
16052
16894
  const grantedToday = bonus.granted;
16053
- const idleCandidates = await this.facts.countIdleCandidates(trust.idle_calendar_days);
16054
16895
  const reasons = await this.facts.archivedByReason();
16055
16896
  const oldest = await this.facts.minSettleClock();
16056
16897
  return {
@@ -16061,7 +16902,6 @@ var MemoryStore = class {
16061
16902
  forgetting_soon: forgettingSoon,
16062
16903
  reinforced_today: reinforcedToday,
16063
16904
  bonus_granted_today: grantedToday,
16064
- idle_candidates: idleCandidates,
16065
16905
  conflict_pending: await this.facts.countPendingConflicts(),
16066
16906
  entities_stale: await this.facts.countStaleEntities(3),
16067
16907
  archived_by_reason: Object.fromEntries(reasons.map((r) => [r.reason ?? "unknown", r.n])),
@@ -16194,9 +17034,48 @@ var MemoryStore = class {
16194
17034
  await (this.semantic.warmUp?.() ?? Promise.resolve());
16195
17035
  return this.semantic.isAvailable();
16196
17036
  }
16197
- /** List contradiction candidates (`resolved` 0 = open, 1 = closed with a recorded verdict). */
17037
+ /**
17038
+ * Point the semantic backend at a shared runtime-dependency root (the base provider's
17039
+ * `runtime-deps` handle) before it warms. Best-effort by contract: a backend without external
17040
+ * module resolution ignores it, and a missing root is reported by the backend itself.
17041
+ */
17042
+ useSemanticModuleDir(dir) {
17043
+ this.semantic.useModuleDir?.(dir);
17044
+ }
17045
+ /**
17046
+ * Point the vector store at a shared runtime-dependency root for its NATIVE binding
17047
+ * (`hnswlib-node`), after the DSH plugin's native self-heal item is ready.
17048
+ *
17049
+ * Same best-effort contract as {@link useSemanticModuleDir}: a store with no native binding (the
17050
+ * brute-force backend, or an `auto` store still on it) ignores it, and a store that already fell
17051
+ * back to brute force rebuilds its tracked vectors under the new root. Nothing here decides whether
17052
+ * a root should exist — the caller's dependency audit does.
17053
+ */
17054
+ useNativeModuleDir(dir) {
17055
+ this.vstore.useModuleDir?.(dir);
17056
+ }
17057
+ /**
17058
+ * Vectors currently in the LIVE index (sync, one map read).
17059
+ *
17060
+ * Exposed for the plugin's dependency audit: an `hnswlib-node` notice is conditioned on the corpus
17061
+ * actually being large enough for an ANN index to matter (`min_vectors`), and a session line that
17062
+ * asks "how big is the store right now" must not run a database query to answer.
17063
+ */
17064
+ liveVectorCount() {
17065
+ return this.vstore.count();
17066
+ }
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
+ */
16198
17074
  async listContradictions(opts) {
16199
- 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
+ }));
16200
17079
  }
16201
17080
  /**
16202
17081
  * Adjudicate one logged conflict — the outlet the detector never had.
@@ -16242,7 +17121,6 @@ var MemoryStore = class {
16242
17121
  reason: "loser_not_in_pair"
16243
17122
  };
16244
17123
  await this.archive(loserFactId, "contradiction");
16245
- await this.facts.markValidTo(loserFactId, formatUtcTs(Date.now()), Date.now());
16246
17124
  await this.contradictions.resolve(id, resolution, loserFactId);
16247
17125
  return {
16248
17126
  resolved: true,
@@ -16629,10 +17507,21 @@ var MemoryStore = class {
16629
17507
  * ({@link timePage}, `mode:"time"`): the window is the whole question, so there is nothing to
16630
17508
  * match, plan or rank.
16631
17509
  *
16632
- * The exact, facts and relation passes reinforce the facts they return, exactly like the fuzzy
16633
- * pass does for its hits: all are recalls the caller actually reads. A caller that must stay
16634
- * read-only passes `track: false` (the conditional hint's probe does). Exact, facts, relation and
16635
- * time also honour the caller's output budget (their text is the caller's to bound).
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}).
16636
17525
  *
16637
17526
  * It does NOT emit a retrieval-health event. Those counters describe the retrieval PIPELINE
16638
17527
  * (legs, fusion, semantic-live rate); none of an exact, facts, relation or time answer ran a leg,
@@ -16678,32 +17567,47 @@ var MemoryStore = class {
16678
17567
  * happened that day" reads in the order it happened; `score` is the constant 1 exactly like the
16679
17568
  * exact page's, because there is no relevance number here for the model to compare.
16680
17569
  *
16681
- * It honours `kind`/`source`, pages with the same `offset`/`limit` (default 10, cap 50) and the
16682
- * same `max_tokens` budget as every other recall, and reinforces the rows it returns unless
16683
- * `track: false` — a window the caller reads is a recall like any other.
17570
+ * The PAGE is the contract's, not the caller's guess: `limit` omitted means one page of
17571
+ * {@link TIME_DEFAULT_LIMIT} rows (the same 50 the contract caps an explicit page at), and an
17572
+ * explicit `limit` (1-50) still wins. A window that holds more than one page is the normal case
17573
+ * for a busy day, so the envelope does not leave the caller to infer the shortfall: it reports
17574
+ * `remaining = total - (offset + hits.length)`, the count still unseen.
17575
+ *
17576
+ * The output budget is honoured only when the caller names it (`max_tokens`). Falling back to the
17577
+ * configured `retrieval.max_output_tokens` here would rewrite the text of a window's rows behind a
17578
+ * page the caller already bounded with `limit` — the page size is the knob for "how much of the
17579
+ * list", and the budget stays a knob for a RANKED result (exact/facts/relation/hybrid unchanged).
17580
+ * `max_tokens` (including the contract's `0` = unlimited) is passed through unchanged.
17581
+ *
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.
16684
17585
  */
16685
17586
  async timePage(input) {
16686
17587
  if (input.time === void 0) throw new Error("mem_recall.search 需要 `query`,或只给 `time`(纯时间召回);两者都没有时无法检索。");
16687
17588
  const time = normalizeTimeFilter(input.time);
16688
- const { offset, limit } = exactPage(input.offset, input.limit);
17589
+ const page = exactPage(input.offset, input.limit);
16689
17590
  const filters = {
16690
17591
  time,
16691
17592
  ...input.kind === void 0 ? {} : { kind: input.kind },
16692
17593
  ...input.source === void 0 ? {} : { source: input.source }
16693
17594
  };
16694
17595
  const total = await this.searchIndex.countInTimeWindow(filters);
17596
+ const limit = input.limit === void 0 ? 50 : page.limit;
17597
+ const offset = page.offset;
16695
17598
  const ids = total === 0 ? [] : await this.searchIndex.timeWindowRows({
16696
17599
  ...filters,
16697
17600
  limit,
16698
17601
  offset
16699
17602
  });
16700
- const { kept } = applyOutputBudget(this.config, await this.factHits(ids, () => 1), input.maxTokens);
16701
- if (input.track !== false) await this.reinforce(kept.map((hit) => hit.ref_id));
17603
+ const { kept } = applyOutputBudget(this.config, await this.factHits(ids, () => 1), input.maxTokens ?? 0);
17604
+ if (input.track !== false) await this.touchOnly(kept.map((hit) => hit.ref_id));
16702
17605
  return {
16703
17606
  mode: "time",
16704
17607
  total,
16705
17608
  offset,
16706
17609
  limit,
17610
+ remaining: Math.max(0, total - (offset + kept.length)),
16707
17611
  truncated: offset + kept.length < total,
16708
17612
  hits: kept
16709
17613
  };
@@ -16735,7 +17639,7 @@ var MemoryStore = class {
16735
17639
  offset
16736
17640
  });
16737
17641
  const { kept } = applyOutputBudget(this.config, await this.factHits(ids, () => 1), input.maxTokens);
16738
- 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));
16739
17643
  return {
16740
17644
  mode: "exact",
16741
17645
  total,
@@ -16772,8 +17676,8 @@ var MemoryStore = class {
16772
17676
  * malformed window is the caller's error exactly as it is in the exact page;
16773
17677
  * - the page is FACT-level (`total`/`offset`/`limit`/`truncated`), and the caller's output budget
16774
17678
  * only shortens `content` — dropping a row would make `total` disagree with the page;
16775
- * - the returned facts are reinforced unless `track: false`, like every other recall the caller
16776
- * 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}).
16777
17681
  */
16778
17682
  async factPage(input, rawQuery) {
16779
17683
  const query = rawQuery.trim();
@@ -16792,7 +17696,7 @@ var MemoryStore = class {
16792
17696
  });
16793
17697
  if (page.total === 0) return null;
16794
17698
  const facts = this.budgetFactEvidence(page.rows.map(factEvidenceOf), input.maxTokens);
16795
- 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));
16796
17700
  return {
16797
17701
  mode: "facts",
16798
17702
  total: page.total,
@@ -16889,8 +17793,8 @@ var MemoryStore = class {
16889
17793
  text: String(row["content"] ?? ""),
16890
17794
  memory_kind: mapLegacyKind(String(row["kind"] ?? "note")),
16891
17795
  ...title === void 0 ? {} : { title },
16892
- created_at: row["created_at"] === null || row["created_at"] === void 0 ? "" : String(row["created_at"]),
16893
- 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"])
16894
17798
  });
16895
17799
  }
16896
17800
  return evidence;
@@ -17118,10 +18022,9 @@ var MemoryStore = class {
17118
18022
  score: scoreOf(id),
17119
18023
  source_ref: factSourceRef(id),
17120
18024
  entities: bags.get(id) ?? [],
17121
- created_at: time?.created_at ?? "",
17122
- updated_at: time?.updated_at ?? null,
18025
+ created_at: modelInstant(time?.created_at) ?? "",
18026
+ updated_at: modelInstant(time?.updated_at),
17123
18027
  ...time?.valid_from === null || time?.valid_from === void 0 ? {} : { valid_from: time.valid_from },
17124
- ...time?.valid_to === null || time?.valid_to === void 0 ? {} : { valid_to: time.valid_to },
17125
18028
  ...time?.title === null || time?.title === void 0 ? {} : { title: time.title }
17126
18029
  };
17127
18030
  });
@@ -17186,6 +18089,19 @@ var MemoryStore = class {
17186
18089
  } catch {}
17187
18090
  }
17188
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
+ /**
17189
18105
  * Semantic leg. `queryVector` short-circuits the encode when the caller already
17190
18106
  * encoded this exact query (the cross-store router does, for both stores at once);
17191
18107
  * `onVector` publishes the vector this leg actually used, so the relaxed retry pass can reuse it
@@ -17294,25 +18210,22 @@ var MemoryStore = class {
17294
18210
  /**
17295
18211
  * Insert (or resolve to) a fact row.
17296
18212
  *
17297
- * `kind` / `ttlDays` are `undefined` when the CALLER specified nothing — the
17298
- * distinction is load-bearing. A duplicate-content hit must not clobber the existing
17299
- * row's values with the insert defaults: re-adding a fact without a kind used to
17300
- * reset it to `general`, and reviving an archived one silently cleared an explicit TTL.
17301
- * The rule for all three branches is therefore: an explicitly provided value lands, an
17302
- * omitted one keeps what the row already has, and a brand-new row falls back to the
17303
- * 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`).
17304
18218
  */
17305
- async persistFact(content, kind, ttlDays, entities, triples, opts, explicitEdges = void 0) {
18219
+ async persistFact(content, kind, entities, triples, opts, explicitEdges = void 0) {
17306
18220
  const hrr = hrrToBytes(encodeHrrEntityVector(entities.map((e) => e.name)));
17307
18221
  const trust = this.config.trust;
17308
18222
  const clock = await this.lifecycle.clock();
17309
18223
  const nowIso = formatUtcTs(Date.now());
17310
18224
  const replaced = opts?.supersedesId === void 0 ? void 0 : await this.getRow(opts.supersedesId);
17311
18225
  const superseded = trust.inherit_trust_on_update ? replaced : void 0;
17312
- 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);
17313
18227
  const startPinned = superseded !== void 0 && isPinned(asTrustRow(superseded)) ? 1 : 0;
17314
18228
  const newKind = kind ?? (replaced === void 0 ? "note" : mapLegacyKind(replaced.kind));
17315
- const newTtl = ttlDays ?? (replaced === void 0 ? 0 : Number(replaced.ttl_days ?? 0));
17316
18229
  /**
17317
18230
  * The title this revision would carry (JOURNAL_TITLE_SEARCH §2.2/§11.2, v17's correction).
17318
18231
  *
@@ -17344,13 +18257,11 @@ var MemoryStore = class {
17344
18257
  }
17345
18258
  const createdAt = replaced === void 0 || replaced.created_at === null ? nowIso : String(replaced.created_at);
17346
18259
  const validFrom = opts?.eventDate === void 0 ? replaced === void 0 ? null : nullableEventTime(replaced.valid_from, replaced.valid_from_ms) : normalizeEventTime(opts.eventDate, "event_date");
17347
- const validTo = opts?.validUntil === void 0 ? replaced === void 0 ? null : nullableEventTime(replaced.valid_to, replaced.valid_to_ms) : normalizeEventTime(opts.validUntil, "valid_until");
17348
18260
  const { supersededId, ...result } = await this.lifecycle.transaction(async () => {
17349
18261
  const cur = await this.facts.insertRevision({
17350
18262
  content,
17351
18263
  kind: newKind,
17352
18264
  title: desiredTitle,
17353
- ttlDays: newTtl,
17354
18265
  supersedesId: opts?.supersedesId ?? null,
17355
18266
  hrr,
17356
18267
  trust: clamp01(startTrust),
@@ -17360,9 +18271,7 @@ var MemoryStore = class {
17360
18271
  windowAt: nowIso,
17361
18272
  createdAt,
17362
18273
  validFrom: validFrom?.text ?? null,
17363
- validTo: validTo?.text ?? null,
17364
- validFromMs: validFrom?.ms ?? null,
17365
- validToMs: validTo?.ms ?? null
18274
+ validFromMs: validFrom?.ms ?? null
17366
18275
  });
17367
18276
  let fact_id;
17368
18277
  let is_new;
@@ -17372,18 +18281,16 @@ var MemoryStore = class {
17372
18281
  fact_id = Number(row.fact_id);
17373
18282
  is_new = false;
17374
18283
  const rowKind = mapLegacyKind(row.kind);
17375
- const rowTtl = Number(row.ttl_days ?? 0);
17376
18284
  const keepKind = kind ?? rowKind;
17377
- const keepTtl = ttlDays ?? rowTtl;
17378
18285
  const rowTitle = storedTitle(row.title);
17379
18286
  const selfRetitle = retitle && opts?.supersedesId !== void 0 && Number(opts.supersedesId) === fact_id;
17380
18287
  if (opts?.title !== void 0 && opts.title !== null && rowTitle !== null && opts.title !== rowTitle && !selfRetitle) throw new Error(TITLE_CONFLICT);
17381
18288
  const keepTitle = selfRetitle ? desiredTitle : rowTitle ?? desiredTitle;
17382
18289
  if (opts?.requireJournalTitle === true && keepKind === "journal" && keepTitle === null) throw new Error(TITLE_REQUIRED);
17383
- 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);
17384
18291
  const settled = effectiveTrust(asTrustRow(row), clock, trust);
17385
- const revived = await this.facts.revive(fact_id, keepKind, keepTtl, Math.max(settled, trust.recall_floor), clock) > 0;
17386
- 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);
17387
18294
  if (keepTitle !== null && keepTitle !== rowTitle) {
17388
18295
  await this.facts.setTitle(fact_id, keepTitle);
17389
18296
  if (selfRetitle) {
@@ -17471,10 +18378,7 @@ var MemoryStore = class {
17471
18378
  if (!opts?.supersedesId || opts.supersedesId === fact_id) return null;
17472
18379
  await this.facts.linkSupersede(fact_id, opts.supersedesId);
17473
18380
  if (opts.archiveOldIfActive) {
17474
- if (await this.facts.archiveSuperseded(opts.supersedesId, await this.lifecycle.clock()) > 0) {
17475
- await this.contradictions.resolveForFact(opts.supersedesId);
17476
- await this.facts.markValidTo(opts.supersedesId, formatUtcTs(Date.now()), Date.now());
17477
- }
18381
+ if (await this.facts.archiveSuperseded(opts.supersedesId, await this.lifecycle.clock()) > 0) await this.contradictions.resolveForFact(opts.supersedesId);
17478
18382
  }
17479
18383
  await this.triples.deleteForFact(opts.supersedesId);
17480
18384
  await this.relations.deleteEdgesForFact(opts.supersedesId);
@@ -17631,8 +18535,8 @@ var MemoryStore = class {
17631
18535
  pinned: isPinned(row),
17632
18536
  remaining_days: remainingDays(row, clock, this.config.trust),
17633
18537
  helpful_count: Number(r.helpful_count),
17634
- created_at: String(r.created_at),
17635
- archived_at: r.archived_at ? String(r.archived_at) : null,
18538
+ created_at: modelInstant(r.created_at) ?? "",
18539
+ archived_at: modelInstant(r.archived_at),
17636
18540
  archive_reason: r.archive_reason ? String(r.archive_reason) : null
17637
18541
  };
17638
18542
  }
@@ -17645,14 +18549,13 @@ var MemoryStore = class {
17645
18549
  entities,
17646
18550
  triples,
17647
18551
  settle_clock: Number(r.settle_clock),
17648
- pinned_at: r.pinned_at ? String(r.pinned_at) : null,
17649
- last_reinforced_at: r.last_reinforced_at ? String(r.last_reinforced_at) : null,
17650
- 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),
17651
18555
  bonus_count: Number(r.bonus_count ?? 0),
17652
- bonus_window_at: r.bonus_window_at ? String(r.bonus_window_at) : null,
18556
+ bonus_window_at: modelInstant(r.bonus_window_at),
17653
18557
  sources: await this.sources.listForFact(factId),
17654
18558
  valid_from: nullableString(r.valid_from),
17655
- valid_to: nullableString(r.valid_to),
17656
18559
  superseded_by: await this.facts.supersededBy(factId),
17657
18560
  assert_count: Number(r.assert_count ?? 1),
17658
18561
  relations: await this.relations.explicitRelationsForFact(factId)
@@ -17758,10 +18661,11 @@ function nullableEventTime(text, ms) {
17758
18661
  * inserts SQL rows.
17759
18662
  *
17760
18663
  * TWO CONSUMERS, TWO SEMANTICS. `import`/`merge` remain a CONTENT-IDENTITY UNION and consume only
17761
- * a subset: kind, ttl_days, embedded source ref, valid_from → event_date and valid_to →
17762
- * valid_until. They do NOT restore created_at/updated_at/trust_score/assert_count/helpful_count/
17763
- * pinned, archived status or the supersedes chain — `metadata_dropped` counts each value they
17764
- * 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`):
17765
18669
  * only into an EMPTY facts table, keeping fact_id, status, timestamps, trust, pin, counters, every
17766
18670
  * provenance row and the supersedes chain, then re-deriving entities. The union path never revives
17767
18671
  * an archived row; restore is the only path that brings archived rows back. The vector, entity,
@@ -17857,7 +18761,6 @@ const FACT_COLUMNS = [
17857
18761
  "helpful_count",
17858
18762
  "last_reinforced_at",
17859
18763
  "valid_from",
17860
- "valid_to",
17861
18764
  "supersedes_id",
17862
18765
  "archived_at",
17863
18766
  "archive_reason",
@@ -18007,7 +18910,7 @@ function readFactsDocument(dbPath, options) {
18007
18910
  last_reinforced_at: textOrNull(cell(row, "last_reinforced_at")),
18008
18911
  sources: sources.get(sourceId) ?? [],
18009
18912
  valid_from: textOrNull(cell(row, "valid_from")),
18010
- valid_to: textOrNull(cell(row, "valid_to")),
18913
+ valid_to: null,
18011
18914
  supersedes_source_id: supersedes === null || supersedes === void 0 ? null : numberOf(supersedes, 0),
18012
18915
  archive: status === "archived" ? {
18013
18916
  at: textOrNull(cell(row, "archived_at")),
@@ -18203,7 +19106,9 @@ function emptyMetadataDropped() {
18203
19106
  assert_count: 0,
18204
19107
  helpful_count: 0,
18205
19108
  pinned: 0,
18206
- supersedes: 0
19109
+ supersedes: 0,
19110
+ ttl_days: 0,
19111
+ valid_to: 0
18207
19112
  };
18208
19113
  }
18209
19114
  /** What a Phase-2 restore WOULD have carried but v1 does not. */
@@ -18214,7 +19119,9 @@ function metadataDroppedOf(row) {
18214
19119
  assert_count: numberOf(cell(row, "assert_count"), 1) > 1 ? 1 : 0,
18215
19120
  helpful_count: numberOf(cell(row, "helpful_count"), 0) > 0 ? 1 : 0,
18216
19121
  pinned: boolOf(cell(row, "pinned")) ? 1 : 0,
18217
- 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
18218
19125
  };
18219
19126
  }
18220
19127
  function accumulateMetadataDropped(total, one) {
@@ -18224,6 +19131,8 @@ function accumulateMetadataDropped(total, one) {
18224
19131
  total.helpful_count += one.helpful_count;
18225
19132
  total.pinned += one.pinned;
18226
19133
  total.supersedes += one.supersedes;
19134
+ total.ttl_days += one.ttl_days;
19135
+ total.valid_to += one.valid_to;
18227
19136
  }
18228
19137
  function sourceIdOf(row) {
18229
19138
  const value = cell(row, "source_id");
@@ -18235,12 +19144,6 @@ function optionalString(row, key, label) {
18235
19144
  if (typeof value !== "string") throw new Error(`${label} 不是字符串`);
18236
19145
  return value === "" ? void 0 : value;
18237
19146
  }
18238
- function optionalTtlDays(row) {
18239
- const value = cell(row, "ttl_days");
18240
- if (value === null || value === void 0) return void 0;
18241
- if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error("ttl_days 无效(需要非负整数)");
18242
- return value;
18243
- }
18244
19147
  function optionalDate(row, key, label) {
18245
19148
  const value = cell(row, key);
18246
19149
  if (value === null || value === void 0) return void 0;
@@ -18302,19 +19205,15 @@ function addRequestFor(identity, row, fallbackSourceRef, preferEmbeddedSources)
18302
19205
  const sourceRef = (preferEmbeddedSources ? embeddedSourceRef(row) : void 0) ?? fallbackSourceRef;
18303
19206
  const kind = mapLegacyKind(optionalString(row, "kind", "kind") ?? optionalString(row, "category", "category"));
18304
19207
  const title = optionalString(row, "title", "title");
18305
- const ttlDays = optionalTtlDays(row);
18306
19208
  const eventDate = optionalDate(row, "valid_from", "valid_from");
18307
- const validUntil = optionalDate(row, "valid_to", "valid_to");
18308
19209
  const relations = relationsOf(row, kind);
18309
19210
  return {
18310
19211
  action: "add",
18311
19212
  content: identity,
18312
19213
  kind,
18313
19214
  ...title === void 0 ? {} : { title },
18314
- ...ttlDays === void 0 ? {} : { ttl_days: ttlDays },
18315
19215
  ...sourceRef === void 0 ? {} : { source_ref: sourceRef },
18316
19216
  ...eventDate === void 0 ? {} : { event_date: eventDate },
18317
- ...validUntil === void 0 ? {} : { valid_until: validUntil },
18318
19217
  ...relations === void 0 ? {} : { relations }
18319
19218
  };
18320
19219
  }
@@ -18709,13 +19608,12 @@ function walHealth(dbPath) {
18709
19608
  * P-08 / P-13 / JOURNAL_TITLE_SEARCH: the `mem_remember` extras, lifted off the request and onto the
18710
19609
  * store's write options. Only the keys actually present are carried, so an omitted argument stays
18711
19610
  * "omitted" all the way down (the store's "unset keeps what the row has" rule — an update inherits
18712
- * an omitted title, TTL, event time — depends on that distinction).
19611
+ * an omitted title or event time — depends on that distinction).
18713
19612
  */
18714
19613
  function writeOptions(req, rules) {
18715
19614
  return {
18716
19615
  ...req.source_ref === void 0 ? {} : { sourceRef: req.source_ref },
18717
19616
  ...req.event_date === void 0 ? {} : { eventDate: req.event_date },
18718
- ...req.valid_until === void 0 ? {} : { validUntil: req.valid_until },
18719
19617
  ...req.title === void 0 ? {} : { title: req.title },
18720
19618
  ...req.retitle === true ? { retitle: true } : {},
18721
19619
  ...req.relations === void 0 ? {} : { relations: req.relations },
@@ -18839,11 +19737,9 @@ async function buildRuntime(opts) {
18839
19737
  },
18840
19738
  async remember(req, rules) {
18841
19739
  switch (req.action) {
18842
- case "add": return this.memory.add(req.content, req.kind, req.ttl_days, writeOptions(req, rules));
18843
- 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));
18844
19742
  case "remove": return this.memory.remove(req.fact_id, req.reason);
18845
- case "helpful": return this.memory.helpful(req.fact_id);
18846
- case "unhelpful": return this.memory.unhelpful(req.fact_id);
18847
19743
  }
18848
19744
  },
18849
19745
  async recall(req) {
@@ -18873,14 +19769,19 @@ async function buildRuntime(opts) {
18873
19769
  },
18874
19770
  async admin(req) {
18875
19771
  switch (req.action) {
18876
- case "stats": return {
18877
- ...await this.memory.countByStatus(),
18878
- retrieval: retrievalHealthSummary(),
18879
- vectors: await this.memory.vectorSpaceHealth(),
18880
- wal: walHealth(memDb),
18881
- sources: await this.memory.sourceCoverage(),
18882
- validity: await this.memory.validityCoverage()
18883
- };
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
+ };
18884
19785
  case "list": return await this.memory.list(req.kind, req.status ?? "active", req.limit ?? 50, req.offset ?? 0, req.source, req.time);
18885
19786
  case "detail": return await this.memory.get(req.fact_id) ?? { error: `fact_id=${req.fact_id} not found` };
18886
19787
  case "archive": return await this.memory.archive(req.fact_id, req.reason);
@@ -19031,11 +19932,9 @@ const descriptors = [
19031
19932
  content: optionalText.optional(),
19032
19933
  fact_id: count.optional(),
19033
19934
  kind: optionalText.optional(),
19034
- ttl_days: count.optional(),
19035
19935
  reason: optionalText.optional(),
19036
19936
  source_ref: optionalText.optional(),
19037
19937
  event_date: optionalText.optional(),
19038
- valid_until: optionalText.optional(),
19039
19938
  title: optionalText.optional(),
19040
19939
  retitle: bool.optional(),
19041
19940
  relations: z.array(z.object({
@@ -19254,7 +20153,7 @@ const VERSION_PACKAGES = [
19254
20153
  /** This plugin's npm package id, which names the probe and verification keys. */
19255
20154
  const PACKAGE_ID = String(hostContribution.package ?? "@avantf/dsh-mem");
19256
20155
  /** Best-effort one-line reason for a caught value. */
19257
- function reasonOf(error) {
20156
+ function reasonOf$2(error) {
19258
20157
  return error instanceof Error ? error.message : String(error);
19259
20158
  }
19260
20159
  /** Emit a warning without ever letting the logger turn it into a crash. */
@@ -19294,7 +20193,7 @@ function runtimeFromCompat(module) {
19294
20193
  }
19295
20194
  /** The "the check itself failed" outcome: keep loading, say why. */
19296
20195
  function cannotDetermine(error, log, prefix) {
19297
- const message = `${prefix} WARNING — the compatibility check itself failed (${reasonOf(error)}); loading anyway`;
20196
+ const message = `${prefix} WARNING — the compatibility check itself failed (${reasonOf$2(error)}); loading anyway`;
19298
20197
  warn(log, message);
19299
20198
  return { verdict: {
19300
20199
  load: true,
@@ -19378,6 +20277,290 @@ function verifyRegisteredFaces(input) {
19378
20277
  toolNames: input.toolNames
19379
20278
  });
19380
20279
  }
20280
+ //#endregion
20281
+ //#region lib/types/deps_audit.js
20282
+ /**
20283
+ * The STARTUP DEPENDENCY AUDIT: one cached, synchronous answer per CONFIGURED entry to "can this
20284
+ * process actually load that piece right now?", recorded at the end of the mount and refreshed when a
20285
+ * self-heal item settles.
20286
+ *
20287
+ * WHY IT EXISTS. A session notice is the last step of the fallback chain
20288
+ * `npm optional → self-heal → notice`, and the decision "is this really missing?" cannot be made on
20289
+ * the prompt path: `pre-step` runs on every user message, and resolving a package there would be a
20290
+ * filesystem hit (and, for a native addon, a `dlopen`) per step. So the expensive half happens ONCE,
20291
+ * off the mount path, and `pre-step` only consumes the registry, whose predicates read this snapshot.
20292
+ *
20293
+ * CONFIG-DRIVEN, NOT PACKAGE-DRIVEN. This module knows two probe KINDS and nothing about which
20294
+ * packages exist:
20295
+ *
20296
+ * - {@link moduleProbe} — resolve AND load one specifier (a real `require`/`import`, so a native
20297
+ * addon's binding is loaded rather than merely found), trying the ready shared roots first and
20298
+ * this tree last;
20299
+ * - {@link itemProbe} — the state of one runtime-deps item, through a caller-supplied lookup (the
20300
+ * provisioner's own `resolve`, with the "declared but not settled" gate on top).
20301
+ *
20302
+ * Which entries exist, how loud each one is, and what it says come from the plugin's SHIPPED
20303
+ * `res/runtime-deps.yaml` (`RuntimeDepsResourceSchema` in the contract). No package name, sentence or
20304
+ * severity is written here.
20305
+ *
20306
+ * WHAT IT IS NOT:
20307
+ *
20308
+ * - **Not a retry loop.** `run()` at startup, `run()` again when a self-heal item settles. A package
20309
+ * installed by hand afterwards is picked up on the next restart, not polled for.
20310
+ * - **Not throwing.** It runs beside a live host: a probe that fails is a recorded state plus a log
20311
+ * line, never an exception out of the mount.
20312
+ * - **Not a second source of truth about an item.** `itemProbe` ASKS the caller; the states here are
20313
+ * only a cache of that answer, refreshed on every settlement.
20314
+ */
20315
+ /** The states this build understands (a JS caller can hand over anything). */
20316
+ const STATES = [
20317
+ "available",
20318
+ "failed",
20319
+ "missing",
20320
+ "skipped",
20321
+ "pending",
20322
+ "unknown"
20323
+ ];
20324
+ /** Reason text for a thrown value, without trusting it. */
20325
+ function reasonOf$1(error) {
20326
+ try {
20327
+ if (error instanceof Error && typeof error.message === "string" && error.message !== "") return error.message;
20328
+ return String(error);
20329
+ } catch {
20330
+ return "unknown";
20331
+ }
20332
+ }
20333
+ /**
20334
+ * Build the audit.
20335
+ *
20336
+ * Every configured entry starts `unknown`; nothing is assumed about an entry that has not been
20337
+ * checked. The caller runs it once (fire-and-forget) and again for the entries a settlement could
20338
+ * change — that ordering is what makes a session line appear only after there is an observation.
20339
+ */
20340
+ function createDependencyAudit(options) {
20341
+ const states = /* @__PURE__ */ new Map();
20342
+ const pending = /* @__PURE__ */ new Map();
20343
+ const probes = /* @__PURE__ */ new Map();
20344
+ for (const probe of options.probes) {
20345
+ probes.set(probe.id, probe);
20346
+ states.set(probe.id, "unknown");
20347
+ }
20348
+ const warn = (message) => {
20349
+ try {
20350
+ options.logger?.warn(message);
20351
+ } catch {}
20352
+ };
20353
+ const info = (message) => {
20354
+ try {
20355
+ options.logger?.info(message);
20356
+ } catch {}
20357
+ };
20358
+ const runId = (id) => {
20359
+ const running = pending.get(id);
20360
+ if (running !== void 0) return running;
20361
+ const probe = probes.get(id);
20362
+ if (probe === void 0) return Promise.resolve();
20363
+ const task = (async () => {
20364
+ let next;
20365
+ try {
20366
+ next = await probe.check();
20367
+ } catch (error) {
20368
+ warn(`deps audit: probing ${id} failed (${reasonOf$1(error)}); its state stays unknown`);
20369
+ next = "unknown";
20370
+ }
20371
+ if (!STATES.includes(next)) next = "unknown";
20372
+ const before = states.get(id);
20373
+ states.set(id, next);
20374
+ if (before !== next) info(`deps audit: ${id} is ${next}`);
20375
+ })().finally(() => {
20376
+ pending.delete(id);
20377
+ });
20378
+ pending.set(id, task);
20379
+ return task;
20380
+ };
20381
+ return {
20382
+ state(id) {
20383
+ return states.get(id) ?? "unknown";
20384
+ },
20385
+ vectors() {
20386
+ try {
20387
+ return options.vectors();
20388
+ } catch {
20389
+ return 0;
20390
+ }
20391
+ },
20392
+ async run() {
20393
+ await Promise.all([...probes.keys()].map((id) => runId(id)));
20394
+ },
20395
+ runId
20396
+ };
20397
+ }
20398
+ /**
20399
+ * Resolve and LOAD one specifier, from the first root that can serve it and then from this tree.
20400
+ *
20401
+ * The `createRequire(<root>/package.json)` anchor is the same rule the semantic adapter uses: a
20402
+ * shared runtime-deps root's `package.json` is what makes `resolve` look in THAT root's
20403
+ * `node_modules`, so the audit observes the copy the runtime would load. The import is by FILE URL
20404
+ * because the resolved entry is inside the root, not a bare specifier this module could resolve.
20405
+ *
20406
+ * The three answers are deliberately distinct, because they lead to different repairs:
20407
+ *
20408
+ * - `available` — it loaded;
20409
+ * - `failed` — something RESOLVED but loading it threw (the node-gyp build that exists and does not
20410
+ * work: the case a bare `resolve()` check would report as healthy);
20411
+ * - `missing` — nothing resolved anywhere.
20412
+ *
20413
+ * A root that cannot serve the specifier is not an error: the next candidate is tried, which is what
20414
+ * makes a garbage-collected store no worse than no self-heal. Never throws.
20415
+ */
20416
+ async function moduleProbe(id, specifier, roots) {
20417
+ const candidates = [];
20418
+ for (const root of roots()) {
20419
+ if (root === "") continue;
20420
+ try {
20421
+ candidates.push(createRequire(join(root, "package.json")).resolve(specifier));
20422
+ } catch {}
20423
+ }
20424
+ try {
20425
+ candidates.push(createRequire(import.meta.url).resolve(specifier));
20426
+ } catch {}
20427
+ if (candidates.length === 0) return "missing";
20428
+ let resolvedButBroken = false;
20429
+ for (const entry of candidates) try {
20430
+ await import(pathToFileURL(entry).href);
20431
+ return "available";
20432
+ } catch {
20433
+ resolvedButBroken = true;
20434
+ }
20435
+ return resolvedButBroken ? "failed" : "missing";
20436
+ }
20437
+ /**
20438
+ * Observe one runtime-deps ITEM's state through the caller's own lookup.
20439
+ *
20440
+ * The caller owns the mapping (its provisioner's `resolve` plus the "declared but not settled" gate),
20441
+ * so this probe adds nothing but a defensive boundary: a throwing lookup is `unknown`, not a failure
20442
+ * claim. `pending` is passed through because it is a MEANINGFUL state — an install in flight — and the
20443
+ * notice predicate treats it as "not reportable yet".
20444
+ */
20445
+ function itemProbe(id, itemId, stateOf) {
20446
+ return {
20447
+ id,
20448
+ check: async () => {
20449
+ try {
20450
+ return stateOf(itemId);
20451
+ } catch {
20452
+ return "unknown";
20453
+ }
20454
+ }
20455
+ };
20456
+ }
20457
+ //#endregion
20458
+ //#region lib/types/runtime_deps_resource.js
20459
+ /**
20460
+ * The plugin's OWN dependency declaration: `res/runtime-deps.yaml`, read from the INSTALL directory.
20461
+ *
20462
+ * WHY A SHIPPED RESOURCE. The package set, the sources, the attempts, the verify list and the audit
20463
+ * sentences describe THIS BUILD — which module the local backend imports, which native bindings this
20464
+ * release looks for, what a session should be told when one of them is missing. Keeping them in a
20465
+ * user's `configs/common.yaml` made every deployment responsible for a list only the package can know
20466
+ * (and made a stale copy install a module no code loads). The operator keeps the one real policy
20467
+ * decision — the two `enabled` switches — and the DATA travels with the code that depends on it.
20468
+ *
20469
+ * WHERE IT IS READ FROM. `<package>/res/runtime-deps.yaml`, located from `import.meta.url`:
20470
+ * `lib/index.js` (bundled) and `src/*.ts` (tests, dev) are both one level below the package root, so
20471
+ * the same relative URL resolves to the SAME file in the checkout and in an installed tarball. It is
20472
+ * deliberately NOT resolved through `node_modules` lookups, `~/.avantf`, or the cwd: a host may mount
20473
+ * the plugin from a linked checkout, a global install or a packed tarball, and all three must read
20474
+ * their own copy.
20475
+ *
20476
+ * FAILURE IS EXPLICIT AND NON-FATAL. A missing/unreadable/malformed file is a PACKAGING bug, not an
20477
+ * environment one, so the answer is a structured failure with the path and the reason — never a
20478
+ * throw out of the mount and never a silent "no dependencies declared". The caller logs it as an
20479
+ * ERROR and degrades (no self-heal item, no audit): the plugin keeps its tools, service, prompt and
20480
+ * Remote face, and the runtime stays on its optional-dependency resolution.
20481
+ *
20482
+ * The base never sees this file: `envinit` maps the validated object onto the provider's own
20483
+ * `RuntimeDepsSpec` field by field (see `runtimeDepsSpecOf`).
20484
+ */
20485
+ /**
20486
+ * `res/runtime-deps.yaml` next to the package that owns this module.
20487
+ *
20488
+ * `import.meta.url` is the anchor (not `process.cwd()`, not a config path): the file ships beside the
20489
+ * code, and an installed plugin must read the copy its own package manager put there.
20490
+ */
20491
+ function runtimeDepsResourceUrl() {
20492
+ return new URL("../res/runtime-deps.yaml", import.meta.url);
20493
+ }
20494
+ /** Reason text for a thrown value, without trusting it. */
20495
+ function reasonOf(error) {
20496
+ try {
20497
+ if (error instanceof Error && typeof error.message === "string" && error.message !== "") return error.message;
20498
+ return String(error);
20499
+ } catch {
20500
+ return "unknown";
20501
+ }
20502
+ }
20503
+ /**
20504
+ * Read and validate the shipped declaration.
20505
+ *
20506
+ * Synchronous on purpose: it is a few kilobytes next to the entry module, read once at mount, and the
20507
+ * caller's plan (the item specs and the audit entries) has to exist before anything is declared.
20508
+ *
20509
+ * `url` is injectable so a test can point at a scratch install directory; production always uses
20510
+ * {@link runtimeDepsResourceUrl}.
20511
+ */
20512
+ function loadRuntimeDepsResource(url = runtimeDepsResourceUrl()) {
20513
+ let path;
20514
+ try {
20515
+ path = fileURLToPath(url);
20516
+ } catch (error) {
20517
+ return {
20518
+ ok: false,
20519
+ path: String(url),
20520
+ reason: `不是可读的文件 URL(${reasonOf(error)})`
20521
+ };
20522
+ }
20523
+ let text;
20524
+ try {
20525
+ text = readFileSync(path, "utf8");
20526
+ } catch (error) {
20527
+ return {
20528
+ ok: false,
20529
+ path,
20530
+ reason: `无法读取(${reasonOf(error)})`
20531
+ };
20532
+ }
20533
+ let raw;
20534
+ try {
20535
+ raw = parse(text);
20536
+ } catch (error) {
20537
+ return {
20538
+ ok: false,
20539
+ path,
20540
+ reason: `YAML 解析失败(${reasonOf(error)})`
20541
+ };
20542
+ }
20543
+ if (raw === null || raw === void 0) return {
20544
+ ok: false,
20545
+ path,
20546
+ reason: "文件是空的(没有任何声明)"
20547
+ };
20548
+ const parsed = RuntimeDepsResourceSchema.safeParse(raw);
20549
+ if (!parsed.success) {
20550
+ const issue = parsed.error.issues[0];
20551
+ const where = issue === void 0 ? "" : `${issue.path.join(".") === "" ? "" : `${issue.path.join(".")}: `}`;
20552
+ return {
20553
+ ok: false,
20554
+ path,
20555
+ reason: `声明不符合 RuntimeDepsResourceSchema(${where}${issue?.message ?? "invalid"})`
20556
+ };
20557
+ }
20558
+ return {
20559
+ ok: true,
20560
+ path,
20561
+ resource: parsed.data
20562
+ };
20563
+ }
19381
20564
  const PACKAGE = "@avantf/dsh-plugin-base";
19382
20565
  function parseVersion(input) {
19383
20566
  const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(input.trim());
@@ -19761,6 +20944,25 @@ function rangeOf(value) {
19761
20944
  * models elsewhere (`AVANTF_MEM_MODEL_CACHE`) — that would install a second, never-read copy.
19762
20945
  * A model that cannot be fetched is a WARNING: the semantic path degrades to FTS+entity, it
19763
20946
  * never refuses the mount.
20947
+ * 3. **`mem:semantic-deps`** (`runtime-deps`, `startup: 'background'`) — optional: repair a missing
20948
+ * `@huggingface/transformers` install through the base's shared runtime-dependency store. It is
20949
+ * OFF unless `semantic.self_heal.enabled` says otherwise (that is a network decision, not a
20950
+ * startup default), needs base >= 0.7.0 / interface v6 (the `createRuntimeDeps` helper), and is
20951
+ * declared against the SAME content-addressed root any other family plugin would reuse. The
20952
+ * item, the shared root handle and the operator's configured session notices all come from that
20953
+ * one handle. The handle directory is handed back to the caller, which points the embedding
20954
+ * backend at it before the semantic warm.
20955
+ * 4. **`mem:native-deps`** (`runtime-deps`, `startup: 'background'`) — optional: repair the NATIVE
20956
+ * optional dependencies (`nodejieba`, `hnswlib-node`) when `semantic.native_self_heal.enabled`
20957
+ * is on. A SEPARATE item on purpose: a different package set is a different content key, so the
20958
+ * base's store gives it its own immutable root instead of mixing two node-gyp builds into the
20959
+ * transformers install. The handle directory is handed back to the caller, which points the
20960
+ * tokenizer and the vector store's ANN binding at it.
20961
+ *
20962
+ * Sessions are told about a missing package by NOTICES, and a notice is judged on two observations
20963
+ * that this module neither owns alone: the item's live state (below) and the startup dependency
20964
+ * audit's cached answer (`deps_audit.ts`, supplied in the plan). A repair that is still installing
20965
+ * matches nothing, and a failed repair whose package is loadable stays silent.
19764
20966
  *
19765
20967
  * Two hard rules:
19766
20968
  *
@@ -19783,6 +20985,26 @@ const PLUGIN = "mem";
19783
20985
  */
19784
20986
  const MODEL_ITEM = "mem:model";
19785
20987
  /**
20988
+ * The shared runtime-dependency item: `@huggingface/transformers` repaired through the base's
20989
+ * content-addressed store when `semantic.self_heal.enabled` is on. Same root as every other family
20990
+ * plugin's item for the same package set.
20991
+ */
20992
+ const SEMANTIC_DEPS_ITEM = "mem:semantic-deps";
20993
+ /**
20994
+ * The NATIVE runtime-dependency item: `nodejieba` + `hnswlib-node`, repaired through the same base
20995
+ * mechanism when `semantic.native_self_heal.enabled` is on.
20996
+ *
20997
+ * A SEPARATE item, not a third package in {@link SEMANTIC_DEPS_ITEM}, for two reasons that both
20998
+ * matter:
20999
+ *
21000
+ * - the store is content-addressed, so this spec's package set (two node-gyp builds) keys a
21001
+ * DIFFERENT immutable root under `<home>/deps/runtime/` — these packages are never installed into
21002
+ * the transformers root, and vice versa;
21003
+ * - the two have independent failure modes and independent consequences (semantic leg vs.
21004
+ * entity/jaccard legs vs. ANN index), so one install failing must not report the other as broken.
21005
+ */
21006
+ const NATIVE_DEPS_ITEM = "mem:native-deps";
21007
+ /**
19786
21008
  * The default ONNX embedding repository — the one `semantic.local_model` defaults to and the one
19787
21009
  * {@link MODEL_FILES} describes. A DIFFERENT repo is a different file set, which is why the item
19788
21010
  * omits `spec.files` for it (see {@link managedModelSpec}).
@@ -19824,6 +21046,23 @@ const DEADLINE_MS = 15e3;
19824
21046
  * rather than ship the inversion.
19825
21047
  */
19826
21048
  const MODEL_REQUEST_TIMEOUT_MS = 3e5;
21049
+ /**
21050
+ * Per-attempt budget for the `mem:semantic-deps` install. A package-manager install of
21051
+ * transformers.js + onnxruntime-node is tens of MB; the base's 30 s network default would time out on
21052
+ * a slow link and report a failure the next attempt could have avoided. The item is `background`, so
21053
+ * this budget spends none of the startup allowance; it only bounds one attempt.
21054
+ */
21055
+ const SEMANTIC_DEPS_ATTEMPT_TIMEOUT_MS = 6e5;
21056
+ /**
21057
+ * Per-attempt budget for the `mem:native-deps` install.
21058
+ *
21059
+ * Longer than the semantic one on purpose: `nodejieba` and `hnswlib-node` are node-gyp packages, so
21060
+ * the attempt includes downloading a toolchain-compatible source tree and COMPILING it — measured
21061
+ * minutes on a cold cache, and slower on a laptop that is also running the host. A budget that
21062
+ * expires mid-compile would report a failure the very next attempt (with the compiler cache warm)
21063
+ * would have avoided, and the item is `background`, so this spends no startup allowance.
21064
+ */
21065
+ const NATIVE_DEPS_ATTEMPT_TIMEOUT_MS = 9e5;
19827
21066
  /** This module's warning token, so one grep finds every environment-initialisation line. */
19828
21067
  const OWN_PREFIX = "envinit:";
19829
21068
  /**
@@ -19858,6 +21097,104 @@ function sameDir(a, b) {
19858
21097
  return resolve(expandHome$1(a)) === resolve(expandHome$1(b));
19859
21098
  }
19860
21099
  /**
21100
+ * The `runtime-deps` spec the base's shared store should own, or `undefined` when it should not.
21101
+ *
21102
+ * Refused when:
21103
+ *
21104
+ * 1. **self-heal is off** — the shipped default. Enabling it is a network decision the operator
21105
+ * makes (`semantic.self_heal.enabled`), never a startup surprise.
21106
+ * 2. **the backend is not `local_bge`** — this item repairs the module the local ONNX adapter
21107
+ * loads; a third-party backend owns its own dependencies.
21108
+ * 3. **no package was declared** — `spec.packages` must be non-empty (the base rejects an empty
21109
+ * map); declaring nothing is cleaner than a guaranteed `invalid-option`.
21110
+ *
21111
+ * The config layers are copied field by field, not passed through: the schema's defaults include an
21112
+ * empty `cache` placeholder that has to become the family root here (`<home>/cache/npm`), because
21113
+ * the schema cannot know which family root the host will resolve. An explicitly configured cache is
21114
+ * `~`-expanded, exactly like every other path in this plugin.
21115
+ */
21116
+ function managedSemanticDepsSpec(semantic, declaration, home) {
21117
+ if (!semantic.self_heal.enabled) return void 0;
21118
+ if (semantic.backend !== "local_bge") return void 0;
21119
+ if (Object.keys(declaration.packages).length === 0) return void 0;
21120
+ return runtimeDepsSpecOf(declaration, home);
21121
+ }
21122
+ /**
21123
+ * The `runtime-deps` spec for the NATIVE packages, or `undefined` when nothing should be declared.
21124
+ *
21125
+ * Refused only when `semantic.native_self_heal.enabled` is off or the package map is empty. There is
21126
+ * no backend condition here (unlike the semantic spec): the tokenizer and the vector store are used
21127
+ * by every backend, and a `vectorStore.backend: local_numpy` deployment still pays for `nodejieba`.
21128
+ */
21129
+ function managedNativeDepsSpec(semantic, declaration, home) {
21130
+ if (!semantic.native_self_heal.enabled) return void 0;
21131
+ if (Object.keys(declaration.packages).length === 0) return void 0;
21132
+ return runtimeDepsSpecOf(declaration, home);
21133
+ }
21134
+ /**
21135
+ * The shared shape of both `runtime-deps` specs: packages, sources, attempts, verify.
21136
+ *
21137
+ * One implementation, because the two sections use ONE provider vocabulary — a second copy of the
21138
+ * cache/`~` rules is how a private registry ends up working in one section and not the other. The
21139
+ * config layers are copied field by field, not passed through: the schema's defaults include an empty
21140
+ * `cache` placeholder that has to become the family root here (`<home>/cache/npm`), because the
21141
+ * schema cannot know which family root the host will resolve.
21142
+ */
21143
+ function runtimeDepsSpecOf(heal, home) {
21144
+ const packages = {};
21145
+ for (const [name, range] of Object.entries(heal.packages)) packages[name] = range;
21146
+ return {
21147
+ packages,
21148
+ sources: heal.sources.map((source) => {
21149
+ const registry = source.registry.trim();
21150
+ const configuredCache = source.cache.trim();
21151
+ const cache = registry === "" && configuredCache === "" ? join(home, "cache", "npm") : configuredCache === "" ? "" : expandHome$1(configuredCache);
21152
+ return {
21153
+ id: source.id,
21154
+ ...registry === "" ? {} : { registry },
21155
+ ...cache === "" ? {} : { cache }
21156
+ };
21157
+ }),
21158
+ attempts: heal.attempts.map((attempt) => ({
21159
+ source: attempt.source,
21160
+ ...attempt.manager === void 0 ? {} : { manager: attempt.manager },
21161
+ ...Object.keys(attempt.env).length === 0 ? {} : { env: { ...attempt.env } },
21162
+ ...attempt.allowScripts === void 0 ? {} : { allowScripts: attempt.allowScripts }
21163
+ })),
21164
+ verify: [...heal.verify]
21165
+ };
21166
+ }
21167
+ /**
21168
+ * The configured dependency-audit entries, copied field by field and resolved for execution.
21169
+ *
21170
+ * Why this is not part of a self-heal spec: the spec is what the base INSTALLS (and part of the shared
21171
+ * root's content key, so it has to be exactly the provider's vocabulary), while an audit entry is a
21172
+ * PROMPT line plus the observation behind it. Two consumers, two lifetimes — editing a sentence must
21173
+ * not look like a new dependency set.
21174
+ *
21175
+ * The entries are returned VERBATIM (text, severity, condition) and no default is invented: the
21176
+ * config's default is the empty array, so a profile that never wrote one gets no injection at all.
21177
+ * `min_vectors` is defaulted to `0` here, and to nothing else — this section names no package, so it
21178
+ * also invents no package-specific threshold.
21179
+ */
21180
+ function managedDependencyAudit(audit) {
21181
+ if (!Array.isArray(audit)) return [];
21182
+ return audit.map((entry) => ({
21183
+ id: entry.id,
21184
+ severity: entry.severity,
21185
+ when: entry.when,
21186
+ minVectors: entry.min_vectors ?? 0,
21187
+ body: entry.body,
21188
+ probe: entry.kind === "module" ? {
21189
+ kind: "module",
21190
+ specifier: entry.specifier
21191
+ } : {
21192
+ kind: "runtime-deps",
21193
+ item: entry.item
21194
+ }
21195
+ }));
21196
+ }
21197
+ /**
19861
21198
  * The family root: `$AVANTF_HOME`, else `~/.avantf/env`.
19862
21199
  *
19863
21200
  * `AVANTF_HOME` is the same variable the engine uses for its DATA home (`memory/`),
@@ -19947,6 +21284,121 @@ function modelItem(framework, model) {
19947
21284
  };
19948
21285
  }
19949
21286
  /**
21287
+ * A `runtime-deps` item with the DELIVERY policy this plugin owns (the helper supplies only the
21288
+ * shape): both `onMissing` axes `degrade` — a failed install may not refuse the mount — `background`
21289
+ * (an install may take minutes and spends no startup budget), and the caller's per-attempt budget.
21290
+ *
21291
+ * `target.root: 'deps/runtime'` is the base's canonical store, already set by the helper. Two items
21292
+ * with different package sets are different content keys and therefore different immutable roots
21293
+ * inside it, which is what keeps `mem:semantic-deps` and `mem:native-deps` from sharing an install.
21294
+ */
21295
+ function runtimeDepsItem(item, timeoutMs) {
21296
+ return {
21297
+ ...item,
21298
+ onMissing: {
21299
+ atStartup: "degrade",
21300
+ atUse: "degrade"
21301
+ },
21302
+ startup: "background",
21303
+ policy: { timeoutMs }
21304
+ };
21305
+ }
21306
+ /** The provisioner's five states in the audit's vocabulary. */
21307
+ function dependencyStateOf(state) {
21308
+ switch (state?.state) {
21309
+ case "ready": return "available";
21310
+ case "failed": return "failed";
21311
+ case "missing": return "missing";
21312
+ case "skipped": return "skipped";
21313
+ case "pending": return "pending";
21314
+ default: return "unknown";
21315
+ }
21316
+ }
21317
+ /**
21318
+ * Which observed audit state one configured `when` covers.
21319
+ *
21320
+ * The same word means the same thing for both probe KINDS, so one mapping serves both:
21321
+ * `failed` → a load that threw / an item that failed; `missing` → nothing resolved, or the item is
21322
+ * missing/skipped; `degraded` → any of those three. `available`, `pending` and `unknown` match
21323
+ * NOTHING: a background install still in flight and an unobserved entry are both "not reportable
21324
+ * yet", and a healthy dependency is not a finding.
21325
+ */
21326
+ function matchesWhen(when, state) {
21327
+ switch (when) {
21328
+ case "failed": return state === "failed";
21329
+ case "missing": return state === "missing" || state === "skipped";
21330
+ case "degraded": return state === "failed" || state === "missing" || state === "skipped";
21331
+ }
21332
+ }
21333
+ /**
21334
+ * Register one session notice per configured audit entry, judged on the AUDIT's cached state.
21335
+ *
21336
+ * The predicate is a pure read of two snapshots (`state()` is a map read, `vectors()` is the live
21337
+ * index count), so the prompt path never probes anything — the expensive half ran once at startup and
21338
+ * again on each settlement (`startDependencyAudit` / the `onSettled` path). A line is delivered when:
21339
+ *
21340
+ * - the entry's recorded state matches the configured `when` (see {@link matchesWhen}), and
21341
+ * - the live vector index has reached `minVectors` (0 = no threshold).
21342
+ *
21343
+ * Registration itself is total: the base registry warns and ignores a duplicate/malformed id, and an
21344
+ * id that is already taken costs one line, never the step.
21345
+ */
21346
+ function registerDependencyNotices(input) {
21347
+ for (const notice of input.notices) input.registry.register({
21348
+ id: notice.id,
21349
+ severity: notice.severity,
21350
+ when: notice.when,
21351
+ body: notice.body
21352
+ }, () => matchesWhen(notice.when, input.audit.state(notice.id)) && input.audit.vectors() >= notice.minVectors);
21353
+ }
21354
+ /** Build the probes an audit entry needs — one per configured KIND, no package named here. */
21355
+ function probesOf(notices, roots, itemState) {
21356
+ return notices.map((notice) => {
21357
+ switch (notice.probe.kind) {
21358
+ case "module": {
21359
+ const { specifier } = notice.probe;
21360
+ return {
21361
+ id: notice.id,
21362
+ check: () => moduleProbe(notice.id, specifier, roots)
21363
+ };
21364
+ }
21365
+ case "runtime-deps": {
21366
+ const { item } = notice.probe;
21367
+ return itemProbe(notice.id, item, itemState);
21368
+ }
21369
+ }
21370
+ });
21371
+ }
21372
+ /**
21373
+ * Whether the loaded base can serve the shared `runtime-deps` item AND observe it.
21374
+ *
21375
+ * Three members are required, and all three are one capability here: `RUNTIME_DEPS_KIND` /
21376
+ * `runtimeDepsProvider` (interface v5, base 0.6.0) install the root, and `createRuntimeDeps`
21377
+ * (interface v6, base 0.7.0) is what DECLARES the item and binds the notices to its state. Since the
21378
+ * item is now built by that helper, a base with the provider but not the helper cannot serve this
21379
+ * plugin at all — so the predicate answers the caller's real question ("may I declare, wait for and
21380
+ * translate this item?") instead of a narrower one that would let a half-wired declaration through.
21381
+ *
21382
+ * An OLDER generation does not normally reach this point — the interface gate withholds the whole
21383
+ * base first (`gateOrDegrade`), so the plugin takes its legacy path. This guard covers the remaining
21384
+ * shape: a module that reports a current generation but does not actually carry the members (a
21385
+ * partial/hostile module, or a packaging mistake). Enabling self-heal must then degrade to the
21386
+ * runtime's optional-dependency path, not throw from `declare`. It is also what lets the fake
21387
+ * framework in the tests exercise that degradation instead of a TypeError.
21388
+ *
21389
+ * Exported because the startup caller asks the same question: a skipped item will never settle, so
21390
+ * `startManagedResources` must not wait for it (see `packages/plugin/src/index.ts`). One predicate,
21391
+ * two readers — the loader's skip and the caller's "do not wait" can never disagree.
21392
+ *
21393
+ * `undefined` (the test seam's partial framework, or no module at all) answers `false`: nothing is
21394
+ * served, so nothing may be awaited.
21395
+ */
21396
+ function supportsRuntimeDeps(framework) {
21397
+ if (framework === void 0) return false;
21398
+ const candidate = framework;
21399
+ return typeof candidate.RUNTIME_DEPS_KIND === "string" && typeof candidate.runtimeDepsProvider === "function" && typeof candidate.createRuntimeDeps === "function";
21400
+ }
21401
+ /**
19950
21402
  * The plugin-specific halves of the shared loader's sentences: mem says the FRAMEWORK is skipped and
19951
21403
  * names the legacy provisioning path among the withheld capabilities. The mechanism is the shim's
19952
21404
  * (`interface_gate.ts`), the wording is this plugin's.
@@ -19980,6 +21432,8 @@ function loadEnvinit(options = {}) {
19980
21432
  /** The body of {@link loadEnvinit}; the cache above makes it run at most once per process. */
19981
21433
  async function loadOnce(options) {
19982
21434
  const home = options.home ?? resolveHome();
21435
+ const declaration = loadRuntimeDepsResource(options.depsResourceUrl);
21436
+ if (!declaration.ok) options.log?.error(`${OWN_PREFIX} ERROR — ${declaration.path} is unusable (${declaration.reason}); self-heal and the startup dependency audit are SKIPPED for this run (the plugin mounts, and every optional dependency keeps its own fallback)`);
19983
21437
  const range = await readDependencyRange(fileURLToPath(import.meta.url));
19984
21438
  const autoDownload = options.autoDownload ?? envDownloadsAllowed();
19985
21439
  const deadlineMs = options.deadlineMs ?? DEADLINE_MS;
@@ -20001,9 +21455,15 @@ async function loadOnce(options) {
20001
21455
  try {
20002
21456
  compat = runtimeFromCompat(options.compatModule ?? framework);
20003
21457
  } catch (error) {
20004
- warn(options.log, `${OWN_PREFIX} WARNING — the ${COMPAT_PACKAGE} compatibility gate could not be initialised (${reasonOf(error)}); the compatibility gate is SKIPPED and the plugin will mount anyway`);
21458
+ warn(options.log, `${OWN_PREFIX} WARNING — the ${COMPAT_PACKAGE} compatibility gate could not be initialised (${reasonOf$2(error)}); the compatibility gate is SKIPPED and the plugin will mount anyway`);
20005
21459
  }
20006
21460
  let disposed = false;
21461
+ /** The shared `runtime-deps` handles: set when `provisionResources` declares each item. */
21462
+ let deps;
21463
+ let native;
21464
+ /** The startup dependency audit and its notice registry; both exist once `provisionResources` ran. */
21465
+ let audit;
21466
+ let noticeRegistry;
20007
21467
  return {
20008
21468
  home,
20009
21469
  kit: framework,
@@ -20012,40 +21472,148 @@ async function loadOnce(options) {
20012
21472
  tools: join(home, "tools"),
20013
21473
  models: join(home, "models")
20014
21474
  },
20015
- provisionResources(plan, onSettled) {
21475
+ runtimeDeps() {
21476
+ return deps;
21477
+ },
21478
+ nativeDeps() {
21479
+ return native;
21480
+ },
21481
+ depsResource() {
21482
+ return declaration;
21483
+ },
21484
+ notices() {
21485
+ return noticeRegistry;
21486
+ },
21487
+ async startDependencyAudit() {
21488
+ if (audit === void 0) return;
21489
+ try {
21490
+ await audit.run();
21491
+ } catch (error) {
21492
+ warn(options.log, `${OWN_PREFIX} WARNING — the dependency audit failed (${reasonOf$2(error)}); notices stay silent`);
21493
+ }
21494
+ },
21495
+ provisionResources(plan, onSettled, onFailure) {
20016
21496
  if (disposed) return;
21497
+ const canServe = supportsRuntimeDeps(framework);
21498
+ const wantedDeps = plan.semanticDeps;
21499
+ const wantedNative = plan.nativeDeps;
21500
+ if (wantedDeps !== void 0 && !canServe) warn(options.log, `${OWN_PREFIX} WARNING — semantic self-heal is enabled but the loaded ${FRAMEWORK_PACKAGE} has no runtime-deps helper (it needs base >= 0.7.0 / interface v6); the item is SKIPPED and the plugin will mount anyway`);
21501
+ if (wantedNative !== void 0 && !canServe) warn(options.log, `${OWN_PREFIX} WARNING — native self-heal is enabled but the loaded ${FRAMEWORK_PACKAGE} has no runtime-deps helper (it needs base >= 0.7.0 / interface v6); the item is SKIPPED and the plugin will mount anyway`);
20017
21502
  const items = [];
20018
21503
  if (plan.model !== void 0) items.push(modelItem(framework, plan.model));
20019
- if (items.length === 0) return;
21504
+ /**
21505
+ * Declare one shared item through the helper.
21506
+ *
21507
+ * The spec fields are forwarded verbatim (the helper copies them, so the content key cannot
21508
+ * move under a mutating caller). Notices are NOT passed in: the helper's registry binds a line
21509
+ * to its ITEM's state alone, while an audit entry may observe a package that no item
21510
+ * of ours installs — so every notice is registered below, on one registry, from the audit.
21511
+ */
21512
+ const declare = (itemId, spec, timeoutMs, label) => {
21513
+ try {
21514
+ const handle = framework.createRuntimeDeps({
21515
+ namespace: PLUGIN,
21516
+ itemId,
21517
+ ...spec
21518
+ });
21519
+ items.push(runtimeDepsItem(handle.item, timeoutMs));
21520
+ return handle;
21521
+ } catch (error) {
21522
+ warn(options.log, `${OWN_PREFIX} WARNING — the shared ${label} runtime-deps item could not be declared (${reasonOf$2(error)}); self-heal for it is SKIPPED and the plugin will mount anyway`);
21523
+ return;
21524
+ }
21525
+ };
21526
+ if (wantedDeps !== void 0 && canServe) deps = declare(SEMANTIC_DEPS_ITEM, wantedDeps.spec, SEMANTIC_DEPS_ATTEMPT_TIMEOUT_MS, "semantic");
21527
+ if (wantedNative !== void 0 && canServe) native = declare(NATIVE_DEPS_ITEM, wantedNative.spec, NATIVE_DEPS_ATTEMPT_TIMEOUT_MS, "native");
21528
+ const handles = [deps, native].filter((handle) => handle !== void 0);
20020
21529
  const maxItemTimeoutMs = items.reduce((max, item) => Math.max(max, item.policy?.timeoutMs ?? 0), 0);
20021
- const resources = framework.createProvisioner({
20022
- home,
20023
- envinitRange: range,
20024
- logger: provisionLogger(options.log),
20025
- policy: {
20026
- autoDownload: autoDownload ? { [framework.MODEL_CACHE_KIND]: plan.modelAutoDownload } : false,
20027
- deadlineMs: Math.max(deadlineMs, maxItemTimeoutMs),
20028
- mirrors: { archive: [...plan.archiveMirrors] }
21530
+ /** The shared roots a module probe may resolve from, in declaration order (ready ones only). */
21531
+ const roots = () => handles.map((handle) => handle.resolve()?.dir).filter((dir) => dir !== void 0 && dir !== "");
21532
+ let resources;
21533
+ if (items.length > 0) {
21534
+ resources = framework.createProvisioner({
21535
+ home,
21536
+ envinitRange: range,
21537
+ logger: provisionLogger(options.log),
21538
+ policy: {
21539
+ autoDownload: autoDownload ? {
21540
+ [framework.MODEL_CACHE_KIND]: plan.modelAutoDownload,
21541
+ ...handles.length === 0 ? {} : { [framework.RUNTIME_DEPS_KIND]: true }
21542
+ } : false,
21543
+ deadlineMs: Math.max(deadlineMs, maxItemTimeoutMs),
21544
+ mirrors: { archive: [...plan.archiveMirrors] }
21545
+ }
21546
+ });
21547
+ resources.register(framework.binaryArchiveProvider());
21548
+ resources.register(framework.modelCacheProvider());
21549
+ if (handles.length > 0) resources.register(framework.runtimeDepsProvider());
21550
+ resources.declare({
21551
+ plugin: PLUGIN,
21552
+ items
21553
+ });
21554
+ disposers.push(resources);
21555
+ }
21556
+ const inflight = new Set(items.map((item) => item.id));
21557
+ const itemState = (id) => {
21558
+ if (inflight.has(id)) return "pending";
21559
+ if (resources === void 0) return "missing";
21560
+ return dependencyStateOf(resources.resolve(id));
21561
+ };
21562
+ const notices = plan.dependencyAudit ?? [];
21563
+ if (notices.length > 0) {
21564
+ const createRegistry = framework.createSessionNotices;
21565
+ if (typeof createRegistry !== "function") warn(options.log, `${OWN_PREFIX} WARNING — ${notices.length} dependency-audit entr(y/ies) are declared but the loaded ${FRAMEWORK_PACKAGE} has no session-notice registry (it needs base >= 0.7.0 / interface v6); no notice will be delivered`);
21566
+ audit = createDependencyAudit({
21567
+ probes: probesOf(notices, roots, itemState),
21568
+ vectors: plan.vectorCount ?? (() => 0),
21569
+ logger: {
21570
+ info: (message) => {
21571
+ options.log?.info(`${OWN_PREFIX} ${message}`);
21572
+ },
21573
+ warn: (message) => {
21574
+ options.log?.warn(`${OWN_PREFIX} ${message}`);
21575
+ }
21576
+ }
21577
+ });
21578
+ if (typeof createRegistry === "function") {
21579
+ noticeRegistry = createRegistry.call(framework, provisionLogger(options.log));
21580
+ registerDependencyNotices({
21581
+ registry: noticeRegistry,
21582
+ notices,
21583
+ audit
21584
+ });
20029
21585
  }
21586
+ }
21587
+ if (resources === void 0) return;
21588
+ for (const handle of handles) handle.ensure(resources).catch((error) => {
21589
+ warn(options.log, `${OWN_PREFIX} WARNING — the shared runtime-deps item could not be dispatched (${reasonOf$2(error)}); the plugin continues`);
20030
21590
  });
20031
- resources.register(framework.binaryArchiveProvider());
20032
- resources.register(framework.modelCacheProvider());
20033
- resources.declare({
20034
- plugin: PLUGIN,
20035
- items
20036
- });
20037
- disposers.push(resources);
20038
21591
  resources.ensure({
20039
21592
  only: items.map((item) => item.id),
20040
21593
  onSettled: (entry) => {
20041
21594
  try {
20042
- onSettled(entry);
21595
+ inflight.delete(entry.id);
21596
+ const shared = handles.find((handle) => handle.item.id === entry.id)?.resolve();
21597
+ const state = resources.resolve(entry.id);
21598
+ onSettled(entry, shared !== void 0 ? shared.dir : state !== void 0 && state.state === "ready" ? state.handle.dir : void 0);
21599
+ if (audit !== void 0) audit.run().catch((error) => {
21600
+ warn(options.log, `${OWN_PREFIX} WARNING — the dependency audit after ${entry.id} failed (${reasonOf$2(error)})`);
21601
+ });
20043
21602
  } catch (error) {
20044
- warn(options.log, `${OWN_PREFIX} WARNING — the onSettled callback failed (${reasonOf(error)})`);
21603
+ warn(options.log, `${OWN_PREFIX} WARNING — the onSettled callback failed (${reasonOf$2(error)})`);
20045
21604
  }
20046
21605
  }
20047
21606
  }).catch((error) => {
20048
- warn(options.log, `${OWN_PREFIX} WARNING — declaring the background resources failed (${reasonOf(error)}); the plugin continues (tools stay registered)`);
21607
+ warn(options.log, `${OWN_PREFIX} WARNING — declaring the background resources failed (${reasonOf$2(error)}); the plugin continues (tools stay registered)`);
21608
+ inflight.clear();
21609
+ if (audit !== void 0) audit.run().catch((auditError) => {
21610
+ warn(options.log, `${OWN_PREFIX} WARNING — the dependency audit after a dispatch failure failed (${reasonOf$2(auditError)})`);
21611
+ });
21612
+ try {
21613
+ onFailure?.(error);
21614
+ } catch (callbackError) {
21615
+ warn(options.log, `${OWN_PREFIX} WARNING — the onFailure callback failed (${reasonOf$2(callbackError)})`);
21616
+ }
20049
21617
  });
20050
21618
  },
20051
21619
  dispose() {
@@ -20190,7 +21758,7 @@ function tokenize(rawInput) {
20190
21758
  return tokens;
20191
21759
  }
20192
21760
  function metadataDroppedLine(dropped) {
20193
- 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);
20194
21762
  }
20195
21763
  /** Failures are rendered in full up to {@link FAILURES_SHOWN}; the rest is a count, never silence. */
20196
21764
  const FAILURES_SHOWN = 5;
@@ -20682,6 +22250,26 @@ function degradedLegNote(value) {
20682
22250
  const result = value?.result;
20683
22251
  return result !== null && typeof result === "object" && result.degraded === true ? DEGRADED_LEG_NOTE : void 0;
20684
22252
  }
22253
+ /**
22254
+ * The paging sentence of a PURE TIME window (`mode:"time"`, RECALL_INTERFACE §2.4), as one line the
22255
+ * model can read without doing arithmetic: 「共 N 条,已显示 M 条,还有 K 条未显示」.
22256
+ *
22257
+ * The fields it spells out (`total` / `hits.length` / `remaining`) are already in the JSON block; the
22258
+ * sentence exists because a page of 50 out of a 286-row day otherwise looks like the whole day unless
22259
+ * the reader stops to compare four numbers. Emitted ONLY when something is left unseen — a window that
22260
+ * fits on its page needs no note, and `remaining` is 0 exactly then.
22261
+ *
22262
+ * Same rules as {@link degradedLegNote}: a SECOND block, appended after the parseable JSON one, and
22263
+ * absent whenever the payload is not a time result (including older hosts, which send no `remaining`).
22264
+ */
22265
+ function timePageNote(value) {
22266
+ const result = value?.result;
22267
+ if (result === null || typeof result !== "object" || result.mode !== "time") return void 0;
22268
+ const { total, remaining, hits } = result;
22269
+ if (typeof total !== "number" || typeof remaining !== "number" || remaining <= 0) return void 0;
22270
+ if (!Array.isArray(hits)) return void 0;
22271
+ return `时间窗口共 ${String(total)} 条,已显示 ${String(hits.length)} 条,还有 ${String(remaining)} 条未显示。`;
22272
+ }
20685
22273
  /** The tool output spec registered on every mem tool. */
20686
22274
  const OUTPUT = {
20687
22275
  schema: {
@@ -20693,10 +22281,15 @@ const OUTPUT = {
20693
22281
  type: "text",
20694
22282
  text: JSON.stringify(toWellFormedDeep(value), null, 2)
20695
22283
  }];
20696
- const note = degradedLegNote(value);
20697
- if (note !== void 0) blocks.push({
22284
+ const degraded = degradedLegNote(value);
22285
+ if (degraded !== void 0) blocks.push({
22286
+ type: "text",
22287
+ text: degraded
22288
+ });
22289
+ const paging = timePageNote(value);
22290
+ if (paging !== void 0) blocks.push({
20698
22291
  type: "text",
20699
- text: note
22292
+ text: paging
20700
22293
  });
20701
22294
  return blocks;
20702
22295
  }
@@ -20954,6 +22547,22 @@ function hostLogger(ctx) {
20954
22547
  };
20955
22548
  }
20956
22549
  /**
22550
+ * The host's agent registry (`ctx.agents`), read defensively.
22551
+ *
22552
+ * It is the one place this plugin learns whether a session is a top-level (owner) agent or a
22553
+ * worker/subagent, and that decision only decides whether a session NOTICE is delivered. The read is
22554
+ * deliberately structural ({@link isOwnerSession} takes `unknown`): a host may not load the agent
22555
+ * registry at all, and `agent/pre-step` itself is emitted by the agent loop, so an absent registry is
22556
+ * "cannot tell" — never a reason to throw on the prompt path.
22557
+ */
22558
+ function agentRegistry(ctx) {
22559
+ try {
22560
+ return ctx.agents;
22561
+ } catch {
22562
+ return;
22563
+ }
22564
+ }
22565
+ /**
20957
22566
  * Declare the expensive resources through the family framework, warm the tokenizer and the embedding
20958
22567
  * model, and continue initialisation as each item settles.
20959
22568
  *
@@ -20970,12 +22579,37 @@ function hostLogger(ctx) {
20970
22579
  * — `managedModelSpec` decides): the framework's root would then collect a copy nothing reads. In
20971
22580
  * that case, and whenever no item will warm the model, the warm is started here directly.
20972
22581
  *
22582
+ * **Optional dependency repair.** `mem:semantic-deps` (shared `runtime-deps` store) is declared only
22583
+ * when `semantic.self_heal.enabled` is on (`managedSemanticDepsSpec` decides). When it is present, the
22584
+ * semantic warm waits for BOTH items: the shared root has to exist before the backend chooses it, and
22585
+ * the model files have to be on disk before the pipeline opens them. On success the handle directory
22586
+ * is handed to the backend (`useSemanticModuleDir`) so the import comes from the verified shared root;
22587
+ * on failure the backend keeps its own optional-dependency resolution.
22588
+ *
22589
+ * SESSION NOTICES are NOT carried by these handles: they are the `audit` entries of the plugin's own
22590
+ * `res/runtime-deps.yaml`, registered on one registry (`env.notices()`) whose predicates read the
22591
+ * startup dependency audit's cached state (`deps_audit.ts`). That is why a repair that is still
22592
+ * installing cannot be reported as a failure, and why an entry about a PACKAGE (rather than an item)
22593
+ * can be delivered at all.
22594
+ *
22595
+ * **NATIVE optional dependency repair.** `mem:native-deps` (a SEPARATE `runtime-deps` item: different
22596
+ * package set ⇒ different content-addressed root) repairs `nodejieba` and `hnswlib-node` when
22597
+ * `semantic.native_self_heal.enabled` is on. When it is present the TOKENIZER warm is deferred to its
22598
+ * settlement: the dictionary parse (~1 s, synchronous) has to happen once, from the root the process
22599
+ * will actually use, not from a tree that is about to be superseded. On success both module roots are
22600
+ * handed to their loaders (`useTokenizerModuleDir`, `MemoryStore.useNativeModuleDir`).
22601
+ *
20973
22602
  * Outcomes handled below:
20974
22603
  *
20975
22604
  * - `mem:model` settles (present/installed) → warm the semantic path from the managed root, now
20976
22605
  * that the files are on disk. That ordering is the whole point: the runtime's constructor warm is
20977
22606
  * deferred while the framework is up (`deferWarm`), so nothing can race the install, and the
20978
22607
  * explicit warm here is what starts the model.
22608
+ * - `mem:semantic-deps` settles (present/installed) → point the backend at the shared root, then
22609
+ * warm. failed/skipped → keep the optional path and still warm. Either way the package's audit
22610
+ * entry is refreshed, because the notice's other half is "is it loadable NOW".
22611
+ * - `mem:native-deps` settles → hand both module roots over, refresh the two native audit entries,
22612
+ * then warm the tokenizer (or retry it, when an earlier lazy write already failed).
20979
22613
  * - `mem:model` failed/skipped → one WARNING, then STILL warm: with `semantic.auto_download` on the
20980
22614
  * runtime takes its own download path, and with it off the semantic leg degrades to FTS+entity.
20981
22615
  * Either way the mount is untouched.
@@ -20989,20 +22623,65 @@ function hostLogger(ctx) {
20989
22623
  function startManagedResources(env, rt, logger, waitFor) {
20990
22624
  const { semantic, tools } = rt.config.common;
20991
22625
  const model = managedModelSpec(semantic, env.roots.models);
22626
+ const loaded = env.depsResource();
22627
+ const spec = loaded.ok ? loaded.resource : void 0;
22628
+ const depsSpec = spec === void 0 ? void 0 : managedSemanticDepsSpec(semantic, spec.semantic, env.home);
22629
+ const semanticDeps = depsSpec === void 0 ? void 0 : { spec: depsSpec };
22630
+ const nativeSpec = spec === void 0 ? void 0 : managedNativeDepsSpec(semantic, spec.native, env.home);
22631
+ const nativeDeps = nativeSpec === void 0 ? void 0 : { spec: nativeSpec };
20992
22632
  const plan = {
20993
22633
  archiveMirrors: tools.mirror,
20994
22634
  modelAutoDownload: semantic.auto_download,
20995
- model
22635
+ model,
22636
+ semanticDeps,
22637
+ nativeDeps,
22638
+ dependencyAudit: managedDependencyAudit(spec?.audit),
22639
+ vectorCount: () => rt.memory.liveVectorCount()
22640
+ };
22641
+ const waitsForNative = nativeDeps !== void 0 && supportsRuntimeDeps(env.kit);
22642
+ let tokenizerWarmStarted = false;
22643
+ const warmTokenizer = () => {
22644
+ if (tokenizerWarmStarted) return;
22645
+ tokenizerWarmStarted = true;
22646
+ warmTokenizerAsync(rt, { ...waitFor === void 0 ? {} : { waitFor } });
22647
+ };
22648
+ if (!waitsForNative) warmTokenizer();
22649
+ const pending = /* @__PURE__ */ new Set();
22650
+ if (model !== void 0) pending.add(MODEL_ITEM);
22651
+ if (semanticDeps !== void 0 && supportsRuntimeDeps(env.kit)) pending.add(SEMANTIC_DEPS_ITEM);
22652
+ const warmOnce = () => {
22653
+ if (pending.size === 0) warmSemanticAsync(rt);
20996
22654
  };
20997
- warmTokenizerAsync(rt, { ...waitFor === void 0 ? {} : { waitFor } });
20998
- if (model === void 0 && semantic.backend === "local_bge") warmSemanticAsync(rt);
20999
- env.provisionResources(plan, (entry) => {
21000
- if (entry.id === "mem:model") warmSemanticAsync(rt);
22655
+ if (pending.size === 0 && semantic.backend === "local_bge") warmSemanticAsync(rt);
22656
+ env.provisionResources(plan, (entry, handleDir) => {
22657
+ if (entry.id === "mem:model" || entry.id === "mem:semantic-deps") {
22658
+ if (entry.id === "mem:semantic-deps" && handleDir !== void 0) {
22659
+ rt.memory.useSemanticModuleDir(handleDir);
22660
+ logger.info(`envinit: semantic deps root ${handleDir}`);
22661
+ }
22662
+ pending.delete(entry.id);
22663
+ warmOnce();
22664
+ }
22665
+ if (entry.id === "mem:native-deps") {
22666
+ if (handleDir !== void 0) {
22667
+ useTokenizerModuleDir(handleDir);
22668
+ rt.memory.useNativeModuleDir(handleDir);
22669
+ logger.info(`envinit: native deps root ${handleDir}`);
22670
+ }
22671
+ warmTokenizer();
22672
+ }
21001
22673
  if (entry.action === "failed" || entry.action === "skipped") {
21002
22674
  logger.warn(`envinit: ${entry.id} is ${entry.action} (${String(entry.code)}${entry.reason === void 0 ? "" : ` — ${entry.reason}`})`);
22675
+ if (entry.id === "mem:semantic-deps") logger.warn("envinit: semantic self-heal did not complete — the runtime keeps its own optional-dependency resolution, and retrieval degrades to FTS+entity if that is absent too");
22676
+ if (entry.id === "mem:native-deps") logger.warn("envinit: native self-heal did not complete — the runtime keeps its own optional-dependency resolution; entity/triple extraction falls back to the regex path and the vector store stays brute-force");
21003
22677
  return;
21004
22678
  }
21005
22679
  logger.info(`envinit: ${entry.id} ${entry.action} (${entry.source}${entry.version === void 0 ? "" : ` ${entry.version}`})`);
22680
+ }, (error) => {
22681
+ logger.warn(`envinit: dispatching the background resources failed (${describeError$1(error)}); warming from the runtime's own paths`);
22682
+ pending.clear();
22683
+ warmOnce();
22684
+ warmTokenizer();
21006
22685
  });
21007
22686
  }
21008
22687
  async function apply(ctx, config) {
@@ -21130,7 +22809,7 @@ async function apply(ctx, config) {
21130
22809
  }
21131
22810
  for (const warning of promptTextWarnings(promptSections)) logger.warn(`prompt text: ${warning}`);
21132
22811
  logger.info(`prompt files: ${promptDir} (${loadedPrompts.map((entry) => `${entry.file}:${entry.source}`).join(", ")})`);
21133
- ctx.on("agent/pre-step", async ({ signal }, next) => {
22812
+ ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
21134
22813
  const decision = await next();
21135
22814
  if (decision.kind === "reject" || signal.aborted) return decision;
21136
22815
  const text = freshUserText(decision.messages);
@@ -21141,18 +22820,22 @@ async function apply(ctx, config) {
21141
22820
  } catch {
21142
22821
  hit = false;
21143
22822
  }
21144
- if (!hit) return decision;
22823
+ const sessionId = String(agent.id);
22824
+ const owner = isOwnerSession(agentRegistry(ctx), sessionId);
22825
+ const notices = noticeLines(env?.notices(), sessionId, owner);
22826
+ const injection = stepInjection(hit, notices);
22827
+ if (injection === "") return decision;
21145
22828
  return {
21146
22829
  ...decision,
21147
22830
  messages: [...decision.messages, createUserMessage({
21148
22831
  content: [{
21149
22832
  type: "text",
21150
- text: RELEVANCE_HINT
22833
+ text: injection
21151
22834
  }],
21152
22835
  source: {
21153
22836
  kind: "mem-hint",
21154
22837
  form: "notice",
21155
- summary: RELEVANCE_HINT
22838
+ summary: injection
21156
22839
  }
21157
22840
  })]
21158
22841
  };
@@ -21179,6 +22862,7 @@ async function apply(ctx, config) {
21179
22862
  log: logger,
21180
22863
  compat
21181
22864
  });
22865
+ env?.startDependencyAudit();
21182
22866
  ctx.effect(() => async () => {
21183
22867
  logger.info("plugin unmount: disposing runtime (closing databases)");
21184
22868
  await rt.shutdown();