@remnic/core 9.35.1 → 9.35.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/dist/access-admin-ops-surface.d.ts +5 -5
  2. package/dist/access-admin-ops-surface.js +19 -18
  3. package/dist/access-authorization-probe.d.ts +5 -5
  4. package/dist/access-authorization-probe.js +20 -19
  5. package/dist/access-boundary.d.ts +5 -5
  6. package/dist/access-boundary.js +19 -18
  7. package/dist/access-cli.js +43 -42
  8. package/dist/access-cli.js.map +1 -1
  9. package/dist/access-extraction-force-flush.d.ts +5 -5
  10. package/dist/access-extraction-force-flush.js +19 -18
  11. package/dist/access-http-lcm-compaction.d.ts +5 -5
  12. package/dist/access-http-lifecycle-flush.d.ts +5 -5
  13. package/dist/access-http.d.ts +5 -5
  14. package/dist/access-http.js +24 -23
  15. package/dist/access-identity-continuity-surface.d.ts +4 -4
  16. package/dist/access-identity-continuity-surface.js +19 -18
  17. package/dist/access-lcm-surface.d.ts +5 -5
  18. package/dist/access-lcm-surface.js +19 -18
  19. package/dist/access-mcp.d.ts +5 -5
  20. package/dist/access-mcp.js +22 -21
  21. package/dist/access-namespace-preflight.js +19 -18
  22. package/dist/access-observe-write-surface.d.ts +5 -5
  23. package/dist/access-observe-write-surface.js +19 -18
  24. package/dist/access-operations-batch.js +20 -19
  25. package/dist/access-operations.d.ts +8 -8
  26. package/dist/access-operations.js +21 -20
  27. package/dist/access-recall-concurrency.d.ts +5 -5
  28. package/dist/access-recall-concurrency.js +19 -18
  29. package/dist/access-recall-response.d.ts +5 -5
  30. package/dist/access-recall-response.js +19 -18
  31. package/dist/access-recall-surface.d.ts +5 -5
  32. package/dist/access-recall-surface.js +19 -18
  33. package/dist/access-schema.d.ts +88 -88
  34. package/dist/{access-service-D9zIBlpp.d.ts → access-service-C2WYT1pO.d.ts} +3 -3
  35. package/dist/access-service-helpers.d.ts +5 -5
  36. package/dist/access-service.d.ts +5 -5
  37. package/dist/access-service.js +19 -18
  38. package/dist/access-surface-catalog.d.ts +5 -5
  39. package/dist/access-wearables-meetings-surface.d.ts +2 -2
  40. package/dist/bootstrap.d.ts +4 -4
  41. package/dist/briefing.d.ts +1 -1
  42. package/dist/briefing.js +10 -9
  43. package/dist/buffer.d.ts +1 -1
  44. package/dist/{capsule-merge-2H2IUTBT.js → capsule-merge-IDWJPCWK.js} +77 -8
  45. package/dist/capsule-merge-IDWJPCWK.js.map +1 -0
  46. package/dist/causal-consolidation.js +11 -10
  47. package/dist/causal-consolidation.js.map +1 -1
  48. package/dist/{chunk-65ZPVYTA.js → chunk-3CE77VAP.js} +55 -5
  49. package/dist/chunk-3CE77VAP.js.map +1 -0
  50. package/dist/chunk-4J7G5SY4.js +338 -0
  51. package/dist/chunk-4J7G5SY4.js.map +1 -0
  52. package/dist/{chunk-EO7X2LDK.js → chunk-4TAD3B56.js} +2 -2
  53. package/dist/{chunk-QHYB4MJV.js → chunk-5ZGHUWTT.js} +2 -2
  54. package/dist/{chunk-TCIWHK3N.js → chunk-6D5D5A4M.js} +5 -5
  55. package/dist/{chunk-BIXWNHIL.js → chunk-6TF4M2LB.js} +5 -5
  56. package/dist/{chunk-2NUC473W.js → chunk-AKELMJO2.js} +220 -48
  57. package/dist/{chunk-2NUC473W.js.map → chunk-AKELMJO2.js.map} +1 -1
  58. package/dist/{chunk-FBUE6PD3.js → chunk-BEQOZOYZ.js} +21 -4
  59. package/dist/chunk-BEQOZOYZ.js.map +1 -0
  60. package/dist/{chunk-SA3WJJF2.js → chunk-BGLDKAYY.js} +2 -2
  61. package/dist/{chunk-OPRP6Q4Z.js → chunk-DKKZ6SO2.js} +3 -3
  62. package/dist/{chunk-6TAUHL33.js → chunk-DLVPUX7U.js} +10 -2
  63. package/dist/chunk-DLVPUX7U.js.map +1 -0
  64. package/dist/{chunk-QNWXCJ76.js → chunk-ENTCVTQR.js} +18 -1
  65. package/dist/{chunk-QNWXCJ76.js.map → chunk-ENTCVTQR.js.map} +1 -1
  66. package/dist/{chunk-AEPYA55F.js → chunk-FE7MUX7M.js} +2 -2
  67. package/dist/{chunk-NQUF7XKR.js → chunk-FT4RF53O.js} +2 -2
  68. package/dist/{chunk-GHR7WHZX.js → chunk-GORHSN5C.js} +2 -2
  69. package/dist/{chunk-7TEZUUGM.js → chunk-HUS2XN3T.js} +2 -2
  70. package/dist/{chunk-XWDZQIYU.js → chunk-IJAQCBGV.js} +2 -2
  71. package/dist/{chunk-TJLLYV2X.js → chunk-IMEXE4XS.js} +7 -7
  72. package/dist/{chunk-ZCPP5OW6.js → chunk-JQSBRLJ6.js} +31 -27
  73. package/dist/chunk-JQSBRLJ6.js.map +1 -0
  74. package/dist/{chunk-IZXTZ7BF.js → chunk-JYLN6KHH.js} +6 -6
  75. package/dist/{chunk-HEVWWPTL.js → chunk-LH3KXIBJ.js} +17 -17
  76. package/dist/{chunk-EH5U2YCX.js → chunk-M3DBB2ND.js} +2 -2
  77. package/dist/{chunk-XABS455Y.js → chunk-NBAEGFXT.js} +5 -5
  78. package/dist/{chunk-DP7ZV2II.js → chunk-OA6PIPDE.js} +2 -2
  79. package/dist/{chunk-T4HH5Q3U.js → chunk-OJRLTZQM.js} +3 -3
  80. package/dist/{chunk-7ZHGPX56.js → chunk-PRP3M5Y3.js} +2 -2
  81. package/dist/{chunk-Y2ZD2RVG.js → chunk-PYTUBWAS.js} +2 -2
  82. package/dist/{chunk-4TGZCYHH.js → chunk-Q3CVY7LV.js} +1954 -1642
  83. package/dist/chunk-Q3CVY7LV.js.map +1 -0
  84. package/dist/{chunk-PH3ZWL3W.js → chunk-Q53RLEW7.js} +2 -2
  85. package/dist/{chunk-Y6OWJ3V5.js → chunk-RY2XZTFN.js} +2 -2
  86. package/dist/{chunk-2SHIWGY7.js → chunk-RY7CHVGE.js} +2 -2
  87. package/dist/{chunk-7VUAG236.js → chunk-T3FVAHM7.js} +4 -4
  88. package/dist/{chunk-2LTDWSR6.js → chunk-TSH56VPU.js} +2 -2
  89. package/dist/{chunk-J6SLNQRO.js → chunk-UBWOCQ3I.js} +6 -2
  90. package/dist/chunk-UBWOCQ3I.js.map +1 -0
  91. package/dist/{chunk-5FHE4JI7.js → chunk-VEUVON5I.js} +2 -2
  92. package/dist/{chunk-KEWUNL7P.js → chunk-WET4M3R6.js} +2 -2
  93. package/dist/chunk-WKDFJXW5.js +158 -0
  94. package/dist/chunk-WKDFJXW5.js.map +1 -0
  95. package/dist/{chunk-SLPLPGJQ.js → chunk-WL2S2UBB.js} +5 -5
  96. package/dist/{chunk-AHZYNPLX.js → chunk-YVMVVE4B.js} +2 -2
  97. package/dist/{chunk-5HTAJCTO.js → chunk-ZB3YODID.js} +15 -15
  98. package/dist/{cli-mFX-QQvx.d.ts → cli-jkmzdT8P.d.ts} +3 -3
  99. package/dist/cli.d.ts +6 -6
  100. package/dist/cli.js +34 -33
  101. package/dist/compounding/engine.d.ts +1 -1
  102. package/dist/compounding/engine.js +10 -9
  103. package/dist/connectors/codex-materialize-runner.js +10 -9
  104. package/dist/connectors/index.js +10 -9
  105. package/dist/consolidation-provenance-check.d.ts +1 -1
  106. package/dist/consolidation-provenance-check.js +8 -2
  107. package/dist/consolidation-undo.d.ts +1 -1
  108. package/dist/consolidation-undo.js +10 -1
  109. package/dist/consolidation-undo.js.map +1 -1
  110. package/dist/contradiction/index.d.ts +1 -1
  111. package/dist/corpus-watermark.js +12 -11
  112. package/dist/entity-retrieval.d.ts +1 -1
  113. package/dist/entity-retrieval.js +10 -9
  114. package/dist/explicit-capture.d.ts +4 -4
  115. package/dist/explicit-capture.js +6 -3
  116. package/dist/extraction.js +13 -12
  117. package/dist/{forget-4UY2EOKH.js → forget-DNAJ66ZD.js} +2 -2
  118. package/dist/index.d.ts +398 -398
  119. package/dist/index.js +43 -42
  120. package/dist/local-llm-helpers.d.ts +63 -1
  121. package/dist/local-llm-helpers.js +10 -1
  122. package/dist/local-llm.d.ts +4 -1
  123. package/dist/local-llm.js +2 -2
  124. package/dist/maintenance/memory-governance.js +10 -9
  125. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
  126. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -9
  127. package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
  128. package/dist/maintenance/rebuild-memory-projection.js +11 -10
  129. package/dist/{maintenance-70Wq118b.d.ts → maintenance-Ha9Dg93M.d.ts} +1 -1
  130. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  131. package/dist/memory-worth-outcomes.d.ts +1 -1
  132. package/dist/namespaces/migrate.d.ts +1 -1
  133. package/dist/namespaces/migrate.js +11 -10
  134. package/dist/namespaces/storage.d.ts +1 -1
  135. package/dist/namespaces/storage.js +10 -9
  136. package/dist/offline-sync-impression-drain.js +3 -3
  137. package/dist/operator-doctor-corpus.js +13 -12
  138. package/dist/operator-toolkit.d.ts +1 -1
  139. package/dist/operator-toolkit.js +17 -16
  140. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  141. package/dist/orchestration/maintenance.d.ts +2 -2
  142. package/dist/orchestration/maintenance.js +15 -14
  143. package/dist/{orchestrator-BH8LT-NX.d.ts → orchestrator-DV_u9bfx.d.ts} +3 -3
  144. package/dist/orchestrator.d.ts +4 -4
  145. package/dist/orchestrator.js +43 -42
  146. package/dist/page-versioning.js +7 -1
  147. package/dist/schemas.d.ts +76 -76
  148. package/dist/semantic-consolidation.js +11 -10
  149. package/dist/semantic-rule-promotion.js +10 -9
  150. package/dist/semantic-rule-verifier.js +10 -9
  151. package/dist/{service-CrxXHz5T.d.ts → service-JxXXY-Eh.d.ts} +1 -1
  152. package/dist/shared-context/manager.d.ts +8 -8
  153. package/dist/source-agent-qualifier.js +10 -9
  154. package/dist/{storage-CyWa1Jm6.d.ts → storage-BbH4HDUS.d.ts} +30 -26
  155. package/dist/storage.d.ts +1 -1
  156. package/dist/storage.js +9 -8
  157. package/dist/summarizer.js +3 -3
  158. package/dist/temporal-supersession.d.ts +1 -1
  159. package/dist/tier-migration.d.ts +1 -1
  160. package/dist/transfer/capsule-import.js +8 -2
  161. package/dist/transfer/types.d.ts +66 -66
  162. package/dist/verified-recall.js +10 -9
  163. package/package.json +2 -2
  164. package/src/binary-lifecycle/pipeline.ts +52 -1
  165. package/src/consolidation-undo.ts +4 -0
  166. package/src/curation/index.ts +41 -5
  167. package/src/lifecycle/tombstones.ts +40 -0
  168. package/src/local-llm-helpers.ts +171 -0
  169. package/src/local-llm.ts +34 -30
  170. package/src/maintenance/memory-governance.ts +4 -0
  171. package/src/page-versioning.ts +16 -1
  172. package/src/review/index.ts +97 -13
  173. package/src/spaces/index.ts +127 -6
  174. package/src/storage/citation-hash-source.ts +97 -0
  175. package/src/storage/entity-canonical-id-lock.ts +33 -0
  176. package/src/storage/entity-canonical-id-migration-adapter.ts +8 -4
  177. package/src/storage/entity-canonical-id-migration.ts +129 -77
  178. package/src/storage/entity-canonical-id-references.ts +557 -0
  179. package/src/storage/entity-ref-repair.ts +171 -0
  180. package/src/storage/entity-store.ts +163 -2
  181. package/src/storage/tombstone-blocked-capture-sync.ts +18 -0
  182. package/src/storage.ts +363 -396
  183. package/src/transfer/capsule-import.ts +91 -3
  184. package/src/transfer/capsule-merge.ts +128 -10
  185. package/dist/capsule-merge-2H2IUTBT.js.map +0 -1
  186. package/dist/chunk-4RNZMRAV.js +0 -60
  187. package/dist/chunk-4RNZMRAV.js.map +0 -1
  188. package/dist/chunk-4TGZCYHH.js.map +0 -1
  189. package/dist/chunk-65ZPVYTA.js.map +0 -1
  190. package/dist/chunk-6TAUHL33.js.map +0 -1
  191. package/dist/chunk-FBUE6PD3.js.map +0 -1
  192. package/dist/chunk-J6SLNQRO.js.map +0 -1
  193. package/dist/chunk-ZCPP5OW6.js.map +0 -1
  194. /package/dist/{chunk-EO7X2LDK.js.map → chunk-4TAD3B56.js.map} +0 -0
  195. /package/dist/{chunk-QHYB4MJV.js.map → chunk-5ZGHUWTT.js.map} +0 -0
  196. /package/dist/{chunk-TCIWHK3N.js.map → chunk-6D5D5A4M.js.map} +0 -0
  197. /package/dist/{chunk-BIXWNHIL.js.map → chunk-6TF4M2LB.js.map} +0 -0
  198. /package/dist/{chunk-SA3WJJF2.js.map → chunk-BGLDKAYY.js.map} +0 -0
  199. /package/dist/{chunk-OPRP6Q4Z.js.map → chunk-DKKZ6SO2.js.map} +0 -0
  200. /package/dist/{chunk-AEPYA55F.js.map → chunk-FE7MUX7M.js.map} +0 -0
  201. /package/dist/{chunk-NQUF7XKR.js.map → chunk-FT4RF53O.js.map} +0 -0
  202. /package/dist/{chunk-GHR7WHZX.js.map → chunk-GORHSN5C.js.map} +0 -0
  203. /package/dist/{chunk-7TEZUUGM.js.map → chunk-HUS2XN3T.js.map} +0 -0
  204. /package/dist/{chunk-XWDZQIYU.js.map → chunk-IJAQCBGV.js.map} +0 -0
  205. /package/dist/{chunk-TJLLYV2X.js.map → chunk-IMEXE4XS.js.map} +0 -0
  206. /package/dist/{chunk-IZXTZ7BF.js.map → chunk-JYLN6KHH.js.map} +0 -0
  207. /package/dist/{chunk-HEVWWPTL.js.map → chunk-LH3KXIBJ.js.map} +0 -0
  208. /package/dist/{chunk-EH5U2YCX.js.map → chunk-M3DBB2ND.js.map} +0 -0
  209. /package/dist/{chunk-XABS455Y.js.map → chunk-NBAEGFXT.js.map} +0 -0
  210. /package/dist/{chunk-DP7ZV2II.js.map → chunk-OA6PIPDE.js.map} +0 -0
  211. /package/dist/{chunk-T4HH5Q3U.js.map → chunk-OJRLTZQM.js.map} +0 -0
  212. /package/dist/{chunk-7ZHGPX56.js.map → chunk-PRP3M5Y3.js.map} +0 -0
  213. /package/dist/{chunk-Y2ZD2RVG.js.map → chunk-PYTUBWAS.js.map} +0 -0
  214. /package/dist/{chunk-PH3ZWL3W.js.map → chunk-Q53RLEW7.js.map} +0 -0
  215. /package/dist/{chunk-Y6OWJ3V5.js.map → chunk-RY2XZTFN.js.map} +0 -0
  216. /package/dist/{chunk-2SHIWGY7.js.map → chunk-RY7CHVGE.js.map} +0 -0
  217. /package/dist/{chunk-7VUAG236.js.map → chunk-T3FVAHM7.js.map} +0 -0
  218. /package/dist/{chunk-2LTDWSR6.js.map → chunk-TSH56VPU.js.map} +0 -0
  219. /package/dist/{chunk-5FHE4JI7.js.map → chunk-VEUVON5I.js.map} +0 -0
  220. /package/dist/{chunk-KEWUNL7P.js.map → chunk-WET4M3R6.js.map} +0 -0
  221. /package/dist/{chunk-SLPLPGJQ.js.map → chunk-WL2S2UBB.js.map} +0 -0
  222. /package/dist/{chunk-AHZYNPLX.js.map → chunk-YVMVVE4B.js.map} +0 -0
  223. /package/dist/{chunk-5HTAJCTO.js.map → chunk-ZB3YODID.js.map} +0 -0
  224. /package/dist/{forget-4UY2EOKH.js.map → forget-DNAJ66ZD.js.map} +0 -0
@@ -835,6 +835,46 @@ export function collectRetiredMemoriesForRebuild(
835
835
  return retired;
836
836
  }
837
837
 
838
+ /**
839
+ * One-shot write-time resurrection gate (issue #1579 / #2213). Looks up the
840
+ * fact's identity in the tombstone store and, on a match, downgrades the
841
+ * frontmatter to `pending_review` + `blockedBy` IN PLACE. ADD-ONLY: an
842
+ * already-blocked or terminal-status record is left untouched. Pure over its
843
+ * inputs (no I/O beyond the injected store) so both `writeMemory`'s gate and
844
+ * the chunk-repair re-gate share one lookup + apply.
845
+ */
846
+ export function applyTombstoneResurrectionGate(
847
+ store: Pick<TombstoneStore, "lookup">,
848
+ fm: {
849
+ id: string;
850
+ status?: string;
851
+ blockedBy?: string;
852
+ tombstoneBlockTier?: TombstoneMatchTier;
853
+ entityRef?: string;
854
+ contentHash?: string;
855
+ },
856
+ query: {
857
+ normalizedText: string;
858
+ supersessionKeys: string[];
859
+ namespace: string;
860
+ },
861
+ ): TombstoneMatch | null {
862
+ if (fm.status === "pending_review" && fm.blockedBy) return null;
863
+ if (!(fm.status === undefined || fm.status === "active" || fm.status === "pending_review")) return null;
864
+ const match = store.lookup({
865
+ contentHash: fm.contentHash,
866
+ normalizedText: query.normalizedText,
867
+ ...(fm.entityRef ? { entityRef: fm.entityRef } : {}),
868
+ ...(query.supersessionKeys.length > 0 ? { supersessionKeys: query.supersessionKeys } : {}),
869
+ namespace: query.namespace,
870
+ });
871
+ if (!match) return null;
872
+ fm.status = "pending_review";
873
+ fm.blockedBy = match.tombstoneId;
874
+ fm.tombstoneBlockTier = match.matchedTier;
875
+ return match;
876
+ }
877
+
838
878
  /**
839
879
  * Build the live-emission tombstone inputs for a single retired FACT — one
840
880
  * input per derived supersession key (or a single keyless record when no
@@ -1,3 +1,4 @@
1
+ import { log } from "./logger.js";
1
2
  export function isAbortError(err: unknown): boolean {
2
3
  if (!err || typeof err !== "object") return false;
3
4
  const maybe = err as { name?: string; message?: string };
@@ -56,3 +57,173 @@ export function extractNonRecoverableBackendReasonFromErrorText(errorText: strin
56
57
  return null;
57
58
  }
58
59
  }
60
+
61
+ /**
62
+ * Flatten a fetch rejection into one line, including the `cause` undici hides
63
+ * the real reason behind (`fetch failed` alone says nothing).
64
+ */
65
+ export function describeFetchFailure(err: unknown): string {
66
+ const name = err instanceof Error ? err.name : "";
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ const cause: unknown = err instanceof Error ? (err as Error & { cause?: unknown }).cause : undefined;
69
+ const causeText = cause instanceof Error
70
+ ? ` (cause: ${cause.name}: ${cause.message}${
71
+ typeof (cause as Error & { code?: unknown }).code === "string"
72
+ ? ` [${String((cause as Error & { code?: unknown }).code)}]`
73
+ : ""
74
+ })`
75
+ : cause === undefined
76
+ ? ""
77
+ : ` (cause: ${String(cause)})`;
78
+ return `${name ? `${name}: ` : ""}${message}${causeText}`;
79
+ }
80
+
81
+ /**
82
+ * What to record when every availability probe failed.
83
+ *
84
+ * A probe that TIMED OUT is not evidence the backend is down: the budget is a
85
+ * fixed 2s and a busy event loop can burn it before the socket is scheduled,
86
+ * so the backend looks dead while it answers other callers in single-digit ms.
87
+ * Caching that verdict took extraction offline for a whole health-check
88
+ * interval at a time (issue #2210), so a timeout leaves availability UNKNOWN —
89
+ * this call fails, the next one re-probes instead of reading a stale false.
90
+ *
91
+ * A backend that ANSWERED and said no is a real verdict and stays cached, or
92
+ * the daemon re-probes a known-bad endpoint on every request.
93
+ */
94
+ export function resolveUnavailableVerdict(args: {
95
+ baseUrl: string;
96
+ sawAbortedProbe: boolean;
97
+ wasAvailable: boolean | null;
98
+ }): { cacheVerdict: boolean; warning: string | null } {
99
+ if (args.sawAbortedProbe) {
100
+ return {
101
+ cacheVerdict: false,
102
+ warning:
103
+ `local LLM availability probe timed out at ${args.baseUrl} (event loop busy?); treating availability as ` +
104
+ "unknown and re-probing on the next request rather than marking the backend down",
105
+ };
106
+ }
107
+ return {
108
+ cacheVerdict: true,
109
+ // Surfaced at warn, not debug: extraction stops silently when this flips,
110
+ // and the daemon emits no debug at all unless configured for it.
111
+ warning: args.wasAvailable !== false ? `local LLM became unavailable at ${args.baseUrl}` : null,
112
+ };
113
+ }
114
+
115
+ export interface ProbeFetchResult {
116
+ ok: boolean;
117
+ data: unknown;
118
+ status: number | null;
119
+ /** The probe hit its own budget rather than getting an answer. */
120
+ aborted?: boolean;
121
+ }
122
+
123
+ /**
124
+ * One health-probe request.
125
+ *
126
+ * Every failure is logged with its cause: a transport error and a 404 both
127
+ * returned `ok: false` with nothing recorded, so an unreachable backend was
128
+ * indistinguishable from a wrong path and an availability failure had no
129
+ * diagnosable reason at all (§22, issue #2210).
130
+ */
131
+ export async function probeFetch(
132
+ url: string,
133
+ options: { timeoutMs: number; headers: Record<string, string>; signal?: AbortSignal },
134
+ ): Promise<ProbeFetchResult> {
135
+ const controller = new AbortController();
136
+ const requestSignal = options.signal
137
+ ? AbortSignal.any([options.signal, controller.signal])
138
+ : controller.signal;
139
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
140
+ try {
141
+ const response = await fetch(url, { signal: requestSignal, headers: options.headers });
142
+ clearTimeout(timeout);
143
+ if (!response.ok) {
144
+ log.debug(`local LLM probe: ${url} returned HTTP ${response.status}`);
145
+ return { ok: false, data: null, status: response.status };
146
+ }
147
+ const contentType = response.headers.get("content-type");
148
+ const data = contentType?.includes("application/json") ? await response.json() : await response.text();
149
+ return { ok: true, data, status: response.status };
150
+ } catch (err) {
151
+ clearTimeout(timeout);
152
+ const aborted = isAbortError(err);
153
+ log.debug(
154
+ `local LLM probe: ${url} ${aborted ? `timed out after ${options.timeoutMs}ms` : "failed"}: ${describeFetchFailure(err)}`,
155
+ );
156
+ return { ok: false, data: null, status: null, aborted };
157
+ }
158
+ }
159
+
160
+ interface PendingProbe {
161
+ promise: Promise<boolean>;
162
+ controller: AbortController;
163
+ /** Callers still awaiting this sequence; at zero the sequence is aborted. */
164
+ waiters: number;
165
+ }
166
+
167
+ /**
168
+ * Collapses concurrent probe sequences onto one in-flight run.
169
+ *
170
+ * A timed-out availability probe deliberately caches nothing (issue #2210),
171
+ * which removed the false verdict that used to absorb concurrent callers:
172
+ * every queued request would then run its own full probe sequence, on a host
173
+ * already slow enough to blow the probe budget.
174
+ *
175
+ * Cancellation is refcounted rather than shared: one caller walking away must
176
+ * not abort a sequence the others are still waiting on, so the underlying task
177
+ * is aborted only once every waiter has gone.
178
+ */
179
+ export class SingleFlightProbe {
180
+ private pending: PendingProbe | null = null;
181
+
182
+ run(task: (signal: AbortSignal) => Promise<boolean>, signal?: AbortSignal): Promise<boolean> {
183
+ let active = this.pending;
184
+ if (!active) {
185
+ const controller = new AbortController();
186
+ const created: PendingProbe = { promise: Promise.resolve(false), controller, waiters: 0 };
187
+ created.promise = task(controller.signal).finally(() => {
188
+ if (this.pending === created) this.pending = null;
189
+ });
190
+ this.pending = created;
191
+ active = created;
192
+ }
193
+ const entry = active;
194
+ entry.waiters += 1;
195
+ let released = false;
196
+ const release = () => {
197
+ if (released) return;
198
+ released = true;
199
+ entry.waiters -= 1;
200
+ if (entry.waiters > 0) return;
201
+ // Detach BEFORE aborting. `pending` would otherwise still point at this
202
+ // doomed entry until the task's own `finally` runs a microtask later, and
203
+ // a caller arriving in that window would join an aborted sequence and
204
+ // read its `false` as a verdict.
205
+ if (this.pending === entry) this.pending = null;
206
+ entry.controller.abort();
207
+ };
208
+ if (!signal) return entry.promise.finally(release);
209
+ return new Promise<boolean>((resolve, reject) => {
210
+ const onAbort = () => {
211
+ release();
212
+ resolve(false);
213
+ };
214
+ signal.addEventListener("abort", onAbort, { once: true });
215
+ entry.promise.then(
216
+ (value) => {
217
+ signal.removeEventListener("abort", onAbort);
218
+ release();
219
+ resolve(value);
220
+ },
221
+ (err: unknown) => {
222
+ signal.removeEventListener("abort", onAbort);
223
+ release();
224
+ reject(err instanceof Error ? err : new Error(String(err)));
225
+ },
226
+ );
227
+ });
228
+ }
229
+ }
package/src/local-llm.ts CHANGED
@@ -12,8 +12,12 @@ import {
12
12
  extractNonRecoverableBackendReason,
13
13
  extractNonRecoverableBackendReasonFromErrorText,
14
14
  isAbortError,
15
+ probeFetch,
16
+ resolveUnavailableVerdict,
17
+ type ProbeFetchResult,
15
18
  normalizeBackendTripReason,
16
19
  waitForRetryBackoff,
20
+ SingleFlightProbe,
17
21
  } from "./local-llm-helpers.js";
18
22
 
19
23
  /** Trim trailing slash characters without backtracking regex. */
@@ -256,6 +260,7 @@ export class LocalLlmClient {
256
260
  private config: PluginConfig;
257
261
  private isAvailable: boolean | null = null;
258
262
  private lastHealthCheck: number = 0;
263
+ private readonly availabilityProbe = new SingleFlightProbe();
259
264
  private detectedType: LocalLlmType | null = null;
260
265
  private cachedModelInfo: LocalModelInfo | null = null;
261
266
  private cachedLmsContext: number | null = null;
@@ -371,39 +376,20 @@ export class LocalLlmClient {
371
376
 
372
377
 
373
378
  /**
374
- * Fetch with timeout for health checks
379
+ * Fetch with timeout for health checks. Body lives in the sibling helper so
380
+ * this file stays under its size ceiling (issue #1995).
375
381
  */
376
382
  private async fetchWithTimeout(
377
383
  url: string,
378
384
  timeoutMs: number = 2000,
379
385
  headers?: Record<string, string>,
380
386
  signal?: AbortSignal,
381
- ): Promise<{ ok: boolean; data: unknown; status: number | null }> {
382
- const controller = new AbortController();
383
- const requestSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
384
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
385
-
386
- try {
387
- const response = await fetch(url, {
388
- signal: requestSignal,
389
- headers: this.buildRequestHeaders({ Accept: "application/json", ...(headers ?? {}) }),
390
- });
391
- clearTimeout(timeout);
392
-
393
- if (!response.ok) {
394
- return { ok: false, data: null, status: response.status };
395
- }
396
-
397
- const contentType = response.headers.get("content-type");
398
- if (contentType?.includes("application/json")) {
399
- return { ok: true, data: await response.json(), status: response.status };
400
- } else {
401
- return { ok: true, data: await response.text(), status: response.status };
402
- }
403
- } catch (err) {
404
- clearTimeout(timeout);
405
- return { ok: false, data: null, status: null };
406
- }
387
+ ): Promise<ProbeFetchResult> {
388
+ return await probeFetch(url, {
389
+ timeoutMs,
390
+ headers: this.buildRequestHeaders({ Accept: "application/json", ...(headers ?? {}) }),
391
+ signal,
392
+ });
407
393
  }
408
394
 
409
395
  private async probeLmStudioNativeModels(
@@ -445,8 +431,11 @@ export class LocalLlmClient {
445
431
  if (this.isAvailable !== null && now - this.lastHealthCheck < LocalLlmClient.HEALTH_CHECK_INTERVAL_MS) {
446
432
  return this.isAvailable;
447
433
  }
434
+ return await this.availabilityProbe.run((probeSignal) => this.probeAvailability(probeSignal), signal);
435
+ }
448
436
 
449
- // Normalize URL - replace localhost with 127.0.0.1, remove trailing slashes.
437
+ private async probeAvailability(signal?: AbortSignal): Promise<boolean> {
438
+ const now = Date.now();
450
439
  // Probe server-native endpoints from the server root even when users configure
451
440
  // the OpenAI-compatible `/v1` base URL for chat completions.
452
441
  const configuredBaseUrl = trimTrailingSlashes(
@@ -454,6 +443,10 @@ export class LocalLlmClient {
454
443
  );
455
444
  const probeBaseUrl = stripTrailingV1Path(configuredBaseUrl);
456
445
  let sawUnauthorizedProbe = false;
446
+ // A probe that TIMED OUT says the event loop was busy, not that the backend
447
+ // is down (issue #2210). Tracked separately so a loaded daemon cannot cache
448
+ // itself into a blackout.
449
+ let sawAbortedProbe = false;
457
450
 
458
451
  // Try to detect which server type is running
459
452
  if (signal?.aborted) return false;
@@ -462,6 +455,7 @@ export class LocalLlmClient {
462
455
  log.debug(`checking ${serverConfig.type} at ${healthUrl}`);
463
456
 
464
457
  const result = await this.fetchWithTimeout(healthUrl, 2000, undefined, signal);
458
+ if (result.aborted) sawAbortedProbe = true;
465
459
  if (signal?.aborted) return false;
466
460
  if (result.ok && serverConfig.detectFn(result.data)) {
467
461
  if (serverConfig.type === "mlx") {
@@ -481,6 +475,7 @@ export class LocalLlmClient {
481
475
  if (serverConfig.type === "llamacpp") {
482
476
  let sawLlamaCppSignal = false;
483
477
  const propsProbe = await this.fetchWithTimeout(`${probeBaseUrl}/props`, 2000, undefined, signal);
478
+ if (propsProbe.aborted) sawAbortedProbe = true;
484
479
  if (signal?.aborted) return false;
485
480
  if (propsProbe.ok && isLlamaCppPropsResponse(propsProbe.data)) {
486
481
  sawLlamaCppSignal = true;
@@ -491,6 +486,7 @@ export class LocalLlmClient {
491
486
 
492
487
  const modelsUrl = `${probeBaseUrl}${serverConfig.modelsEndpoint}`;
493
488
  const modelsProbe = await this.fetchWithTimeout(modelsUrl, 2000, undefined, signal);
489
+ if (modelsProbe.aborted) sawAbortedProbe = true;
494
490
  if (signal?.aborted) return false;
495
491
  if (modelsProbe.ok && isLlamaCppModelsResponse(modelsProbe.data)) {
496
492
  sawLlamaCppSignal = true;
@@ -523,6 +519,7 @@ export class LocalLlmClient {
523
519
  try {
524
520
  const modelsUrl = `${probeBaseUrl}/v1/models`;
525
521
  const result = await this.fetchWithTimeout(modelsUrl, 2000, undefined, signal);
522
+ if (result.aborted) sawAbortedProbe = true;
526
523
  if (signal?.aborted) return false;
527
524
  if (result.ok) {
528
525
  this.isAvailable = true;
@@ -538,14 +535,21 @@ export class LocalLlmClient {
538
535
  // Fall through to unavailable
539
536
  }
540
537
 
541
- this.isAvailable = false;
538
+ const wasAvailable = this.isAvailable;
542
539
  this.detectedType = null;
543
- this.lastHealthCheck = now;
544
540
  if (sawUnauthorizedProbe) {
545
541
  log.warn(
546
542
  `local LLM availability probe was unauthorized at ${configuredBaseUrl}; verify localLlmApiKey and localLlmAuthHeader settings`,
547
543
  );
548
544
  }
545
+ const verdict = resolveUnavailableVerdict({
546
+ baseUrl: configuredBaseUrl,
547
+ sawAbortedProbe,
548
+ wasAvailable,
549
+ });
550
+ this.isAvailable = verdict.cacheVerdict ? false : null;
551
+ this.lastHealthCheck = verdict.cacheVerdict ? now : 0;
552
+ if (verdict.warning) log.warn(verdict.warning);
549
553
  log.debug("local LLM not available at", configuredBaseUrl);
550
554
  return false;
551
555
  }
@@ -6,6 +6,7 @@ import type { MemoryFile, MemoryStatus } from "../types.js";
6
6
  import { RECALL_FALLBACK_DIRS } from "../utils/category-dir.js";
7
7
  import { bumpMemoryCorpusVersionForDir } from "../memory-corpus-version.js";
8
8
  import { assertPathInsideRoot } from "../utils/path-containment.js";
9
+ import { requestEntityCanonicalIdReconcile } from "../storage/entity-canonical-id-references.js";
9
10
 
10
11
  export type MemoryGovernanceMode = "shadow" | "apply";
11
12
  export type MemoryGovernanceReasonCode =
@@ -1021,6 +1022,9 @@ export async function restoreMemoryGovernanceRun(
1021
1022
  // same-process or peer readAllMemories rescans mid-batch and never serves a
1022
1023
  // partially-restored corpus (Cursor Medium, #1902).
1023
1024
  bumpMemoryCorpusVersionForDir(options.memoryDir);
1025
+ // Restored bytes predate the current migration journal and can carry
1026
+ // legacy entity references (issue #2213) — request one reconcile pass.
1027
+ await requestEntityCanonicalIdReconcile(path.join(options.memoryDir, "state"));
1024
1028
  }
1025
1029
 
1026
1030
  return {
@@ -27,6 +27,10 @@ import {
27
27
  } from "node:fs/promises";
28
28
  import { ALL_CATEGORY_DIRS, RECALL_FALLBACK_DIRS } from "./utils/category-dir.js";
29
29
  import { bumpMemoryCorpusVersionForDir } from "./memory-corpus-version.js";
30
+ import {
31
+ pathMayCarryEntityRefs,
32
+ requestEntityCanonicalIdReconcile,
33
+ } from "./storage/entity-canonical-id-references.js";
30
34
 
31
35
  // ---------------------------------------------------------------------------
32
36
  // Public interfaces
@@ -342,9 +346,20 @@ export async function revertToVersion(
342
346
  // non-memory queue dirs (questions/). Use RECALL_FALLBACK_DIRS — a revert of a
343
347
  // questions/ page must NOT bump the recall corpus sentinel and force a rescan.
344
348
  const revertedTop = relPath(pagePath, resolvedMemoryDir).split(path.sep)[0];
345
- if ((RECALL_FALLBACK_DIRS as readonly string[]).includes(revertedTop)) {
349
+ const inRecallTier = (RECALL_FALLBACK_DIRS as readonly string[]).includes(revertedTop);
350
+ if (inRecallTier) {
346
351
  bumpMemoryCorpusVersionForDir(resolvedMemoryDir);
347
352
  }
353
+ // A pre-migration snapshot can reintroduce a legacy entityRef — or, under
354
+ // entities/, a legacy relationship target rewriteRelationshipTargets had
355
+ // migrated — that the target's completed journal already renamed (issue
356
+ // #2213); the revert keeps the snapshot bytes faithful, so request one
357
+ // bounded reconcile pass instead. pathMayCarryEntityRefs is the migration's
358
+ // own scan scope (hot recall + cold + archive + entities), which is wider
359
+ // than the corpus bump above on purpose.
360
+ if (pathMayCarryEntityRefs(resolvedMemoryDir, pagePath)) {
361
+ await requestEntityCanonicalIdReconcile(path.join(resolvedMemoryDir, "state"));
362
+ }
348
363
  log.debug(`page-versioning: reverted ${pagePath} to version ${versionId}`);
349
364
 
350
365
  return version;
@@ -9,6 +9,58 @@ import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { getCategoryDir, ALL_CATEGORY_DIRS } from "../utils/category-dir.js";
11
11
  import { bumpMemoryCorpusVersionForDir } from "../memory-corpus-version.js";
12
+ import {
13
+ HistoricalEntityCanonicalIdCache,
14
+ canonicalizeEntityRefFrontmatter,
15
+ repairContentAfterJournalMoveSync,
16
+ } from "../storage/entity-canonical-id-references.js";
17
+
18
+ // Write-boundary journal cache for review-queue promotions (issue #2213).
19
+ const historicalIdCache = new HistoricalEntityCanonicalIdCache();
20
+
21
+ /**
22
+ * In-place raw rewrite under the uniform write-boundary rule (issue #2213):
23
+ * canonicalize the effective entityRef at the write and REPAIR when the
24
+ * journal moved across it — these rewrites re-serialize whole records, so an
25
+ * unrelated approve/dismiss/flag must not write a legacy ref back out. On
26
+ * repair exhaustion the ORIGINAL bytes are restored before the retryable
27
+ * error propagates: performReview bumps the corpus sentinel only after the
28
+ * action returns, so a half-applied rewrite would be invisible to a warm
29
+ * cache while the caller is told the action failed.
30
+ */
31
+ function writeReviewFileCanonicalized(memoryDir: string, filePath: string, content: string): void {
32
+ const stateDir = path.join(memoryDir, "state");
33
+ const idsAtWrite = historicalIdCache.get(stateDir);
34
+ let originalBytes: string | null = null;
35
+ try {
36
+ originalBytes = fs.readFileSync(filePath, "utf8");
37
+ } catch {
38
+ originalBytes = null; // new file — rollback is removal
39
+ }
40
+ const written = canonicalizeEntityRefFrontmatter(content, idsAtWrite);
41
+ fs.writeFileSync(filePath, written, "utf8");
42
+ try {
43
+ repairContentAfterJournalMoveSync({
44
+ stateDir,
45
+ cache: historicalIdCache,
46
+ idsAtWrite,
47
+ rawContent: content,
48
+ lastWritten: written,
49
+ rewrite: (next) => fs.writeFileSync(filePath, next, "utf8"),
50
+ });
51
+ } catch (err) {
52
+ try {
53
+ if (originalBytes === null) {
54
+ fs.unlinkSync(filePath);
55
+ } else {
56
+ fs.writeFileSync(filePath, originalBytes, "utf8");
57
+ }
58
+ } catch {
59
+ // Best effort — the retryable repair error is the primary signal.
60
+ }
61
+ throw err;
62
+ }
63
+ }
12
64
 
13
65
  // ── Types ────────────────────────────────────────────────────────────────────
14
66
 
@@ -295,13 +347,16 @@ function approveItem(
295
347
  const fm = parseFrontmatter(content);
296
348
  if (!fm) return { itemId, action: "approve", message: "Could not parse frontmatter" };
297
349
 
298
- // Issue #1579 — when approving a tombstone-blocked memory, fire the
299
- // revocation hook so the content is re-allowed (append-only log: a
300
- // `kind: "revocation"` entry supersedes the tombstone at lookup). Clear
301
- // blockedBy/tombstoneBlockTier on the promoted memory so it is fully
302
- // active. Fire-and-forget: a hook failure never fails the approval.
350
+ // Issue #1579 — approving a tombstone-blocked memory fires the revocation
351
+ // hook so the content is re-allowed (append-only log: a `kind: "revocation"`
352
+ // entry supersedes the tombstone at lookup). Deferred until the approval's
353
+ // writes commit (Codex P2, round 19): revoking before the promote left a
354
+ // failed repair with the non-resurrection guard already destroyed, letting
355
+ // a later extraction resurrect the content. Fire-and-forget once invoked:
356
+ // a hook failure never fails the approval.
303
357
  const blockedBy = typeof fm.blockedBy === "string" ? fm.blockedBy : null;
304
- if (blockedBy && options.onApproveBlockedMemory) {
358
+ const fireBlockedRevocation = (): void => {
359
+ if (!blockedBy || !options.onApproveBlockedMemory) return;
305
360
  try {
306
361
  const maybe = options.onApproveBlockedMemory(blockedBy, itemId);
307
362
  if (maybe && typeof (maybe as Promise<void>).then === "function") {
@@ -310,7 +365,7 @@ function approveItem(
310
365
  } catch {
311
366
  /* fire-and-forget — never fail the approval (gotcha #13) */
312
367
  }
313
- }
368
+ };
314
369
 
315
370
  const updatedContent = updateFrontmatterFields(content, {
316
371
  confidence: "0.9",
@@ -324,7 +379,8 @@ function approveItem(
324
379
  });
325
380
 
326
381
  if (found.location === "category") {
327
- fs.writeFileSync(found.filePath, updatedContent, "utf8");
382
+ writeReviewFileCanonicalized(memoryDir, found.filePath, updatedContent);
383
+ fireBlockedRevocation();
328
384
  return {
329
385
  itemId,
330
386
  action: "approve",
@@ -342,10 +398,38 @@ function approveItem(
342
398
  const outputPath = path.join(targetDir, dateDir, path.basename(found.filePath));
343
399
 
344
400
  ensureSafeDirectory(rootReal, path.dirname(outputPath));
345
- const promotedPath = writeFileWithoutClobber(outputPath, updatedContent, itemId);
401
+ // The suggestions/review queue dirs sit OUTSIDE the migration's reference
402
+ // scan, so a queued pre-migration item can still carry a legacy entityRef
403
+ // (issue #2213): promote it canonicalized against the target journal, and
404
+ // REPAIR when the journal moved across the write.
405
+ const reviewStateDir = path.join(memoryDir, "state");
406
+ const idsAtWrite = historicalIdCache.get(reviewStateDir);
407
+ const promotedWritten = canonicalizeEntityRefFrontmatter(updatedContent, idsAtWrite);
408
+ const promotedPath = writeFileWithoutClobber(outputPath, promotedWritten, itemId);
409
+ try {
410
+ repairContentAfterJournalMoveSync({
411
+ stateDir: reviewStateDir,
412
+ cache: historicalIdCache,
413
+ idsAtWrite,
414
+ rawContent: updatedContent,
415
+ lastWritten: promotedWritten,
416
+ rewrite: (next) => fs.writeFileSync(promotedPath, next, "utf8"),
417
+ });
418
+ } catch (err) {
419
+ // Roll back so a retried approve does not clobber-fail against a partial
420
+ // promotion; the queue entry survives untouched (Bugbot).
421
+ try {
422
+ fs.unlinkSync(promotedPath);
423
+ } catch {
424
+ // Best effort — the retryable repair error below is the primary signal.
425
+ }
426
+ throw err;
427
+ }
346
428
 
347
- // Remove from review
429
+ // Remove from review, THEN revoke: the guard only falls once the approval
430
+ // has fully committed (promoted file repaired + queue entry gone).
348
431
  fs.unlinkSync(found.filePath);
432
+ fireBlockedRevocation();
349
433
 
350
434
  return {
351
435
  itemId,
@@ -372,13 +456,13 @@ function dismissItem(
372
456
  }
373
457
 
374
458
  const content = fs.readFileSync(found.filePath, "utf8");
375
- fs.writeFileSync(
459
+ writeReviewFileCanonicalized(
460
+ memoryDir,
376
461
  found.filePath,
377
462
  updateFrontmatterFields(content, {
378
463
  reviewDismissed: "true",
379
464
  reviewDismissedAt: new Date().toISOString(),
380
465
  }),
381
- "utf8",
382
466
  );
383
467
  return {
384
468
  itemId,
@@ -403,7 +487,7 @@ function flagItem(
403
487
  flagged: "true",
404
488
  flaggedAt: new Date().toISOString(),
405
489
  });
406
- fs.writeFileSync(found.filePath, fixed);
490
+ writeReviewFileCanonicalized(memoryDir, found.filePath, fixed);
407
491
  return {
408
492
  itemId,
409
493
  action: "flag",