recallnest 3.0.0 → 4.0.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 (246) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +251 -258
  3. package/README_CN.md +226 -247
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +12 -3
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +459 -71
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer-stable.ts +5 -1
  28. package/src/context-composer.ts +11 -2
  29. package/src/distill-lock.ts +246 -74
  30. package/src/dream-pipeline.ts +3 -1
  31. package/src/embedder.ts +24 -0
  32. package/src/env-config.ts +136 -0
  33. package/src/flock.ts +97 -0
  34. package/src/forget-engine.ts +342 -58
  35. package/src/gateway-server.ts +4 -0
  36. package/src/ingest-plan.ts +56 -0
  37. package/src/ingest.ts +294 -121
  38. package/src/kg-contradiction.ts +753 -0
  39. package/src/kg-extractor.ts +17 -0
  40. package/src/kg-store.ts +1 -1
  41. package/src/known-item-eval.ts +455 -0
  42. package/src/llm-client.ts +13 -26
  43. package/src/llm-consolidation.ts +2 -2
  44. package/src/mcp-tool-deps.ts +6 -0
  45. package/src/mcp-tools-advanced.ts +80 -45
  46. package/src/mcp-tools-core.ts +23 -17
  47. package/src/mcp-tools-governance.ts +13 -7
  48. package/src/memory-assets.ts +144 -8
  49. package/src/memory-boundaries.ts +19 -0
  50. package/src/memory-evolution.ts +9 -0
  51. package/src/memory-lint.ts +1 -1
  52. package/src/memory-output.ts +311 -14
  53. package/src/memory-reconcile.ts +1437 -0
  54. package/src/memory-schema.ts +11 -0
  55. package/src/memory-store-port.ts +1 -0
  56. package/src/minis-archive.ts +151 -0
  57. package/src/noise-filter.ts +15 -2
  58. package/src/pii-detector.ts +31 -1
  59. package/src/pivot-apply.ts +55 -5
  60. package/src/pivot-evidence.ts +128 -0
  61. package/src/ppr-traversal.ts +25 -1
  62. package/src/preference-matcher.ts +40 -28
  63. package/src/prospective-memory.ts +7 -4
  64. package/src/retriever.ts +297 -57
  65. package/src/retroactive-boost.ts +3 -2
  66. package/src/runtime-config.ts +15 -1
  67. package/src/scope-policy.ts +1 -0
  68. package/src/session-distiller.ts +6 -2
  69. package/src/session-engine.ts +11 -4
  70. package/src/session-output.ts +20 -1
  71. package/src/session-schema.ts +5 -2
  72. package/src/session-store.ts +59 -12
  73. package/src/skill-promotion.ts +67 -1
  74. package/src/store-receipts.ts +109 -0
  75. package/src/store-write-budget.ts +37 -0
  76. package/src/store.ts +298 -57
  77. package/src/text-fingerprint.ts +25 -0
  78. package/src/trigger-store.ts +443 -0
  79. package/src/ui-server.ts +22 -21
  80. package/src/version-manager.ts +19 -5
  81. package/src/write-verifier.ts +16 -11
  82. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  83. package/src/__tests__/activity-counter.test.ts +0 -125
  84. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  85. package/src/__tests__/admission-control.test.ts +0 -206
  86. package/src/__tests__/age-label.test.ts +0 -33
  87. package/src/__tests__/anchor-generator.test.ts +0 -67
  88. package/src/__tests__/api-server-security.test.ts +0 -102
  89. package/src/__tests__/auto-consolidation.test.ts +0 -168
  90. package/src/__tests__/auto-recall.test.ts +0 -131
  91. package/src/__tests__/babel-integration.test.ts +0 -144
  92. package/src/__tests__/batch-dedup.test.ts +0 -65
  93. package/src/__tests__/belief-history.test.ts +0 -220
  94. package/src/__tests__/canary-eval.test.ts +0 -114
  95. package/src/__tests__/capture-engine.test.ts +0 -1328
  96. package/src/__tests__/capture-heuristic.test.ts +0 -171
  97. package/src/__tests__/cascade-forget.test.ts +0 -210
  98. package/src/__tests__/category-min-scores.test.ts +0 -116
  99. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  100. package/src/__tests__/circuit-breaker.test.ts +0 -99
  101. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  102. package/src/__tests__/confidence-metadata.test.ts +0 -125
  103. package/src/__tests__/confidence-tracker.test.ts +0 -261
  104. package/src/__tests__/conflict-advisor.test.ts +0 -206
  105. package/src/__tests__/conflict-escalation.test.ts +0 -149
  106. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  107. package/src/__tests__/conflict-output.test.ts +0 -85
  108. package/src/__tests__/conflict-store.test.ts +0 -418
  109. package/src/__tests__/connector-v1.test.ts +0 -181
  110. package/src/__tests__/consolidation-engine.test.ts +0 -520
  111. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  112. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  113. package/src/__tests__/context-composer-pins.test.ts +0 -64
  114. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  115. package/src/__tests__/context-reconstructor.test.ts +0 -65
  116. package/src/__tests__/context-renderer.test.ts +0 -91
  117. package/src/__tests__/continuity-eval.test.ts +0 -275
  118. package/src/__tests__/continuity-schema.test.ts +0 -97
  119. package/src/__tests__/contracts.test.ts +0 -262
  120. package/src/__tests__/conversation-importer.test.ts +0 -692
  121. package/src/__tests__/core-recall-eval.test.ts +0 -235
  122. package/src/__tests__/core-summary.test.ts +0 -109
  123. package/src/__tests__/data-checkup.test.ts +0 -304
  124. package/src/__tests__/decay-exemption.test.ts +0 -129
  125. package/src/__tests__/dedup-actions.test.ts +0 -58
  126. package/src/__tests__/dedup-guard.test.ts +0 -39
  127. package/src/__tests__/deterministic-id.test.ts +0 -39
  128. package/src/__tests__/distill-lock.test.ts +0 -305
  129. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  130. package/src/__tests__/doctor.test.ts +0 -319
  131. package/src/__tests__/dream-pipeline.test.ts +0 -820
  132. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  133. package/src/__tests__/dream-write-batching.test.ts +0 -211
  134. package/src/__tests__/drill-down.test.ts +0 -108
  135. package/src/__tests__/embedder.test.ts +0 -122
  136. package/src/__tests__/emotion-decay.test.ts +0 -46
  137. package/src/__tests__/emotion-detector.test.ts +0 -56
  138. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  139. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  140. package/src/__tests__/entity-resolver.test.ts +0 -58
  141. package/src/__tests__/env-config-invariant.test.ts +0 -80
  142. package/src/__tests__/env-config.test.ts +0 -211
  143. package/src/__tests__/error-signature-boost.test.ts +0 -87
  144. package/src/__tests__/error-signature.test.ts +0 -95
  145. package/src/__tests__/error-taxonomy.test.ts +0 -129
  146. package/src/__tests__/ethics-layer.test.ts +0 -476
  147. package/src/__tests__/event-segmenter.test.ts +0 -153
  148. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  149. package/src/__tests__/frequency-tracker.test.ts +0 -113
  150. package/src/__tests__/freshness.test.ts +0 -186
  151. package/src/__tests__/graph-export.test.ts +0 -489
  152. package/src/__tests__/hotness-blend.test.ts +0 -283
  153. package/src/__tests__/ingest-chunking.test.ts +0 -44
  154. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  155. package/src/__tests__/ingest.test.ts +0 -360
  156. package/src/__tests__/interference-detector.test.ts +0 -203
  157. package/src/__tests__/kg-extractor.test.ts +0 -192
  158. package/src/__tests__/kg-store.test.ts +0 -300
  159. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  160. package/src/__tests__/llm-consolidation.test.ts +0 -226
  161. package/src/__tests__/maintenance-locks.test.ts +0 -197
  162. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  163. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  164. package/src/__tests__/memory-boundaries.test.ts +0 -282
  165. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  166. package/src/__tests__/memory-evolution.test.ts +0 -271
  167. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  168. package/src/__tests__/memory-lint.test.ts +0 -421
  169. package/src/__tests__/memory-output.test.ts +0 -224
  170. package/src/__tests__/memory-promotion.test.ts +0 -315
  171. package/src/__tests__/memory-utility.test.ts +0 -256
  172. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  173. package/src/__tests__/multi-vector.test.ts +0 -242
  174. package/src/__tests__/narrative-architecture.test.ts +0 -456
  175. package/src/__tests__/noise-filter.test.ts +0 -205
  176. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  177. package/src/__tests__/obsidian-connector.test.ts +0 -363
  178. package/src/__tests__/omitted-reporting.test.ts +0 -98
  179. package/src/__tests__/openai-contract.test.ts +0 -513
  180. package/src/__tests__/pending-review.test.ts +0 -77
  181. package/src/__tests__/pii-redact.test.ts +0 -333
  182. package/src/__tests__/pivot-apply.test.ts +0 -373
  183. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  184. package/src/__tests__/pivot-distill.test.ts +0 -1178
  185. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  186. package/src/__tests__/ppr-traversal.test.ts +0 -256
  187. package/src/__tests__/prediction-engine.test.ts +0 -349
  188. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  189. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  190. package/src/__tests__/preference-matcher.test.ts +0 -282
  191. package/src/__tests__/preference-slots.test.ts +0 -205
  192. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  193. package/src/__tests__/prospective-memory.test.ts +0 -283
  194. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  195. package/src/__tests__/query-entity-detector.test.ts +0 -171
  196. package/src/__tests__/query-expander-alias.test.ts +0 -146
  197. package/src/__tests__/query-expander.test.ts +0 -34
  198. package/src/__tests__/recall-gate.test.ts +0 -266
  199. package/src/__tests__/recall-governor.test.ts +0 -227
  200. package/src/__tests__/recall-mode.test.ts +0 -64
  201. package/src/__tests__/resolve-db-path.test.ts +0 -130
  202. package/src/__tests__/resolve-tier.test.ts +0 -64
  203. package/src/__tests__/result-synthesizer.test.ts +0 -198
  204. package/src/__tests__/resume-context.test.ts +0 -4898
  205. package/src/__tests__/retrieval-eval.test.ts +0 -163
  206. package/src/__tests__/retrieval-trace.test.ts +0 -213
  207. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  208. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  209. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  210. package/src/__tests__/retroactive-boost.test.ts +0 -223
  211. package/src/__tests__/salience-scoring.test.ts +0 -106
  212. package/src/__tests__/scope-inventory.test.ts +0 -200
  213. package/src/__tests__/scope-policy.test.ts +0 -69
  214. package/src/__tests__/scope-suggester.test.ts +0 -80
  215. package/src/__tests__/session-checkpoint.test.ts +0 -291
  216. package/src/__tests__/session-distiller.test.ts +0 -640
  217. package/src/__tests__/session-gc.test.ts +0 -215
  218. package/src/__tests__/skill-engine.test.ts +0 -552
  219. package/src/__tests__/skill-promotion.test.ts +0 -654
  220. package/src/__tests__/skill-verifier.test.ts +0 -122
  221. package/src/__tests__/source-heartbeat.test.ts +0 -254
  222. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  223. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  224. package/src/__tests__/store-read-consistency.test.ts +0 -122
  225. package/src/__tests__/store-schema-migration.test.ts +0 -87
  226. package/src/__tests__/store-scope-audit.test.ts +0 -81
  227. package/src/__tests__/store-upsert.test.ts +0 -201
  228. package/src/__tests__/supersede-chain.test.ts +0 -154
  229. package/src/__tests__/synthesis-contract.test.ts +0 -252
  230. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  231. package/src/__tests__/temporal-parser.test.ts +0 -241
  232. package/src/__tests__/temporal-validity.test.ts +0 -160
  233. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  234. package/src/__tests__/term-registry.test.ts +0 -121
  235. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  236. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  237. package/src/__tests__/topic-tag.test.ts +0 -113
  238. package/src/__tests__/trusted-memory.test.ts +0 -867
  239. package/src/__tests__/ui-server-security.test.ts +0 -93
  240. package/src/__tests__/usage-tracker.test.ts +0 -287
  241. package/src/__tests__/utility-weight.test.ts +0 -57
  242. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  243. package/src/__tests__/version-manager.test.ts +0 -248
  244. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  245. package/src/__tests__/workflow-observation.test.ts +0 -433
  246. package/src/__tests__/write-verifier.test.ts +0 -122
package/src/flock.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Kernel advisory file locks (flock(2)) reached through bun:ffi, for distill-lock.ts.
3
+ *
4
+ * Why the kernel: a flock belongs to an open file description, and the kernel drops it when the
5
+ * last descriptor closes — including when the holding process exits or is SIGKILLed. So "is the
6
+ * holder still alive" is answered by the kernel, and nobody ever has to decide that a lock is
7
+ * stale and take it away from its holder.
8
+ *
9
+ * Only `flock(int, int)` and the errno accessor are called; both are non-variadic, so the FFI
10
+ * signatures below match the C ABI on x86-64 and arm64 alike (fcntl/open are variadic and would
11
+ * not be safe to declare this way on Apple arm64, which passes variadic arguments on the stack).
12
+ *
13
+ * - Linux (glibc): `flock` and `__errno_location` in libc.so.6. LOCK_EX=2, LOCK_NB=4, LOCK_UN=8,
14
+ * EWOULDBLOCK=EAGAIN=11, EINTR=4, O_CLOEXEC=0o2000000.
15
+ * - macOS: `flock` and `__error` in libSystem.B.dylib (served from the dyld shared cache, so
16
+ * dlopen by its install name works without the file on disk). Same LOCK_* values
17
+ * (<sys/fcntl.h>), EWOULDBLOCK=EAGAIN=35, EINTR=4, O_CLOEXEC=0x01000000.
18
+ */
19
+
20
+ import { dlopen, FFIType, read, type Pointer } from "bun:ffi";
21
+ import { constants, openSync } from "node:fs";
22
+
23
+ const LOCK_EX = 2;
24
+ const LOCK_NB = 4;
25
+ const LOCK_UN = 8;
26
+ const EINTR = 4;
27
+
28
+ interface Platform {
29
+ libPath: string;
30
+ errnoSymbol: "__errno_location" | "__error";
31
+ ewouldblock: number;
32
+ oCloexec: number;
33
+ }
34
+
35
+ function platform(): Platform {
36
+ if (process.platform === "linux") {
37
+ return { libPath: "libc.so.6", errnoSymbol: "__errno_location", ewouldblock: 11, oCloexec: 0o2000000 };
38
+ }
39
+ if (process.platform === "darwin") {
40
+ return { libPath: "/usr/lib/libSystem.B.dylib", errnoSymbol: "__error", ewouldblock: 35, oCloexec: 0x01000000 };
41
+ }
42
+ throw new Error(`cross-process locks need flock(2); platform '${process.platform}' is not supported`);
43
+ }
44
+
45
+ interface Flock {
46
+ flock: (fd: number, op: number) => number;
47
+ errno: () => number;
48
+ ewouldblock: number;
49
+ oCloexec: number;
50
+ }
51
+
52
+ let loaded: Flock | null = null;
53
+
54
+ function lib(): Flock {
55
+ if (loaded) return loaded;
56
+ const p = platform();
57
+ const handle = dlopen(p.libPath, {
58
+ flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
59
+ [p.errnoSymbol]: { args: [], returns: FFIType.ptr },
60
+ });
61
+ const symbols = handle.symbols as unknown as Record<string, (...args: number[]) => unknown>;
62
+ const flockFn = symbols.flock!;
63
+ const errnoFn = symbols[p.errnoSymbol]!;
64
+ loaded = {
65
+ flock: (fd, op) => flockFn(fd, op) as number,
66
+ errno: () => read.i32(errnoFn() as Pointer, 0),
67
+ ewouldblock: p.ewouldblock,
68
+ oCloexec: p.oCloexec,
69
+ };
70
+ return loaded;
71
+ }
72
+
73
+ /**
74
+ * Open (creating if needed) a lock file for reading and writing, close-on-exec so a child
75
+ * process can never inherit the descriptor and with it the lock.
76
+ */
77
+ export function openLockFile(path: string): number {
78
+ return openSync(path, constants.O_RDWR | constants.O_CREAT | lib().oCloexec, 0o644);
79
+ }
80
+
81
+ /** Non-blocking exclusive flock. true = acquired, false = another open file description holds it. */
82
+ export function tryLockExclusive(fd: number): boolean {
83
+ const l = lib();
84
+ for (;;) {
85
+ if (l.flock(fd, LOCK_EX | LOCK_NB) === 0) return true;
86
+ // Read errno immediately: nothing runs between the FFI return and this call.
87
+ const errno = l.errno();
88
+ if (errno === l.ewouldblock) return false;
89
+ if (errno === EINTR) continue;
90
+ throw new Error(`flock(LOCK_EX|LOCK_NB) failed on fd ${fd}: errno ${errno}`);
91
+ }
92
+ }
93
+
94
+ /** Explicit unlock. Closing the descriptor also unlocks; this is for clarity before close. */
95
+ export function unlockFd(fd: number): void {
96
+ lib().flock(fd, LOCK_UN);
97
+ }
@@ -1,19 +1,57 @@
1
1
  /**
2
- * Forget Engine — Ethics-aware memory deletion with full cascade.
2
+ * Forget Engine — ethics-aware memory deletion, with a read-back.
3
3
  *
4
- * Philosophy: "The right to forget" — memories can be explicitly removed,
5
- * but the process must be auditable, reversible (via evidence export),
6
- * and propagated to all derived artifacts (KG triples, pins, evolution chains).
4
+ * Philosophy: "The right to forget" — a memory can be explicitly removed, the
5
+ * removal is auditable, and it reaches the places this engine knows the memory
6
+ * left a copy or a pointer in.
7
7
  *
8
- * Sequence:
9
- * 1. Fetch target memory
8
+ * Sequence (the step comments in forgetMemory() carry the same numbers and names;
9
+ * forget-cascade-completeness.test.ts fails if the two lists drift apart):
10
+ * 1. Fetch target
10
11
  * 2. Privacy tier check (durable requires explicit confirm)
11
- * 3. Evidence export (snapshot before deletion for audit trail)
12
- * 4. KG triple cleanup (via KGStore.deleteBySource)
13
- * 5. Pin archive (mark related pins as forgotten)
14
- * 6. Cascade demote (related memories via cascade-forget.ts)
15
- * 7. Primary delete (remove from LanceDB)
16
- * 8. Audit log (record the forget operation)
12
+ * 3. Evidence snapshot (returned to the caller, not persisted)
13
+ * 4. Trigger rows (delete its rows in memory_triggers; a failure aborts before anything is touched)
14
+ * 5. Pin archive (delete the indexed copy of each pin made from it, then move the pin files out of data/pins; a failure aborts, the primary row stays)
15
+ * 6. KG triples (KGStore.deleteBySource; a failure is logged and the forget continues)
16
+ * 7. Cascade demote (related memories lose importance, via cascade-forget.ts)
17
+ * 8. Evolution breadcrumb (mark the row archived with a "forgotten:" note just before deleting it)
18
+ * 9. Primary delete (remove the row from LanceDB)
19
+ * 10. Read-back (fetch the id again; count what is left in triggers, pins, pin index rows and KG)
20
+ * 11. Audit log (record the operation and what the read-back found)
21
+ *
22
+ * Steps 4 and 5 fail closed and run before the primary delete: once the row is
23
+ * gone, forget() can no longer find the id, so whatever they left behind would
24
+ * have no tool to remove it.
25
+ *
26
+ * When a forget stops part-way:
27
+ * - at step 4, nothing has been changed;
28
+ * - at step 5, the memory is untouched and still active, but its trigger rows, pin
29
+ * index rows or some pin files may already be gone;
30
+ * - at step 9 or 10 (the delete threw, or the row is still readable afterwards), the
31
+ * row stays in the table marked archived with the "forgotten:" note — default recall
32
+ * skips it and the memory-file reconciler treats its text as forgotten — while its
33
+ * triggers, pins and KG triples are gone. That is a soft forget; running forget
34
+ * again finishes it.
35
+ * Whenever something was already changed, the error says what, and a
36
+ * `forget_incomplete` audit entry records it. That entry deliberately carries no
37
+ * `norm=` fingerprint and is not a `forget` operation, so the reconciler does not
38
+ * read an aborted forget as a completed one.
39
+ *
40
+ * Not covered — forgetting a memory does NOT touch:
41
+ * - memories derived from it by dream / consolidation (insights, patterns), or
42
+ * evolution references held by other rows (supersedes, sourceMemories, consolidatedInto);
43
+ * - brief assets and exports that quoted it;
44
+ * - document slices or transcript chunks in other scopes that say the same thing;
45
+ * - the archived pin files themselves (data/archive/forgotten-pins keeps the snippet so a
46
+ * mistaken forget can be undone; remove the file by hand if the content has to go);
47
+ * - older LanceDB table versions, backups, and the session transcripts it came from.
48
+ * The audit entry of a completed forget carries the normalized text fingerprint so
49
+ * that the memory-file reconciler does not put the same text back (memory-reconcile.ts).
50
+ *
51
+ * Until 2026-10-05 this header promised propagation to "all derived artifacts
52
+ * (KG triples, pins, evolution chains)" while the body had no pin code at all, the
53
+ * memory_triggers side table (added 2026-09-22) was never cleaned, and nothing
54
+ * read back after the delete.
17
55
  */
18
56
 
19
57
  import type { MemoryStore, MemoryEntry } from "./store.js";
@@ -22,6 +60,7 @@ import type { AuditLogger } from "./audit-log.js";
22
60
  import { parsePrivacyTier, type PrivacyTier } from "./memory-schema.js";
23
61
  import { parseEvolution, patchEvolution } from "./memory-evolution.js";
24
62
  import { cascadeForget, type CascadeForgetConfig, DEFAULT_CASCADE_FORGET_CONFIG } from "./cascade-forget.js";
63
+ import { textFingerprint } from "./text-fingerprint.js";
25
64
 
26
65
  // ---------------------------------------------------------------------------
27
66
  // Types
@@ -51,6 +90,38 @@ export interface ForgetEvidence {
51
90
  reason?: string;
52
91
  }
53
92
 
93
+ /** The places the read-back looks. */
94
+ export type ForgetLayer = "primary" | "triggers" | "pins" | "pin-index" | "kg";
95
+
96
+ /**
97
+ * What the read-back after the delete found.
98
+ * A count is `null` when that layer is not wired in, or when counting it failed; the
99
+ * second case is also listed in `unverified`, so "not there" and "could not look" stay apart.
100
+ */
101
+ export interface ForgetVerification {
102
+ /** gone = the id no longer resolves; present = it still does; unverified = the read itself failed */
103
+ primary: "gone" | "present" | "unverified";
104
+ triggerRowsLeft: number | null;
105
+ pinsLeft: number | null;
106
+ pinIndexRowsLeft: number | null;
107
+ kgTriplesLeft: number | null;
108
+ /** Layers whose check threw. Empty when everything that could be checked was checked. */
109
+ unverified: ForgetLayer[];
110
+ }
111
+
112
+ /**
113
+ * One word for a read-back. `clean` is the only state in which the forget can be
114
+ * reported as done without a caveat: the id is gone, nothing was found left over,
115
+ * and no check failed.
116
+ */
117
+ export function summarizeVerification(v: ForgetVerification): "clean" | "leftovers" | "unverified" | "present" {
118
+ if (v.primary === "present") return "present";
119
+ const left = (v.triggerRowsLeft ?? 0) + (v.pinsLeft ?? 0) + (v.pinIndexRowsLeft ?? 0) + (v.kgTriplesLeft ?? 0);
120
+ if (left > 0) return "leftovers";
121
+ if (v.unverified.length > 0) return "unverified";
122
+ return "clean";
123
+ }
124
+
54
125
  export interface ForgetResult {
55
126
  /** Whether the memory was successfully forgotten */
56
127
  success: boolean;
@@ -58,19 +129,163 @@ export interface ForgetResult {
58
129
  memoryId: string;
59
130
  /** Evidence snapshot (for audit/undo) */
60
131
  evidence?: ForgetEvidence;
61
- /** Number of KG triples removed */
132
+ /** Whether the KG cleanup ran without error */
62
133
  kgTriplesRemoved: boolean;
134
+ /** Rows removed from memory_triggers (null when no trigger store is wired in) */
135
+ triggerRowsRemoved: number | null;
136
+ /** Pin files moved to the archive (null when no pin archive is wired in) */
137
+ pinsArchived: number | null;
138
+ /** Indexed copies of those pins removed from the main table (null when the step was not reached) */
139
+ pinIndexRowsRemoved: number | null;
63
140
  /** Cascade demote results */
64
141
  cascadeResult: { demotedCount: number; demotedIds: string[] };
142
+ /** Read-back after the primary delete (absent when the forget stopped before deleting) */
143
+ verification?: ForgetVerification;
65
144
  /** Error message if failed */
66
145
  error?: string;
67
146
  }
68
147
 
148
+ /** The slice of TriggerStore the forget engine needs. */
149
+ export interface ForgetTriggerStore {
150
+ /**
151
+ * Delete every trigger row of this memory; resolves to the number of rows it counted just
152
+ * before deleting. A row that another process adds in between is not in that number, and
153
+ * when the number is 0 no delete is issued at all — the read-back (step 10) counts again.
154
+ */
155
+ deleteForMemory(memoryId: string): Promise<number>;
156
+ countForMemory(memoryId: string): Promise<number>;
157
+ }
158
+
159
+ /** Pin files made from a memory (memory-assets.ts: archivePinAssetsForMemory / findPinAssetsForMemory). */
160
+ export interface ForgetPinArchive {
161
+ /**
162
+ * Move this memory's pin files out of the pins directory; returns how many were moved.
163
+ * If it fails after some files were already moved, the error it throws carries that number
164
+ * as `archivedCount` — otherwise the engine would report that nothing had been changed.
165
+ */
166
+ archiveForMemory(memoryId: string): number;
167
+ /** How many pin files made from this memory are still in the pins directory. */
168
+ countForMemory(memoryId: string): number;
169
+ }
170
+
69
171
  export interface ForgetByIdDeps {
70
172
  store: MemoryStore;
71
173
  kgStore?: KGStore | null;
72
174
  auditLogger?: AuditLogger | null;
73
175
  cascadeConfig?: CascadeForgetConfig;
176
+ /** Omit to skip step 4 (callers that have no trigger side table). */
177
+ triggerStore?: ForgetTriggerStore | null;
178
+ /** Omit to skip the file half of step 5 (the indexed copies live in `store` and are always handled). */
179
+ pins?: ForgetPinArchive | null;
180
+ }
181
+
182
+ /** What a forget has already changed at the moment it stops or finishes. */
183
+ interface ForgetProgress {
184
+ triggerRowsRemoved: number | null;
185
+ pinIndexRowsRemoved: number | null;
186
+ pinsArchived: number | null;
187
+ kgTriplesRemoved: boolean;
188
+ cascadeResult: { demotedCount: number; demotedIds: string[] };
189
+ breadcrumbWritten: boolean;
190
+ }
191
+
192
+ function errorMessage(err: unknown): string {
193
+ return err instanceof Error ? err.message : String(err);
194
+ }
195
+
196
+ /** How many pin files a failed pin archive had already moved, when its error says so (see ForgetPinArchive). */
197
+ function archivedCountOf(err: unknown): number | null {
198
+ if (typeof err !== "object" || err === null || !("archivedCount" in err)) return null;
199
+ const n = (err as { archivedCount: unknown }).archivedCount;
200
+ return typeof n === "number" && Number.isInteger(n) && n > 0 ? n : null;
201
+ }
202
+
203
+ /** Quote a value so that it pastes into a POSIX shell command line as one argument. */
204
+ function shellQuote(value: string): string {
205
+ return /^[A-Za-z0-9_.:\/@%+=,-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
206
+ }
207
+
208
+ function countOrNa(n: number | null): string {
209
+ return n === null ? "n/a" : String(n);
210
+ }
211
+
212
+ function failed(memoryId: string, error: string, partial: Partial<ForgetResult> = {}): ForgetResult {
213
+ return {
214
+ success: false,
215
+ memoryId,
216
+ kgTriplesRemoved: false,
217
+ triggerRowsRemoved: null,
218
+ pinsArchived: null,
219
+ pinIndexRowsRemoved: null,
220
+ cascadeResult: { demotedCount: 0, demotedIds: [] },
221
+ error,
222
+ ...partial,
223
+ };
224
+ }
225
+
226
+ function resultFields(progress: ForgetProgress): Pick<ForgetResult, "kgTriplesRemoved" | "triggerRowsRemoved" | "pinsArchived" | "pinIndexRowsRemoved" | "cascadeResult"> {
227
+ return {
228
+ kgTriplesRemoved: progress.kgTriplesRemoved,
229
+ triggerRowsRemoved: progress.triggerRowsRemoved,
230
+ pinsArchived: progress.pinsArchived,
231
+ pinIndexRowsRemoved: progress.pinIndexRowsRemoved,
232
+ cascadeResult: progress.cascadeResult,
233
+ };
234
+ }
235
+
236
+ /** In plain words, what has already been changed. Empty when nothing has. */
237
+ function describeProgress(progress: ForgetProgress): string[] {
238
+ const parts: string[] = [];
239
+ if (progress.triggerRowsRemoved) parts.push(`${progress.triggerRowsRemoved} trigger row(s) removed`);
240
+ if (progress.pinIndexRowsRemoved) parts.push(`${progress.pinIndexRowsRemoved} pin index row(s) removed`);
241
+ if (progress.pinsArchived) parts.push(`${progress.pinsArchived} pin file(s) archived`);
242
+ if (progress.kgTriplesRemoved) parts.push("KG cleanup ran");
243
+ if (progress.cascadeResult.demotedCount) parts.push(`${progress.cascadeResult.demotedCount} related memories demoted`);
244
+ if (progress.breadcrumbWritten) parts.push('row marked archived with a "forgotten:" note');
245
+ return parts;
246
+ }
247
+
248
+ /**
249
+ * Rows in the main table that index a pin made from this memory. `pin_memory` writes one
250
+ * per pin (asset-sync.ts: scope `asset:<pin id>`, text = title + summary + snippet,
251
+ * metadata.originalMemoryId), and search returns them like any other row.
252
+ */
253
+ async function findPinIndexRows(store: MemoryStore, memoryId: string): Promise<MemoryEntry[]> {
254
+ const rows = await store.list(["asset"], undefined, Number.MAX_SAFE_INTEGER, 0);
255
+ return rows.filter((row) => {
256
+ if (!row.scope.startsWith("asset:")) return false;
257
+ try {
258
+ const meta = JSON.parse(row.metadata || "{}") as Record<string, unknown>;
259
+ return meta.assetType === "pinned-memory" && meta.originalMemoryId === memoryId;
260
+ } catch {
261
+ return false;
262
+ }
263
+ });
264
+ }
265
+
266
+ /** Step 10. Checks by id only: every retrieval path can return a row only if it is still in the table. */
267
+ async function readBack(deps: ForgetByIdDeps, id: string, scopeFilter?: string[]): Promise<ForgetVerification> {
268
+ const { store, kgStore, triggerStore, pins } = deps;
269
+ const unverified: ForgetLayer[] = [];
270
+ const check = async <T>(layer: ForgetLayer, look: () => T | Promise<T>): Promise<T | null> => {
271
+ try {
272
+ return await look();
273
+ } catch (err) {
274
+ unverified.push(layer);
275
+ console.error(`[recallnest] Read-back of ${layer} failed after forget:`, errorMessage(err));
276
+ return null;
277
+ }
278
+ };
279
+
280
+ const row = await check("primary", async () => ((await store.get(id, scopeFilter)) ? "present" as const : "gone" as const));
281
+ const triggerRowsLeft = triggerStore ? await check("triggers", () => triggerStore.countForMemory(id)) : null;
282
+ const pinsLeft = pins ? await check("pins", () => pins.countForMemory(id)) : null;
283
+ const pinIndexRowsLeft = await check("pin-index", async () => (await findPinIndexRows(store, id)).length);
284
+ const kgTriplesLeft = kgStore && typeof kgStore.getTriplesBySourceMemories === "function"
285
+ ? await check("kg", async () => (await kgStore.getTriplesBySourceMemories([id])).get(id)?.length ?? 0)
286
+ : null;
287
+
288
+ return { primary: row ?? "unverified", triggerRowsLeft, pinsLeft, pinIndexRowsLeft, kgTriplesLeft, unverified };
74
289
  }
75
290
 
76
291
  // ---------------------------------------------------------------------------
@@ -81,34 +296,22 @@ export async function forgetMemory(
81
296
  deps: ForgetByIdDeps,
82
297
  request: ForgetRequest,
83
298
  ): Promise<ForgetResult> {
84
- const { store, kgStore, auditLogger, cascadeConfig } = deps;
299
+ const { store, kgStore, auditLogger, cascadeConfig, triggerStore, pins } = deps;
85
300
  const { memoryId, confirm, reason, scopeFilter } = request;
86
301
 
87
302
  // 1. Fetch target
88
303
  const entry = await store.get(memoryId, scopeFilter);
89
304
  if (!entry) {
90
- return {
91
- success: false,
92
- memoryId,
93
- kgTriplesRemoved: false,
94
- cascadeResult: { demotedCount: 0, demotedIds: [] },
95
- error: `Memory ${memoryId} not found`,
96
- };
305
+ return failed(memoryId, `Memory ${memoryId} not found`);
97
306
  }
98
307
 
99
308
  // 2. Privacy tier check
100
309
  const privacyTier = parsePrivacyTier(entry.metadata);
101
310
  if (privacyTier === "durable" && !confirm) {
102
- return {
103
- success: false,
104
- memoryId: entry.id,
105
- kgTriplesRemoved: false,
106
- cascadeResult: { demotedCount: 0, demotedIds: [] },
107
- error: `Memory ${entry.id} has privacy tier "durable" — set confirm=true to proceed`,
108
- };
311
+ return failed(entry.id, `Memory ${entry.id} has privacy tier "durable" — set confirm=true to proceed`);
109
312
  }
110
313
 
111
- // 3. Evidence export (snapshot before deletion)
314
+ // 3. Evidence snapshot
112
315
  const evolution = parseEvolution(entry.metadata, entry.timestamp);
113
316
  const evidence: ForgetEvidence = {
114
317
  entry: { ...entry },
@@ -118,82 +321,160 @@ export async function forgetMemory(
118
321
  reason,
119
322
  };
120
323
 
121
- // 4. KG triple cleanup
122
- let kgTriplesRemoved = false;
324
+ const progress: ForgetProgress = {
325
+ triggerRowsRemoved: null,
326
+ pinIndexRowsRemoved: null,
327
+ pinsArchived: null,
328
+ kgTriplesRemoved: false,
329
+ cascadeResult: { demotedCount: 0, demotedIds: [] },
330
+ breadcrumbWritten: false,
331
+ };
332
+
333
+ // Stops the forget after something may already have been changed: says what in the error,
334
+ // and leaves a `forget_incomplete` audit entry (see the file header for why it is not a `forget`).
335
+ const stopIncomplete = (
336
+ stage: "pin-archive" | "delete" | "read-back",
337
+ problem: string,
338
+ nextStep: string,
339
+ extra: Partial<ForgetResult> = {},
340
+ ): ForgetResult => {
341
+ const done = describeProgress(progress);
342
+ if (done.length > 0) {
343
+ try {
344
+ auditLogger?.log({
345
+ operation: "forget_incomplete",
346
+ scope: entry.scope,
347
+ memoryId: entry.id,
348
+ actor: "system",
349
+ details: `stage=${stage} triggers=${countOrNa(progress.triggerRowsRemoved)} pins=${countOrNa(progress.pinsArchived)} pinIndex=${countOrNa(progress.pinIndexRowsRemoved)} kg=${progress.kgTriplesRemoved ? "ran" : "no"} cascade=${progress.cascadeResult.demotedCount} breadcrumb=${progress.breadcrumbWritten ? "yes" : "no"} error=${problem}`,
350
+ });
351
+ } catch (err) {
352
+ console.error("[recallnest] Audit log failed during an incomplete forget:", errorMessage(err));
353
+ }
354
+ }
355
+ const already = done.length > 0 ? ` Already done and not rolled back: ${done.join("; ")}.` : "";
356
+ return failed(entry.id, `${problem}${already} ${nextStep}`, { evidence, ...resultFields(progress), ...extra });
357
+ };
358
+
359
+ // 4. Trigger rows — fail closed (see the file header)
360
+ if (triggerStore) {
361
+ try {
362
+ progress.triggerRowsRemoved = await triggerStore.deleteForMemory(entry.id);
363
+ } catch (err) {
364
+ return failed(entry.id, `Trigger cleanup failed: ${errorMessage(err)}. Nothing was deleted; retry the forget.`, { evidence });
365
+ }
366
+ }
367
+
368
+ // 5. Pin archive — fail closed. A pin is a file under data/pins and an indexed copy in the
369
+ // main table; the copy goes first, because it is the half that search returns.
370
+ try {
371
+ const indexRows = await findPinIndexRows(store, entry.id);
372
+ progress.pinIndexRowsRemoved = 0;
373
+ for (const row of indexRows) {
374
+ // false: the row was already gone (removed between the listing above and this call), so it is not counted
375
+ if (await store.delete(row.id)) progress.pinIndexRowsRemoved += 1;
376
+ }
377
+ if (pins) progress.pinsArchived = pins.archiveForMemory(entry.id);
378
+ } catch (err) {
379
+ // The archive may have moved some files before it failed. Without this, the error below
380
+ // and the audit entry would both say that nothing had been changed.
381
+ const movedBeforeFailing = archivedCountOf(err);
382
+ if (movedBeforeFailing !== null) progress.pinsArchived = movedBeforeFailing;
383
+ const restoreTriggers = progress.triggerRowsRemoved
384
+ ? ` To keep the memory instead, restore its trigger rows with \`triggers-backfill --rebuild --apply --scope ${shellQuote(entry.scope)}\`.`
385
+ : "";
386
+ return stopIncomplete(
387
+ "pin-archive",
388
+ `Pin archive failed: ${errorMessage(err)}.`,
389
+ `The memory itself was not deleted and is still active. Retry the forget.${restoreTriggers}`,
390
+ );
391
+ }
392
+
393
+ // 6. KG triples
123
394
  if (kgStore) {
124
395
  try {
125
396
  await kgStore.deleteBySource(entry.id);
126
- kgTriplesRemoved = true;
397
+ progress.kgTriplesRemoved = true;
127
398
  } catch (err) {
128
- console.error("[recallnest] KG cleanup failed during forget:", err instanceof Error ? err.message : String(err));
399
+ console.error("[recallnest] KG cleanup failed during forget:", errorMessage(err));
129
400
  }
130
401
  }
131
402
 
132
- // 5. Cascade demote (related memories get importance reduction)
133
- let cascadeResult = { demotedCount: 0, demotedIds: [] as string[] };
403
+ // 7. Cascade demote
134
404
  try {
135
- cascadeResult = await cascadeForget(
405
+ progress.cascadeResult = await cascadeForget(
136
406
  store,
137
407
  { id: entry.id, vector: entry.vector, scope: entry.scope },
138
408
  cascadeConfig ?? DEFAULT_CASCADE_FORGET_CONFIG,
139
409
  );
140
410
  } catch (err) {
141
- console.error("[recallnest] Cascade demote failed during forget:", err instanceof Error ? err.message : String(err));
411
+ console.error("[recallnest] Cascade demote failed during forget:", errorMessage(err));
142
412
  }
143
413
 
144
- // 6. Mark evolution status as "forgotten" before delete (audit breadcrumb)
414
+ // 8. Evolution breadcrumb
145
415
  try {
146
416
  const patchedMetadata = patchEvolution(entry.metadata, {
147
417
  status: "archived" as any,
148
418
  evolutionNote: `forgotten: ${reason || "user request"}`,
149
419
  });
150
- await store.update(entry.id, { metadata: patchedMetadata }, scopeFilter);
420
+ progress.breadcrumbWritten = (await store.update(entry.id, { metadata: patchedMetadata }, scopeFilter)) !== null;
151
421
  } catch (err) {
152
- console.error("[recallnest] Evolution patch failed during forget:", err instanceof Error ? err.message : String(err));
422
+ console.error("[recallnest] Evolution patch failed during forget:", errorMessage(err));
153
423
  }
154
424
 
155
- // 7. Primary delete
425
+ const rowState = () => (progress.breadcrumbWritten
426
+ ? 'The row is still in the table, marked archived with a "forgotten:" note: default recall skips it and the memory-file reconciler treats its text as forgotten.'
427
+ : "The row is still in the table and still active.");
428
+
429
+ // 9. Primary delete
156
430
  try {
157
431
  await store.delete(entry.id, scopeFilter);
158
432
  } catch (err) {
159
- return {
160
- success: false,
161
- memoryId: entry.id,
162
- evidence,
163
- kgTriplesRemoved,
164
- cascadeResult,
165
- error: `Delete failed: ${err instanceof Error ? err.message : String(err)}`,
166
- };
433
+ return stopIncomplete("delete", `Delete failed: ${errorMessage(err)}.`, `${rowState()} Run forget again to finish.`);
167
434
  }
168
435
 
169
- // 8. Audit log
436
+ // 10. Read-back
437
+ const verification = await readBack(deps, entry.id, scopeFilter);
438
+ if (verification.primary === "present") {
439
+ return stopIncomplete(
440
+ "read-back",
441
+ `Delete reported success but memory ${entry.id} is still readable.`,
442
+ `${rowState()} Run forget again to finish.`,
443
+ { verification },
444
+ );
445
+ }
446
+
447
+ // 11. Audit log
170
448
  try {
171
449
  auditLogger?.log({
172
450
  operation: "forget",
173
451
  scope: entry.scope,
174
452
  memoryId: entry.id,
175
453
  actor: "system",
176
- details: `tier=${privacyTier} reason=${reason || "none"} cascade=${cascadeResult.demotedCount}`,
454
+ // norm=<归一文本指纹> 放最前:删的是这一行,要忘的是这段文字。记忆文件对账据此不把同文的另一行
455
+ // 恢复回来(memory-reconcile.ts),只认 id 的话换个空白就绕过去了。
456
+ // reason 是自由文本,放最后:details 超 200 字会被截断,前面的字段不能被它挤掉。
457
+ details: `norm=${textFingerprint(entry.text)} tier=${privacyTier} triggers=${countOrNa(progress.triggerRowsRemoved)} pins=${countOrNa(progress.pinsArchived)} pinIndex=${countOrNa(progress.pinIndexRowsRemoved)} verify=${summarizeVerification(verification)} cascade=${progress.cascadeResult.demotedCount} reason=${reason || "none"}`,
177
458
  });
178
- if (cascadeResult.demotedCount > 0) {
459
+ if (progress.cascadeResult.demotedCount > 0) {
179
460
  auditLogger?.log({
180
461
  operation: "cascade_forget",
181
462
  scope: entry.scope,
182
463
  memoryId: entry.id,
183
464
  actor: "system",
184
- details: `demoted ${cascadeResult.demotedCount} related memories: ${cascadeResult.demotedIds.map(id => id.slice(0, 8)).join(",")}`,
465
+ details: `demoted ${progress.cascadeResult.demotedCount} related memories: ${progress.cascadeResult.demotedIds.map(id => id.slice(0, 8)).join(",")}`,
185
466
  });
186
467
  }
187
468
  } catch (err) {
188
- console.error("[recallnest] Audit log failed during forget:", err instanceof Error ? err.message : String(err));
469
+ console.error("[recallnest] Audit log failed during forget:", errorMessage(err));
189
470
  }
190
471
 
191
472
  return {
192
473
  success: true,
193
474
  memoryId: entry.id,
194
475
  evidence,
195
- kgTriplesRemoved,
196
- cascadeResult,
476
+ ...resultFields(progress),
477
+ verification,
197
478
  };
198
479
  }
199
480
 
@@ -248,9 +529,12 @@ export async function forgetByScope(
248
529
  }
249
530
  }
250
531
 
251
- // Bulk KG scope cleanup
532
+ // Bulk KG scope cleanup — only when every entry was forgotten. An entry whose forget stopped
533
+ // (its trigger or pin cleanup failed) is still an active memory, and wiping the whole scope
534
+ // would strip its triples anyway. The entries that were forgotten have already lost theirs
535
+ // in step 6 of forgetMemory (deleteBySource).
252
536
  let kgScopeCleared = false;
253
- if (kgStore) {
537
+ if (kgStore && failedCount === 0) {
254
538
  try {
255
539
  await kgStore.deleteByScope(scope);
256
540
  kgScopeCleared = true;
@@ -44,6 +44,7 @@ import { homedir } from "node:os";
44
44
  import { join, isAbsolute } from "node:path";
45
45
  import { execFile } from "node:child_process";
46
46
  import * as envConfig from "./env-config.js";
47
+ import { HTTP_WRITE_IDLE_TIMEOUT_S } from "./store-write-budget.js";
47
48
 
48
49
  const HOME = homedir();
49
50
  const PORT = clampInt(envConfig.gatewayPortRaw(), 8791, 1, 65535);
@@ -195,6 +196,9 @@ function fileSearch(query: string, scope: string, limit: number): Promise<FileHi
195
196
  // ── server ───────────────────────────────────────────────────────────────────
196
197
  const server = Bun.serve({
197
198
  port: PORT,
199
+ // Writes can wait up to 30 s for store-write before they start; Bun's 10 s default would drop
200
+ // the connection while the handler still commits.
201
+ idleTimeout: HTTP_WRITE_IDLE_TIMEOUT_S,
198
202
  hostname: HOST,
199
203
  async fetch(request) {
200
204
  const id = randomUUID().slice(0, 8);