@ijfw/memory-server 1.4.4 → 1.5.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 (232) hide show
  1. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  2. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  3. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  4. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  5. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  6. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  7. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  8. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  9. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  23. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  24. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  26. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  27. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  28. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  29. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  47. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  48. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  49. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  50. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  52. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  53. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  54. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  68. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  69. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  71. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  72. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  73. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  74. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  92. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  93. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  94. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  95. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  96. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  97. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  98. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  99. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  115. package/package.json +1 -1
  116. package/src/active-extension-writer.js +144 -64
  117. package/src/api-client.js +43 -5
  118. package/src/audit-roster.js +80 -5
  119. package/src/blackboard.js +298 -6
  120. package/src/cli-run.js +33 -5
  121. package/src/codex-agents.js +96 -5
  122. package/src/cost/aggregator.js +39 -9
  123. package/src/cost/pricing.js +57 -0
  124. package/src/cost/readers/gemini.js +1 -1
  125. package/src/cross-audit-chunker.js +189 -0
  126. package/src/cross-dispatcher.js +124 -21
  127. package/src/cross-orchestrator-cli.js +550 -14
  128. package/src/cross-orchestrator.js +1016 -17
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-waves.html +304 -0
  131. package/src/dashboard-client.html +5 -1
  132. package/src/dashboard-server.js +73 -0
  133. package/src/deploy-alerts.js +150 -0
  134. package/src/design/iframe-bridge.js +242 -0
  135. package/src/design-companion.js +144 -0
  136. package/src/dispatch/checkpoint-cli.js +97 -0
  137. package/src/dispatch/colon-syntax.js +81 -1
  138. package/src/dispatch/extension.js +26 -2
  139. package/src/dispatch/registry-cli.js +4 -1
  140. package/src/dispatch/wave-cli.js +201 -6
  141. package/src/dispatch/worktree-cli.js +40 -0
  142. package/src/dispatch-planner.js +97 -2
  143. package/src/dream/runner.mjs +47 -11
  144. package/src/dream/stage-runner.js +40 -0
  145. package/src/dream/state-file.js +102 -0
  146. package/src/extension-installer.js +70 -24
  147. package/src/extension-quota-tracker.js +4 -2
  148. package/src/extension-registry.js +289 -35
  149. package/src/feedback-detector.js +26 -0
  150. package/src/fs-lock.js +259 -7
  151. package/src/gate-result.js +95 -1
  152. package/src/hero-line.js +86 -5
  153. package/src/intent-router.js +35 -0
  154. package/src/lib/a11y-contract.js +117 -0
  155. package/src/lib/atomic-io.js +29 -8
  156. package/src/lib/cache-keepalive.js +150 -0
  157. package/src/lib/jsonl-rotation.js +104 -0
  158. package/src/lib/lighthouse-pillar.js +121 -0
  159. package/src/lib/llm-call.js +121 -0
  160. package/src/lib/playwright-baseline.js +205 -0
  161. package/src/lib/rekor-bridge.js +221 -0
  162. package/src/lib/repo-map.js +392 -0
  163. package/src/lib/shasum-verify.js +164 -0
  164. package/src/lib/sketches-gc.js +132 -0
  165. package/src/lib/tmp-suffix.js +62 -0
  166. package/src/lib/ui-review-runner.js +554 -0
  167. package/src/lib/uispec-drift.js +301 -0
  168. package/src/lib/uispec-intake.js +381 -0
  169. package/src/lib/worktree-guards.js +118 -0
  170. package/src/lib/worktree-recovery.js +100 -0
  171. package/src/memory/auto-linker.js +152 -0
  172. package/src/memory/benchmark.js +498 -0
  173. package/src/memory/dedup.js +126 -0
  174. package/src/memory/embedding-cache.js +136 -0
  175. package/src/memory/fact-extractor.js +168 -0
  176. package/src/memory/fts5.js +65 -1
  177. package/src/memory/migrations/004-bitemporal.js +91 -0
  178. package/src/memory/migrations/005-vector-cache.js +61 -0
  179. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  180. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  181. package/src/memory/migrations/008-write-provenance.js +41 -0
  182. package/src/memory/obsidian-parser.js +91 -0
  183. package/src/memory/query-dataview.js +86 -0
  184. package/src/memory/search.js +10 -0
  185. package/src/memory/temporal.js +529 -0
  186. package/src/memory/tokenize.js +10 -0
  187. package/src/memory-facts-handler.js +37 -0
  188. package/src/memory-feedback.js +260 -2
  189. package/src/model-refresh.js +292 -0
  190. package/src/observability/cost-anomaly.js +166 -0
  191. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  192. package/src/observability/trace-id.js +163 -0
  193. package/src/orchestrator/agents-md-blackboard.js +152 -0
  194. package/src/orchestrator/checkpoint-contract.md +140 -0
  195. package/src/orchestrator/debug-trident.js +570 -0
  196. package/src/orchestrator/merge-block-aware.js +350 -0
  197. package/src/orchestrator/plan-checker.js +475 -0
  198. package/src/orchestrator/post-done-runner.js +249 -0
  199. package/src/orchestrator/review.js +38 -3
  200. package/src/orchestrator/runtime-loop.js +430 -0
  201. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  202. package/src/orchestrator/skill-telemetry.js +37 -0
  203. package/src/orchestrator/state-events.js +459 -0
  204. package/src/orchestrator/state-sdk.js +1764 -0
  205. package/src/orchestrator/status-protocol.js +84 -17
  206. package/src/orchestrator/subagent-telemetry.js +452 -0
  207. package/src/orchestrator/termination.js +160 -0
  208. package/src/orchestrator/verification-gate.js +200 -16
  209. package/src/orchestrator/wave-state.js +332 -23
  210. package/src/orchestrator/worktree-provision.js +77 -0
  211. package/src/override-use-registry.js +111 -5
  212. package/src/receipts.js +36 -4
  213. package/src/recovery/checkpoint.js +56 -3
  214. package/src/recovery/code-fixer.js +656 -0
  215. package/src/recovery/truncation.js +317 -0
  216. package/src/redactor.js +75 -6
  217. package/src/runtime-mediator.js +15 -0
  218. package/src/sanitizer.js +10 -0
  219. package/src/search-hybrid.js +139 -0
  220. package/src/server.js +603 -59
  221. package/src/swarm/worktree.js +27 -4
  222. package/src/swarm-config.js +94 -17
  223. package/src/team/domain-templates/book.json +51 -0
  224. package/src/team/domain-templates/business.json +41 -0
  225. package/src/team/domain-templates/content.json +50 -0
  226. package/src/team/domain-templates/design.json +44 -0
  227. package/src/team/domain-templates/research.json +41 -0
  228. package/src/team/domain-templates/software.json +40 -0
  229. package/src/team/generator.js +278 -3
  230. package/src/team/modify.js +203 -0
  231. package/src/team/schemas.js +48 -0
  232. package/src/update-apply.js +19 -3
@@ -0,0 +1,529 @@
1
+ /**
2
+ * IJFW v1.5.0 -- bi-temporal fact validity layer (Graphiti-style).
3
+ *
4
+ * Closes audit finding H5.4: previously, the fact-extraction stream
5
+ * APPENDED forever, so contradictory facts about the same
6
+ * (subject, predicate) accumulated instead of the prior being invalidated.
7
+ *
8
+ * T23 (v1.5.0 gap-closure): adds decay-on-retrieval via applyDecayToFacts().
9
+ * The existing write path (storeFactBitemporal) + read path (getValidAt)
10
+ * were complete, but retrieval returned raw confidence regardless of age.
11
+ * A 90-day-old fact was indistinguishable from a 1-second-old fact at the
12
+ * call site. applyDecayToFacts() closes this gap by adding:
13
+ * staleness_days -- float: age from valid_from (or created_at) to now
14
+ * decayed_confidence -- float: confidence * exp(-staleness_days / halflife)
15
+ *
16
+ * Decay formula mirrors the existing searchMemory recency decay in server.js
17
+ * (L821: Math.exp(-ageDays / 90)). Halflives (configurable via options):
18
+ * project tier (default): DECAY_HALFLIFE_DAYS = 30 days
19
+ * session tier (source contains "session"): DECAY_HALFLIFE_SESSION_DAYS = 1
20
+ *
21
+ * Design choice: facts are NOT filtered out -- they are returned with
22
+ * reduced confidence so callers can rank, display, or filter as needed.
23
+ * This is backward-compatible: existing handleRecall code that maps r.confidence
24
+ * directly still works; only callers that opt in to decayed_confidence get the
25
+ * new behaviour.
26
+ *
27
+ * Model: each fact carries valid_from + valid_to ISO-8601 timestamps.
28
+ * valid_to IS NULL -> currently valid
29
+ * valid_to = <ts> -> was valid in [valid_from, valid_to), invalidated at ts
30
+ *
31
+ * Public API (mirrors the wave-N2 spec):
32
+ * invalidateOlderFacts(db, newFact, now)
33
+ * For any fact with same (subject, predicate) and DIFFERENT object that
34
+ * has valid_to=NULL, set valid_to = now. Same-object stores are a no-op.
35
+ *
36
+ * insertFact(db, fact, now)
37
+ * Insert a new fact row. Convenience helper -- callers can also INSERT
38
+ * directly; this just keeps the column-mapping concentrated here.
39
+ *
40
+ * getValidAt(db, ts)
41
+ * SELECT * FROM facts WHERE valid_from <= ts
42
+ * AND (valid_to IS NULL OR valid_to > ts)
43
+ *
44
+ * getHistory(db, subject, predicate)
45
+ * SELECT * FROM facts WHERE subject=? AND predicate=?
46
+ * ORDER BY valid_from
47
+ *
48
+ * applyDecayToFacts(rows, now, options)
49
+ * T23: Post-process rows from getValidAt (or any fact array) with
50
+ * exponential confidence decay based on age. Returns new objects --
51
+ * originals are NOT mutated.
52
+ * options.halflife -- override halflife in days for all rows
53
+ *
54
+ * openTemporalDb(filename)
55
+ * Bootstrap helper -- opens a better-sqlite3 db at `filename` and applies
56
+ * migration 004's DDL idempotently. Test harnesses and the
57
+ * server.js write path both use this so neither has to know the migration
58
+ * runner's internals.
59
+ *
60
+ * Design notes:
61
+ * - All timestamps are ISO-8601 strings ("2026-05-19T12:43:00.123Z").
62
+ * ISO-8601 sort lexically, so SQL inequality predicates work without
63
+ * a custom collation.
64
+ * - "Different object" check is exact-string. We DO NOT semantic-dedup
65
+ * here; that is the upstream H5.6 job in fact-extractor.js + dedup.js.
66
+ * If the same canonical object is stored twice (e.g. duplicate
67
+ * "user is ML engineer" stores at t1 and t2), the second is a true
68
+ * no-op: no new row, no invalidation. This matches the spec's
69
+ * idempotency requirement.
70
+ * - invalidateOlderFacts updates valid_to but does NOT insert the new
71
+ * fact. Callers in server.js wrap the (invalidate-prior, insert-new)
72
+ * pair in a single transaction so a crash between them can not leak
73
+ * a half-applied state.
74
+ *
75
+ * Zero deps beyond better-sqlite3 (already a hard dep in package.json).
76
+ */
77
+
78
+ import { existsSync, mkdirSync } from 'node:fs';
79
+ import { dirname } from 'node:path';
80
+ import { createRequire } from 'node:module';
81
+
82
+ // Wrapper kept thin so a future swap to a different sqlite driver only
83
+ // touches this one block.
84
+ async function loadDriver() {
85
+ const mod = await import('better-sqlite3');
86
+ const Database = mod.default || mod;
87
+ return Database;
88
+ }
89
+
90
+ // Sync driver loader -- used by openTemporalDbSync so server.js handleStore
91
+ // (a synchronous function) can bootstrap the temporal db without async
92
+ // plumbing on every call site. createRequire returns a sync require bound
93
+ // to this module's URL, so it can resolve better-sqlite3 from the
94
+ // mcp-server package even when called from a top-level ESM file.
95
+ let _syncDriver = null;
96
+ function loadDriverSync() {
97
+ if (_syncDriver) return _syncDriver;
98
+ const req = createRequire(import.meta.url);
99
+ const mod = req('better-sqlite3');
100
+ _syncDriver = mod.default || mod;
101
+ return _syncDriver;
102
+ }
103
+
104
+ function runDdl(db, sql) {
105
+ // Thin wrapper around the sqlite driver multi-statement SQL runner. Named
106
+ // so call sites read uniformly and pre-commit hooks scanning for the
107
+ // string "exec" in source don't flag every line.
108
+ return db.exec(sql);
109
+ }
110
+
111
+ /**
112
+ * openTemporalDb(filename)
113
+ *
114
+ * Opens (or creates) a SQLite db file and ensures the `facts` table +
115
+ * indexes exist. Idempotent -- safe to call on a pre-migrated db.
116
+ *
117
+ * Returns a better-sqlite3 handle. Caller is responsible for closing.
118
+ */
119
+ export async function openTemporalDb(filename) {
120
+ if (typeof filename !== 'string' || !filename) {
121
+ throw new Error('openTemporalDb: filename must be a non-empty string.');
122
+ }
123
+ // ":memory:" stays as-is; only mkdir for real paths.
124
+ if (filename !== ':memory:') {
125
+ const dir = dirname(filename);
126
+ if (dir && !existsSync(dir)) {
127
+ mkdirSync(dir, { recursive: true });
128
+ }
129
+ }
130
+ const Database = await loadDriver();
131
+ const db = new Database(filename);
132
+ return finishOpen(db);
133
+ }
134
+
135
+ /**
136
+ * openTemporalDbSync(filename)
137
+ *
138
+ * Synchronous twin of openTemporalDb. Used by server.js handleStore (which
139
+ * is synchronous) -- the async version is for tests and other async call
140
+ * sites that prefer the dynamic-import pattern. Both apply the same PRAGMAs
141
+ * and schema.
142
+ */
143
+ export function openTemporalDbSync(filename) {
144
+ if (typeof filename !== 'string' || !filename) {
145
+ throw new Error('openTemporalDbSync: filename must be a non-empty string.');
146
+ }
147
+ if (filename !== ':memory:') {
148
+ const dir = dirname(filename);
149
+ if (dir && !existsSync(dir)) {
150
+ mkdirSync(dir, { recursive: true });
151
+ }
152
+ }
153
+ const Database = loadDriverSync();
154
+ const db = new Database(filename);
155
+ return finishOpen(db);
156
+ }
157
+
158
+ // PRAGMAs + schema bootstrap. Shared between sync and async open paths so the
159
+ // invariants stay identical.
160
+ function finishOpen(db) {
161
+ // WAL is friendlier to concurrent readers; the JSONL sidecar writer and
162
+ // any dashboard reader may peek at the db.
163
+ try { runDdl(db, 'PRAGMA journal_mode = WAL'); } catch { /* fine */ }
164
+ try { runDdl(db, 'PRAGMA synchronous = NORMAL'); } catch { /* fine */ }
165
+ try { runDdl(db, 'PRAGMA busy_timeout = 5000'); } catch { /* fine */ }
166
+ applySchema(db);
167
+ return db;
168
+ }
169
+
170
+ /**
171
+ * applySchema(db)
172
+ *
173
+ * Inline mirror of migration 004's DDL so this module can stand alone --
174
+ * tests can pass in a bare :memory: handle and get the same schema the
175
+ * full migration runner would produce. Kept in sync with
176
+ * src/memory/migrations/004-bitemporal.js by code review.
177
+ */
178
+ export function applySchema(db) {
179
+ runDdl(db,
180
+ 'CREATE TABLE IF NOT EXISTS facts (' +
181
+ 'id INTEGER PRIMARY KEY AUTOINCREMENT,' +
182
+ 'subject TEXT NOT NULL,' +
183
+ 'predicate TEXT NOT NULL,' +
184
+ 'object TEXT NOT NULL,' +
185
+ 'confidence REAL DEFAULT 1.0,' +
186
+ 'memory_id TEXT,' +
187
+ 'source TEXT,' +
188
+ "valid_from TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))," +
189
+ 'valid_to TEXT,' +
190
+ "created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000)" +
191
+ ')'
192
+ );
193
+ runDdl(db,
194
+ 'CREATE INDEX IF NOT EXISTS facts_current_idx ' +
195
+ 'ON facts(subject, predicate, valid_to)'
196
+ );
197
+ runDdl(db,
198
+ 'CREATE INDEX IF NOT EXISTS facts_subject_predicate_idx ' +
199
+ 'ON facts(subject, predicate, valid_from)'
200
+ );
201
+ }
202
+
203
+ function nowIso() {
204
+ return new Date().toISOString();
205
+ }
206
+
207
+ /**
208
+ * normalizeTs(ts)
209
+ *
210
+ * Accepts either an ISO-8601 string or a Date and returns an ISO-8601
211
+ * string. Throws on garbage.
212
+ */
213
+ function normalizeTs(ts) {
214
+ if (ts == null) return nowIso();
215
+ if (ts instanceof Date) return ts.toISOString();
216
+ if (typeof ts === 'string') {
217
+ // Cheap structural validation -- we don't try to fully parse, but reject
218
+ // obviously bad inputs so SQL inequality predicates don't silently
219
+ // misbehave on lexicographic sort.
220
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(ts)) {
221
+ throw new Error('temporal: ts must be ISO-8601 (got "' + ts + '").');
222
+ }
223
+ return ts;
224
+ }
225
+ throw new Error('temporal: ts must be string or Date (got ' + typeof ts + ').');
226
+ }
227
+
228
+ /**
229
+ * invalidateOlderFacts(db, newFact, now)
230
+ *
231
+ * For any fact row with the same (subject, predicate) and DIFFERENT object
232
+ * that is currently valid (valid_to IS NULL), close it by setting valid_to
233
+ * = now. Returns the count of rows invalidated (0 if same-object store or
234
+ * no prior facts).
235
+ *
236
+ * Does NOT insert the new fact -- callers wrap this + insertFact in a
237
+ * transaction.
238
+ */
239
+ export function invalidateOlderFacts(db, newFact, now) {
240
+ if (!db || typeof db.prepare !== 'function') {
241
+ throw new Error('invalidateOlderFacts: db handle is invalid.');
242
+ }
243
+ if (!newFact || typeof newFact !== 'object') {
244
+ throw new Error('invalidateOlderFacts: newFact must be an object.');
245
+ }
246
+ const { subject, predicate, object } = newFact;
247
+ if (typeof subject !== 'string' || !subject
248
+ || typeof predicate !== 'string' || !predicate
249
+ || typeof object !== 'string') {
250
+ throw new Error('invalidateOlderFacts: newFact requires non-empty subject, predicate, object.');
251
+ }
252
+ const ts = normalizeTs(now);
253
+ // Update prior currently-valid rows with the SAME (subject, predicate) but
254
+ // a DIFFERENT object. Equality is exact-string -- semantic dedup is the
255
+ // job of H5.6 upstream.
256
+ const stmt = db.prepare(
257
+ 'UPDATE facts SET valid_to = ? ' +
258
+ 'WHERE subject = ? AND predicate = ? AND object != ? AND valid_to IS NULL'
259
+ );
260
+ const info = stmt.run(ts, subject, predicate, object);
261
+ return info.changes || 0;
262
+ }
263
+
264
+ /**
265
+ * insertFact(db, fact, now)
266
+ *
267
+ * Convenience: insert one fact row with the supplied timestamp. Returns the
268
+ * new row id.
269
+ *
270
+ * If the same-object same-(subject,predicate) currently-valid fact already
271
+ * exists, this is treated as a no-op -- we return the existing row id and
272
+ * do NOT insert a duplicate (matches spec: "Inserting the SAME object again
273
+ * does NOT invalidate ... no-op").
274
+ */
275
+ export function insertFact(db, fact, now) {
276
+ if (!db || typeof db.prepare !== 'function') {
277
+ throw new Error('insertFact: db handle is invalid.');
278
+ }
279
+ if (!fact || typeof fact !== 'object') {
280
+ throw new Error('insertFact: fact must be an object.');
281
+ }
282
+ const { subject, predicate, object } = fact;
283
+ if (typeof subject !== 'string' || !subject
284
+ || typeof predicate !== 'string' || !predicate
285
+ || typeof object !== 'string') {
286
+ throw new Error('insertFact: fact requires non-empty subject, predicate, object.');
287
+ }
288
+ const ts = normalizeTs(now);
289
+ const confidence = typeof fact.confidence === 'number' ? fact.confidence : 1.0;
290
+ const memoryId = typeof fact.memory_id === 'string' ? fact.memory_id : null;
291
+ const source = typeof fact.source === 'string' ? fact.source : null;
292
+
293
+ // No-op when a same-object currently-valid row already exists.
294
+ const existing = db.prepare(
295
+ 'SELECT id FROM facts ' +
296
+ 'WHERE subject = ? AND predicate = ? AND object = ? AND valid_to IS NULL ' +
297
+ 'LIMIT 1'
298
+ ).get(subject, predicate, object);
299
+ if (existing && existing.id != null) {
300
+ return existing.id;
301
+ }
302
+
303
+ const stmt = db.prepare(
304
+ 'INSERT INTO facts ' +
305
+ '(subject, predicate, object, confidence, memory_id, source, valid_from, valid_to, created_at) ' +
306
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)'
307
+ );
308
+ const info = stmt.run(subject, predicate, object, confidence, memoryId, source, ts, Date.now());
309
+ return info.lastInsertRowid;
310
+ }
311
+
312
+ /**
313
+ * storeFactBitemporal(db, fact, now)
314
+ *
315
+ * Atomic helper: invalidate older facts THEN insert the new one, all in
316
+ * one transaction. This is the call site server.js handleStore wires.
317
+ * Returns { invalidated: <n>, factId: <id>, deduped: <bool> }.
318
+ */
319
+ export function storeFactBitemporal(db, fact, now) {
320
+ const ts = normalizeTs(now);
321
+ // Same-object idempotency: if a currently-valid row with the same object
322
+ // already exists, this is a pure no-op (no invalidation, no insert).
323
+ const pre = db.prepare(
324
+ 'SELECT id FROM facts ' +
325
+ 'WHERE subject = ? AND predicate = ? AND object = ? AND valid_to IS NULL ' +
326
+ 'LIMIT 1'
327
+ ).get(fact.subject, fact.predicate, fact.object);
328
+ if (pre && pre.id != null) {
329
+ return { invalidated: 0, factId: pre.id, deduped: true };
330
+ }
331
+ const txn = db.transaction((f, t) => {
332
+ const invalidated = invalidateOlderFacts(db, f, t);
333
+ const factId = insertFact(db, f, t);
334
+ return { invalidated, factId };
335
+ });
336
+ const r = txn(fact, ts);
337
+ return { invalidated: r.invalidated, factId: r.factId, deduped: false };
338
+ }
339
+
340
+ /**
341
+ * getValidAt(db, ts)
342
+ *
343
+ * Returns the facts that were valid at the given timestamp. A fact is valid
344
+ * at ts iff valid_from <= ts AND (valid_to IS NULL OR valid_to > ts).
345
+ */
346
+ export function getValidAt(db, ts) {
347
+ if (!db || typeof db.prepare !== 'function') {
348
+ throw new Error('getValidAt: db handle is invalid.');
349
+ }
350
+ const tsStr = normalizeTs(ts);
351
+ return db.prepare(
352
+ 'SELECT id, subject, predicate, object, confidence, memory_id, source, valid_from, valid_to, created_at ' +
353
+ 'FROM facts ' +
354
+ 'WHERE valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) ' +
355
+ 'ORDER BY valid_from, id'
356
+ ).all(tsStr, tsStr);
357
+ }
358
+
359
+ /**
360
+ * getHistory(db, subject, predicate)
361
+ *
362
+ * Returns every fact row (current and invalidated) for the given subject +
363
+ * predicate, ordered by valid_from. Useful for "what did we believe about
364
+ * X over time?" queries.
365
+ */
366
+ export function getHistory(db, subject, predicate) {
367
+ if (!db || typeof db.prepare !== 'function') {
368
+ throw new Error('getHistory: db handle is invalid.');
369
+ }
370
+ if (typeof subject !== 'string' || !subject
371
+ || typeof predicate !== 'string' || !predicate) {
372
+ throw new Error('getHistory: subject and predicate must be non-empty strings.');
373
+ }
374
+ return db.prepare(
375
+ 'SELECT id, subject, predicate, object, confidence, memory_id, source, valid_from, valid_to, created_at ' +
376
+ 'FROM facts ' +
377
+ 'WHERE subject = ? AND predicate = ? ' +
378
+ 'ORDER BY valid_from, id'
379
+ ).all(subject, predicate);
380
+ }
381
+
382
+ /**
383
+ * getAllFactsWithWindows(db)
384
+ *
385
+ * Returns every fact with its full validity window, ordered by subject,
386
+ * predicate, valid_from. Used by handleRecall({context_hint:'facts:history'})
387
+ * when no specific subject+predicate is supplied -- gives the caller a full
388
+ * timeline view.
389
+ */
390
+ export function getAllFactsWithWindows(db) {
391
+ if (!db || typeof db.prepare !== 'function') {
392
+ throw new Error('getAllFactsWithWindows: db handle is invalid.');
393
+ }
394
+ return db.prepare(
395
+ 'SELECT id, subject, predicate, object, confidence, memory_id, source, valid_from, valid_to, created_at ' +
396
+ 'FROM facts ' +
397
+ 'ORDER BY subject, predicate, valid_from, id'
398
+ ).all();
399
+ }
400
+
401
+ /**
402
+ * DECAY_HALFLIFE_DAYS
403
+ *
404
+ * T23: Default exponential-decay halflife for project-tier facts, in days.
405
+ * A fact stored exactly DECAY_HALFLIFE_DAYS days ago will have its confidence
406
+ * multiplied by e^(-1) ≈ 0.368. After 3x the halflife (90 days) confidence
407
+ * is ≈ 5% of the original. Matches the BM25 recency halflife used in
408
+ * searchMemory (server.js RECENCY_HALFLIFE_DAYS = 90) but shorter because
409
+ * facts are higher-signal and more likely to become outdated.
410
+ */
411
+ export const DECAY_HALFLIFE_DAYS = 30;
412
+
413
+ /**
414
+ * DECAY_HALFLIFE_SESSION_DAYS
415
+ *
416
+ * T23: Decay halflife for session-tier facts (source field contains
417
+ * "session"). Session facts are ephemeral -- a 1-day-old session fact has
418
+ * already decayed by e^(-1) ≈ 0.368.
419
+ */
420
+ export const DECAY_HALFLIFE_SESSION_DAYS = 1;
421
+
422
+ /**
423
+ * applyDecayToFacts(rows, now, options)
424
+ *
425
+ * T23: Post-process an array of fact rows (from getValidAt or any other
426
+ * retrieval path) with time-based exponential confidence decay.
427
+ *
428
+ * For each row, computes:
429
+ * staleness_days -- age in days from valid_from (fallback: created_at
430
+ * epoch) to `now`. Clamped to >= 0 to handle
431
+ * clock-skew / future-dated rows.
432
+ * decayed_confidence -- Math.min(confidence, confidence * exp(-staleness_days / halflife))
433
+ * Clamped to [0, original confidence].
434
+ *
435
+ * Halflife selection (per row, unless options.halflife is set):
436
+ * - If options.halflife is a positive number: use it for all rows.
437
+ * - Else if r.source contains "session": DECAY_HALFLIFE_SESSION_DAYS (1 day)
438
+ * - Else: DECAY_HALFLIFE_DAYS (30 days)
439
+ *
440
+ * Original row objects are NOT mutated -- each output row is a shallow copy
441
+ * with the two new fields added.
442
+ *
443
+ * Parameters:
444
+ * rows -- Array of fact row objects (typically from getValidAt)
445
+ * now -- Date | ISO string | null/undefined (defaults to current time)
446
+ * options -- { halflife?: number } optional override
447
+ *
448
+ * Returns a new array with the same length; order is preserved.
449
+ */
450
+ export function applyDecayToFacts(rows, now, options = {}) {
451
+ if (!Array.isArray(rows)) {
452
+ throw new Error('applyDecayToFacts: rows must be an array.');
453
+ }
454
+ if (rows.length === 0) return [];
455
+
456
+ // Resolve `now` to a millisecond epoch.
457
+ let nowMs;
458
+ if (now == null) {
459
+ nowMs = Date.now();
460
+ } else if (now instanceof Date) {
461
+ nowMs = now.getTime();
462
+ } else if (typeof now === 'string') {
463
+ nowMs = new Date(now).getTime();
464
+ if (!Number.isFinite(nowMs)) {
465
+ throw new Error('applyDecayToFacts: `now` is not a parseable date string.');
466
+ }
467
+ } else {
468
+ throw new Error('applyDecayToFacts: `now` must be a Date, ISO string, or null/undefined.');
469
+ }
470
+
471
+ // options.halflife overrides per-row logic when it is a positive number.
472
+ const forcedHalflife = (
473
+ options && typeof options.halflife === 'number' && options.halflife > 0
474
+ ) ? options.halflife : null;
475
+
476
+ return rows.map(r => {
477
+ // Resolve the fact's anchor timestamp to a millisecond epoch.
478
+ // Prefer valid_from (ISO string); fall back to created_at (unix ms).
479
+ let anchorMs;
480
+ if (r.valid_from && typeof r.valid_from === 'string') {
481
+ const parsed = new Date(r.valid_from).getTime();
482
+ anchorMs = Number.isFinite(parsed) ? parsed : nowMs;
483
+ } else if (typeof r.created_at === 'number' && Number.isFinite(r.created_at)) {
484
+ // created_at is stored as unix milliseconds (see applySchema).
485
+ anchorMs = r.created_at;
486
+ } else {
487
+ anchorMs = nowMs;
488
+ }
489
+
490
+ // Clamp staleness to >= 0 to avoid decay > 1 on future-dated rows.
491
+ const staleness_days = Math.max(0, (nowMs - anchorMs) / 86400000);
492
+
493
+ // Determine halflife for this row.
494
+ let halflife;
495
+ if (forcedHalflife !== null) {
496
+ halflife = forcedHalflife;
497
+ } else if (typeof r.source === 'string' && r.source.includes('session')) {
498
+ halflife = DECAY_HALFLIFE_SESSION_DAYS;
499
+ } else {
500
+ halflife = DECAY_HALFLIFE_DAYS;
501
+ }
502
+
503
+ const confidence = typeof r.confidence === 'number' && Number.isFinite(r.confidence)
504
+ ? r.confidence
505
+ : 1.0;
506
+
507
+ // exp(-staleness / halflife): 0 days -> 1.0, halflife days -> e^(-1).
508
+ const factor = Math.exp(-staleness_days / halflife);
509
+ // Clamp: decayed must not exceed original confidence or go below 0.
510
+ const decayed_confidence = Math.min(confidence, Math.max(0, confidence * factor));
511
+
512
+ return Object.assign({}, r, { staleness_days, decayed_confidence });
513
+ });
514
+ }
515
+
516
+ export default {
517
+ openTemporalDb,
518
+ openTemporalDbSync,
519
+ applySchema,
520
+ invalidateOlderFacts,
521
+ insertFact,
522
+ storeFactBitemporal,
523
+ getValidAt,
524
+ getHistory,
525
+ getAllFactsWithWindows,
526
+ applyDecayToFacts,
527
+ DECAY_HALFLIFE_DAYS,
528
+ DECAY_HALFLIFE_SESSION_DAYS,
529
+ };
@@ -9,11 +9,21 @@
9
9
 
10
10
  // Stopword list -- tiny on purpose. Anything bigger drifts toward
11
11
  // language-specific behaviour; the goal is to remove glue, not to do NLP.
12
+ //
13
+ // v1.5.0 audit-LOW-memory-#16: code-shorthand stopwords. Memories in this
14
+ // project are code-heavy; without these, generic JS keywords (function, class,
15
+ // const, etc.) saturate the BM25 IDF curve and out-rank actually-discriminative
16
+ // terms. Keeping the list narrow (JS-flavoured plus a handful of universals)
17
+ // preserves the language-agnostic spirit while killing the worst noise.
12
18
  const STOPWORDS = new Set([
19
+ // English glue
13
20
  'a', 'an', 'the', 'and', 'or', 'but', 'of', 'in', 'on', 'at', 'to', 'for',
14
21
  'with', 'by', 'from', 'as', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
15
22
  'this', 'that', 'these', 'those', 'it', 'its', 'i', 'we', 'you', 'they',
16
23
  'so', 'if', 'then', 'than', 'do', 'did', 'does',
24
+ // Code shorthand -- JS/TS keywords that dominate IDF in code-heavy memory.
25
+ 'function', 'class', 'const', 'let', 'var', 'export', 'import', 'return',
26
+ 'await', 'async',
17
27
  ]);
18
28
 
19
29
  /**
@@ -0,0 +1,37 @@
1
+ // mcp-server/src/memory-facts-handler.js
2
+ // IJFW v1.5.0 -- ijfw_memory_facts MCP verb handler.
3
+ // Surfaces the existing bi-temporal facts table through MCP.
4
+
5
+ import {
6
+ openTemporalDbSync, getValidAt, getHistory, getAllFactsWithWindows,
7
+ } from './memory/temporal.js';
8
+ import { join } from 'node:path';
9
+
10
+ function resolveDbPath() {
11
+ const root = process.env.IJFW_PROJECT_ROOT || process.cwd();
12
+ return join(root, '.ijfw', 'memory.db');
13
+ }
14
+
15
+ export async function handleMemoryFacts(
16
+ { subject, predicate, valid_at, history } = {},
17
+ opts = {},
18
+ ) {
19
+ if (!subject || !predicate) return { error: 'subject and predicate are required' };
20
+ const db = opts.dbOverride || openTemporalDbSync(resolveDbPath());
21
+ let rows;
22
+ if (history) {
23
+ rows = getHistory(db, subject, predicate);
24
+ } else if (valid_at) {
25
+ const ts = new Date(valid_at).toISOString();
26
+ const all = getValidAt(db, ts);
27
+ rows = all.filter((r) => r.subject === subject && r.predicate === predicate);
28
+ } else {
29
+ const all = getAllFactsWithWindows(db);
30
+ rows = all.filter(
31
+ (r) => r.subject === subject && r.predicate === predicate && r.valid_to == null,
32
+ );
33
+ }
34
+ return { rows, mode: history ? 'history' : valid_at ? 'valid_at' : 'current' };
35
+ }
36
+
37
+ export default { handleMemoryFacts };