@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
package/src/server.js CHANGED
@@ -33,9 +33,28 @@ import { checkPrompt } from './prompt-check.js';
33
33
  import { applyCaps, CAP_CONTENT } from './caps.js';
34
34
  import { ensureSchemaHeader, SCHEMA_HEADER } from './schema.js';
35
35
  import { searchCorpus } from './search-bm25.js';
36
+ // r17 (cold-tier wire-up): hybrid BM25+vector ranking when IJFW_VECTORS=on AND
37
+ // @xenova/transformers is installed. Silent BM25-only fallback otherwise.
38
+ // Lives in its own module so tests can drive the rerank helper without
39
+ // importing server.js (whose stdio bootstrap would hang the test runner).
40
+ import { maybeRerankWithVectors } from './search-hybrid.js';
36
41
  import { crossProjectSearch } from './cross-project-search.js';
37
42
  // R2-E -- single source of truth for markdown/HTML/control-char defanger.
38
43
  import { sanitizeContent } from './sanitizer.js';
44
+ // H5.5 / H5.6 — ingest-time fact extraction + semantic dedup. Closes
45
+ // memory-engine.md competitor gaps (mem0/Zep extract facts; Graphiti dedups).
46
+ // Both are pure-JS, zero-LLM, deterministic.
47
+ import { extractFacts, factToJsonl } from './memory/fact-extractor.js';
48
+ import { findNearDuplicate, readDedupConfig } from './memory/dedup.js';
49
+ // v1.5.0 audit H5.4 — Graphiti-style bi-temporal validity. Lets storing a
50
+ // contradictory fact close the prior's valid_to instead of accumulating.
51
+ import {
52
+ openTemporalDbSync,
53
+ storeFactBitemporal,
54
+ getValidAt as temporalGetValidAt,
55
+ getHistory as temporalGetHistory,
56
+ getAllFactsWithWindows as temporalGetAllFactsWithWindows,
57
+ } from './memory/temporal.js';
39
58
  // 1.1.6: update tools (cap 8 -> 10) -- token-issuance + OOB terminal confirm.
40
59
  // Per CLAUDE.md policy: future growth triggers retirement review, not raise.
41
60
  import { ijfwUpdateCheck, TOOL_DEF as UPDATE_CHECK_TOOL } from './update-check.js';
@@ -394,8 +413,11 @@ function readOr(filepath, fallback = '') {
394
413
  // --- Append helper (atomic for entries < PIPE_BUF; append-only growth) ---
395
414
  //
396
415
  // We rely on POSIX O_APPEND atomicity for entries under 4KB. Sanitized
397
- // entries are bounded at MAX_STORE_LENGTH=5000 chars, but the entry header
398
- // keeps each *line* well under 4KB after sanitization (single-line collapse).
416
+ // entries are bounded at MAX_STORE_LENGTH=4096 chars (CAP_CONTENT in caps.js),
417
+ // but the entry header keeps each *line* well under 4KB after sanitization
418
+ // (single-line collapse). Audit MED #8 / F-COR-1: doc-vs-code parity fix --
419
+ // the prior text said 5000, which had drifted from the actual cap.
420
+
399
421
  function appendLine(filepath, line) {
400
422
  try {
401
423
  if (!existsSync(filepath)) {
@@ -593,6 +615,105 @@ function getRecentJournalEntries(count = 5) {
593
615
  return entries.slice(-count).join('\n');
594
616
  }
595
617
 
618
+ // H5.6 — dedup needs `recents` shaped as { id, content } for findNearDuplicate.
619
+ // We synthesize id from the timestamp (project-journal entries are unique by ts
620
+ // down to ms; collisions would only happen on near-simultaneous writes which
621
+ // our atomic-append + fs flush ordering already serialise).
622
+ function getRecentMemoriesForDedup(limit = 50) {
623
+ const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
624
+ if (!journal) return [];
625
+ const lines = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
626
+ // Most-recent-last in the file → reverse so findNearDuplicate sees newest first.
627
+ const slice = lines.slice(-limit).reverse();
628
+ return slice.map(line => {
629
+ // Format: "- [<iso>] <body>" → { id: iso, content: body }
630
+ const m = line.match(/^- \[([^\]]+)\]\s*(.*)$/);
631
+ if (!m) return null;
632
+ return { id: m[1], content: m[2] };
633
+ }).filter(Boolean);
634
+ }
635
+
636
+ // H5.5 — sidecar file for structured facts (one JSON object per line).
637
+ // Append-only; consumed by handleRecall({context_hint:'facts'}).
638
+ const FACTS_FILE = join(MEMORY_DIR, 'facts.jsonl');
639
+
640
+ // Stable short id for joining a fact back to its journal entry. We don't have
641
+ // a uuid; the journal-line text itself + ts is unique enough for cross-ref.
642
+ function factMemoryIdFor(journalEntryText) {
643
+ return 'm-' + createHash('sha256')
644
+ .update(String(journalEntryText) + ':' + Date.now())
645
+ .digest('hex')
646
+ .slice(0, 10);
647
+ }
648
+
649
+ function appendFactsToSidecar(facts, meta) {
650
+ if (!Array.isArray(facts) || facts.length === 0) return { ok: true, written: 0 };
651
+ try {
652
+ const lines = facts.map(f => factToJsonl(f, meta)).join('\n') + '\n';
653
+ appendFileSync(FACTS_FILE, lines);
654
+ return { ok: true, written: facts.length };
655
+ } catch (err) {
656
+ // Non-fatal: facts are augmentation, not source-of-truth. Journal already
657
+ // captured the raw memory.
658
+ return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
659
+ }
660
+ }
661
+
662
+ // v1.5.0 audit H5.4 — sibling SQL store for bi-temporal facts. Lives next to
663
+ // facts.jsonl so the JSONL sidecar (append-only timeline log) and the SQL
664
+ // table (queryable point-in-time view) stay co-located. Lazy-opened on first
665
+ // use so a project that never stores a memory never pays the better-sqlite3
666
+ // load cost.
667
+ const FACTS_DB_FILE = join(MEMORY_DIR, 'facts.db');
668
+ let _factsDbHandle = null;
669
+ function getFactsDb() {
670
+ if (_factsDbHandle) return _factsDbHandle;
671
+ try {
672
+ _factsDbHandle = openTemporalDbSync(FACTS_DB_FILE);
673
+ return _factsDbHandle;
674
+ } catch (err) {
675
+ // Non-fatal. JSONL sidecar still gets written; we just lose the bi-temporal
676
+ // SQL view for this process. Surface via stderr so operators see the
677
+ // degradation but the user-facing store result still says "ok".
678
+ try {
679
+ process.stderr.write(`[ijfw temporal] facts.db unavailable (${err.code || err.message}); SQL fact view degraded\n`);
680
+ } catch { /* stderr may be detached */ }
681
+ return null;
682
+ }
683
+ }
684
+
685
+ // writeFactsBitemporal -- for each extracted fact, close any prior currently-
686
+ // valid fact with the same (subject, predicate) but different object, then
687
+ // insert the new fact. Wrapped in a per-fact transaction inside temporal.js's
688
+ // storeFactBitemporal helper. Best-effort: a SQL failure logs to stderr but
689
+ // never breaks the journal-or-JSONL path.
690
+ function writeFactsBitemporal(facts, meta) {
691
+ if (!Array.isArray(facts) || facts.length === 0) return { ok: true, written: 0, invalidated: 0 };
692
+ const db = getFactsDb();
693
+ if (!db) return { ok: false, code: 'ENOFACTSDB', written: 0, invalidated: 0 };
694
+ let written = 0;
695
+ let invalidated = 0;
696
+ for (const f of facts) {
697
+ try {
698
+ const r = storeFactBitemporal(db, {
699
+ subject: f.subject,
700
+ predicate: f.predicate,
701
+ object: f.object,
702
+ confidence: f.confidence,
703
+ memory_id: meta && meta.memory_id,
704
+ source: meta && meta.source,
705
+ }, meta && meta.ts);
706
+ invalidated += r.invalidated;
707
+ if (!r.deduped) written += 1;
708
+ } catch (err) {
709
+ try {
710
+ process.stderr.write(`[ijfw temporal] storeFactBitemporal failed: ${err.message}\n`);
711
+ } catch { /* stderr may be detached */ }
712
+ }
713
+ }
714
+ return { ok: true, written, invalidated };
715
+ }
716
+
596
717
  // --- Cross-project registry (Phase 3) ---
597
718
  //
598
719
  // Registry lines look like: <abs-path> | <sha256-12> | <first-seen-iso>
@@ -635,55 +756,81 @@ function readProjectMemory(projectPath) {
635
756
  };
636
757
  }
637
758
 
638
- function searchAcrossProjects(query, limit) {
639
- const queryLower = String(query).toLowerCase();
640
- const keywords = queryLower.split(/\s+/).filter(w => w.length > 2);
641
- if (keywords.length === 0) return [];
642
-
643
- const results = [];
644
- for (const entry of readRegistry()) {
645
- const tag = basename(entry.path);
646
- const mem = readProjectMemory(entry.path);
647
- for (const [src, content] of Object.entries(mem)) {
648
- if (!content) continue;
649
- const lines = content.split('\n');
650
- for (let i = 0; i < lines.length; i++) {
651
- const line = lines[i];
652
- if (line.trim().length === 0) continue;
653
- const score = keywords.filter(k => line.toLowerCase().includes(k)).length;
654
- if (score > 0) {
655
- results.push({
656
- source: `${src}@${tag}`,
657
- line: i + 1,
658
- content: `[project:${tag}] ${line.trim().substring(0, 200)}`,
659
- score
660
- });
661
- }
662
- }
663
- }
664
- }
665
- results.sort((a, b) => b.score - a.score);
666
- return results.slice(0, limit);
667
- }
759
+ // v1.5.1 H1.5 (audit memory-engine.md F-FUN-4): the legacy naive-keyword-count
760
+ // `searchAcrossProjects` was removed. `scope:'all'` now routes to the BM25-
761
+ // ranked `crossProjectSearch` in cross-project-search.js (which already
762
+ // returns the same `{ source, line, content, score }` shape via the
763
+ // `[project:<name>]` content prefix). Two parallel cross-project surfaces
764
+ // existed; the worse one was the default. Now there is one.
668
765
 
669
766
  // --- Search ---
670
767
  // P5.1 / H4 -- BM25 ranking over line-level docs. Source tags and line
671
768
  // numbers preserved so callers get the same output shape; scoring is
672
769
  // BM25 (IDF + TF + length-normalized) with per-source boost. Team tier
673
770
  // ranks first via a score bump for ties.
674
- function searchMemory(query, limit = 10, scope = 'project') {
771
+ //
772
+ // r17 (cold-tier wire-up): when IJFW_VECTORS=on AND @xenova/transformers is
773
+ // installed AND the model is loadable, the BM25 top-K is reranked via cosine
774
+ // similarity over the snippet text (blended weights wBm25=0.6, wVec=0.4 from
775
+ // vectors.js defaults). Async because embedder load + embed() are async. The
776
+ // `opts.embedder` parameter lets tests inject a mock embedder without
777
+ // installing @xenova/transformers.
778
+
779
+ // v1.5.0 wire-W1.C — lazy memory.db handle for the embedding cache.
780
+ // Opens once per process and reuses thereafter. Returns null when the
781
+ // project has no .ijfw/index/memory.db (e.g. fresh checkout that never
782
+ // stored a memory) so the rerank still falls back to live embed.
783
+ let _memoryDbForRerank = null;
784
+ async function getMemoryDbForRerank() {
785
+ if (_memoryDbForRerank) return _memoryDbForRerank;
786
+ try {
787
+ const { openDb } = await import('./memory/fts5.js');
788
+ _memoryDbForRerank = await openDb(PROJECT_DIR);
789
+ return _memoryDbForRerank;
790
+ } catch {
791
+ _memoryDbForRerank = null;
792
+ return null;
793
+ }
794
+ }
795
+
796
+ async function searchMemory(query, limit = 10, scope = 'project', opts = {}) {
675
797
  limit = Math.min(Math.max(1, limit | 0), MAX_SEARCH_RESULTS);
676
- if (scope === 'all') return searchAcrossProjects(query, limit);
798
+ if (scope === 'all') {
799
+ // v1.5.1 H1.5 (audit memory-engine.md F-FUN-4): use BM25-ranked
800
+ // crossProjectSearch, not the legacy naive keyword-count scan.
801
+ const projects = readRegistry();
802
+ if (projects.length === 0) return [];
803
+ return crossProjectSearch(query, projects, readProjectMemory, { limit });
804
+ }
677
805
 
678
806
  const sources = [
679
- { name: 'team', content: readTeamKnowledge(), boost: 1.25 },
680
- { name: 'knowledge', content: readKnowledgeBase(), boost: 1.15 },
681
- { name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0 },
682
- { name: 'handoff', content: readHandoff(), boost: 1.1 },
683
- { name: 'global', content: readGlobalKnowledge(), boost: 0.95 },
684
- { name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95 },
807
+ { name: 'team', content: readTeamKnowledge(), boost: 1.25, path: join(MEMORY_DIR, 'team-knowledge.md') },
808
+ { name: 'knowledge', content: readKnowledgeBase(), boost: 1.15, path: join(MEMORY_DIR, 'knowledge.md') },
809
+ { name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0, path: join(MEMORY_DIR, 'project-journal.md') },
810
+ { name: 'handoff', content: readHandoff(), boost: 1.1, path: join(MEMORY_DIR, 'handoff.md') },
811
+ { name: 'global', content: readGlobalKnowledge(), boost: 0.95, path: null },
812
+ { name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95, path: null },
685
813
  ];
686
814
 
815
+ // v1.5.0 audit MED #12 (memory-engine.md F-FUN-5): recency decay on the
816
+ // boosted map. Each source file has an mtime; per-result age (days since
817
+ // mtime) feeds Math.exp(-ageDays / 90), so a 90-day-old source decays
818
+ // by ~1/e (0.37) and a 1-year-old source by ~0.018. Fresh entries
819
+ // (< 1 day) stay essentially unchanged (~0.99). Sources without a file
820
+ // (global, claude-native) get no decay (multiplier 1.0).
821
+ const RECENCY_HALFLIFE_DAYS = 90;
822
+ const nowMs = Date.now();
823
+ const sourceDecay = new Map();
824
+ for (const src of sources) {
825
+ if (!src.path) { sourceDecay.set(src.name, 1); continue; }
826
+ let ageDays = 0;
827
+ try {
828
+ const st = statSync(src.path);
829
+ ageDays = Math.max(0, (nowMs - st.mtimeMs) / 86400000);
830
+ } catch { ageDays = 0; }
831
+ sourceDecay.set(src.name, Math.exp(-ageDays / RECENCY_HALFLIFE_DAYS));
832
+ }
833
+
687
834
  const docs = [];
688
835
  const meta = new Map();
689
836
  for (const src of sources) {
@@ -702,19 +849,46 @@ function searchMemory(query, limit = 10, scope = 'project') {
702
849
  const ranked = searchCorpus(query, docs, { limit: limit * 3 });
703
850
  if (ranked.length === 0) return [];
704
851
 
705
- const boosted = ranked.map(r => {
852
+ // r17: cold-tier hybrid rerank. Pure no-op when vectors disabled OR
853
+ // embedder unavailable. Never throws into the caller.
854
+ //
855
+ // v1.5.0 wire-W1.C: when the caller didn't supply a db handle but the
856
+ // memory.db exists for this project, open it lazily + thread through so
857
+ // the embedding cache backs the rerank. The default modelId mirrors
858
+ // vectors.js DEFAULT_MODEL so first-call writes match cache reads from
859
+ // the same process on later calls.
860
+ //
861
+ // r20-MED fix: previously the lazy-open was SKIPPED whenever opts.embedder
862
+ // was supplied (intended as a test-seam guard). That meant any caller
863
+ // passing a custom embedder (e.g. an HTTP-backed one) lost the cache.
864
+ // Now: always lazy-open when !opts.db. Tests that want to disable the
865
+ // cache pass opts.db = null explicitly.
866
+ const rerankOpts = { ...opts };
867
+ if (!rerankOpts.db && rerankOpts.db !== null) {
868
+ try {
869
+ rerankOpts.db = await getMemoryDbForRerank();
870
+ } catch { /* memory db unavailable -- skip cache, fall back to live embed */ }
871
+ }
872
+ if (!rerankOpts.modelId) {
873
+ rerankOpts.modelId = process.env.IJFW_VECTORS_MODEL || 'Xenova/all-MiniLM-L6-v2';
874
+ }
875
+ const reranked = await maybeRerankWithVectors(query, ranked, rerankOpts);
876
+
877
+ const boosted = reranked.map(r => {
706
878
  const m = meta.get(r.id);
879
+ const decay = sourceDecay.get(m.source) ?? 1;
707
880
  return {
708
881
  source: m.source,
709
882
  line: m.line,
710
883
  content: (r.snippet || '').substring(0, 200),
711
- score: r.score * (m.boost || 1),
884
+ score: r.score * (m.boost || 1) * decay,
712
885
  };
713
886
  });
714
887
  boosted.sort((a, b) => b.score - a.score);
715
888
  return boosted.slice(0, limit);
716
889
  }
717
890
 
891
+
718
892
  // --- DESIGN picker (1.2.0 Phase 5) ---
719
893
  // MCP-only delivery of the 12-template design catalog for OpenCode / Qwen
720
894
  // Code / Kimi Code / OpenClaw / Aider. No new tool -- served via existing
@@ -805,7 +979,7 @@ const TOOLS = [
805
979
  inputSchema: {
806
980
  type: 'object',
807
981
  properties: {
808
- content: { type: 'string', description: 'Full statement of what to remember. Max 5000 chars. Sanitised on storage.' },
982
+ content: { type: 'string', description: 'Full statement of what to remember. Max 4096 chars. Sanitised on storage.' },
809
983
  type: { type: 'string', enum: VALID_MEMORY_TYPES, description: 'Memory tier: decision or pattern -> knowledge base (frontmatter). handoff -> overwrites handoff.md. preference -> project-namespaced global. observation -> journal only.' },
810
984
  summary: { type: 'string', description: 'Optional 1-line summary (≤80 chars). Used as the frontmatter name for decisions/patterns.' },
811
985
  why: { type: 'string', description: 'Optional rationale -- why this decision was made. Populates the Why section in the knowledge base entry.' },
@@ -844,6 +1018,21 @@ const TOOLS = [
844
1018
  required: []
845
1019
  }
846
1020
  },
1021
+ {
1022
+ // v1.5.0 M5 (INT.6) -- bi-temporal facts MCP surface.
1023
+ name: 'ijfw_memory_facts',
1024
+ description: 'Query the bi-temporal facts table (subject/predicate/object timeline with valid_from / valid_to). Default: current-valid rows only. Pass history=true for full timeline; pass valid_at=<ISO-8601> for point-in-time. Subject + predicate are required.',
1025
+ inputSchema: {
1026
+ type: 'object',
1027
+ properties: {
1028
+ subject: { type: 'string', description: 'Fact subject (e.g. "v1.5.0").' },
1029
+ predicate: { type: 'string', description: 'Fact predicate (e.g. "ship_date").' },
1030
+ valid_at: { type: 'string', description: 'Optional ISO-8601 timestamp. Returns rows whose validity window covers this instant.' },
1031
+ history: { type: 'boolean', description: 'If true, return all rows (current + invalidated) ordered DESC by valid_from.' }
1032
+ },
1033
+ required: ['subject', 'predicate']
1034
+ }
1035
+ },
847
1036
  {
848
1037
  name: 'ijfw_prompt_check',
849
1038
  description: 'Call on the first turn when the user prompt is short (<30 tokens) or likely vague. Returns whether the prompt is under-specified and a sharpening suggestion. Deterministic regex detector -- no LLM call. Use for Codex/Cursor/Windsurf/Copilot/Gemini where pre-prompt hooks are not available.',
@@ -893,6 +1082,46 @@ const TOOLS = [
893
1082
  },
894
1083
  required: ['command'],
895
1084
  },
1085
+ },
1086
+ {
1087
+ // v1.5.0 T13: ijfw_state — single MCP face for the state-SDK verb facade.
1088
+ // Absorbs the retired ijfw_subagent_post_done tool (post-done IS a state
1089
+ // transition → reachable as the `subagent.post-done` verb). All 20 frozen
1090
+ // verbs from STATE-SDK-CONTRACT §7 are reachable through this one tool,
1091
+ // keeping the MCP cap at 12/12. The same `query(verb, payload, ctx)` core
1092
+ // is also exposed as a JS import and a CLI colon-namespace (`ijfw state:<verb>`).
1093
+ name: 'ijfw_state',
1094
+ description: 'State-SDK verb facade — invoke any of the 20 frozen verbs (workflow.*, wave.*, phase.*, subagent.*, event.emit, telemetry.record, roster.*, extension.set-active, decision.add, blocker.*, state.replay, state.validate) over the canonical physical state files. Single MCP face for the state-SDK; subagent.post-done is the verb that absorbed the retired ijfw_subagent_post_done tool. Returns the verb result with `ok` + `verbId` + verb-specific fields (see STATE-SDK-CONTRACT §7).',
1095
+ inputSchema: {
1096
+ type: 'object',
1097
+ properties: {
1098
+ verb: { type: 'string', description: 'Verb name from the frozen 20-verb registry (e.g. "workflow.get", "wave.advance", "subagent.post-done", "state.validate").' },
1099
+ payload: { type: 'object', description: 'Verb-specific payload (see STATE-SDK-CONTRACT §7 for each verb signature). Defaults to {} when omitted.' },
1100
+ projectRoot: { type: 'string', description: 'Project root for ctx (defaults to process.cwd()).' },
1101
+ subagentId: { type: 'string', description: 'Subagent id stamped on event/telemetry records (defaults to "parent").' },
1102
+ homeDir: { type: 'string', description: 'Home dir override for the homedir-scope active-extension file (defaults to process.env.HOME / USERPROFILE / os.homedir()).' },
1103
+ },
1104
+ required: ['verb'],
1105
+ },
1106
+ },
1107
+ {
1108
+ // v1.5.0-major W12-C N03: Trident-as-a-service. Multi-lens consensus
1109
+ // convergence (lock-in #47 — canonical Phase E). Dispatches all 3 lenses
1110
+ // (codex/gemini/claude by default) in parallel; if verdicts diverge,
1111
+ // re-runs with a CYCLE_SUMMARY of the disagreement until consensus or
1112
+ // maxIterations (default 3). Stall breaker halts on byte-identical
1113
+ // iterations. Fills the 12th tool-cap slot.
1114
+ name: 'ijfw_cross_audit_converge',
1115
+ description: 'Multi-lens Trident audit with consensus convergence loop. Dispatches codex/gemini/claude in parallel against a commit range, detects verdict divergence, and re-runs with a cycle summary until consensus or maxIterations. Returns {verdict, iterations, findings, divergence?, stalled?}. Verdict: PASS / CONDITIONAL / FAIL / consensus_failed / UNREACHABLE.',
1116
+ inputSchema: {
1117
+ type: 'object',
1118
+ properties: {
1119
+ commitRange: { type: 'string', description: 'Git commit range to audit (e.g. "HEAD~1..HEAD", "main..feature/x"). Required.' },
1120
+ maxIterations: { type: 'number', description: 'Max convergence iterations (default 3). 1 → single-shot (fallback mode).' },
1121
+ lenses: { type: 'array', items: { type: 'string' }, description: 'Lens ids to dispatch (default ["codex","gemini","claude"]).' },
1122
+ },
1123
+ required: ['commitRange'],
1124
+ },
896
1125
  }
897
1126
  ];
898
1127
 
@@ -901,8 +1130,8 @@ const TOOLS = [
901
1130
  function handleRecall({ context_hint, detail_level = 'standard', from_project }) {
902
1131
  // Cross-project explicit pull. We bypass current-project sources and read
903
1132
  // the target project's knowledge/handoff/journal directly. Search queries
904
- // are routed through searchAcrossProjects via scope:'all' on the search tool;
905
- // recall here is for "give me everything from X."
1133
+ // are routed through crossProjectSearch (BM25) via scope:'all' on the
1134
+ // search tool; recall here is for "give me everything from X."
906
1135
  if (from_project) {
907
1136
  const target = resolveProject(from_project);
908
1137
  if (!target) {
@@ -949,6 +1178,95 @@ function handleRecall({ context_hint, detail_level = 'standard', from_project })
949
1178
  return { text: getRecentJournalEntries(10) || 'No decisions recorded yet.' };
950
1179
  }
951
1180
 
1181
+ // H5.5 / v1.5.0 H5.4 — structured facts feed.
1182
+ // context_hint === 'facts' -> only currently-valid facts (SQL
1183
+ // getValidAt(now)); fallback to raw
1184
+ // facts.jsonl if the SQL table is
1185
+ // empty/unavailable (back-compat for
1186
+ // pre-H5.4 installations).
1187
+ // context_hint === 'facts:history' -> full timeline including invalidated
1188
+ // rows (with their valid_from/valid_to
1189
+ // windows). If the hint is followed by
1190
+ // ":subject/predicate" we narrow to
1191
+ // that pair; otherwise return every
1192
+ // row.
1193
+ if (context_hint === 'facts' || (typeof context_hint === 'string' && context_hint.startsWith('facts:history'))) {
1194
+ const isHistory = typeof context_hint === 'string' && context_hint.startsWith('facts:history');
1195
+ try {
1196
+ const db = getFactsDb();
1197
+ if (db) {
1198
+ if (isHistory) {
1199
+ // Optional ":subject/predicate" narrow-down. Spec: "if subject+
1200
+ // predicate keys can be inferred from the recall query; else return
1201
+ // all facts with their validity windows".
1202
+ const tail = context_hint.slice('facts:history'.length).replace(/^:/, '').trim();
1203
+ let rows;
1204
+ if (tail) {
1205
+ const [subj, pred] = tail.split('/').map(s => s && s.trim()).filter(Boolean);
1206
+ if (subj && pred) {
1207
+ rows = temporalGetHistory(db, subj, pred);
1208
+ } else {
1209
+ rows = temporalGetAllFactsWithWindows(db);
1210
+ }
1211
+ } else {
1212
+ rows = temporalGetAllFactsWithWindows(db);
1213
+ }
1214
+ if (!rows || rows.length === 0) {
1215
+ return { text: 'No structured facts extracted yet. Store memories with key:value lines, "X uses Y", or "decided to ..." phrases to populate.' };
1216
+ }
1217
+ const lines = rows.map(r => JSON.stringify({
1218
+ subject: r.subject,
1219
+ predicate: r.predicate,
1220
+ object: r.object,
1221
+ confidence: r.confidence,
1222
+ valid_from: r.valid_from,
1223
+ valid_to: r.valid_to,
1224
+ memory_id: r.memory_id,
1225
+ source: r.source,
1226
+ }));
1227
+ if (detail_level === 'summary') {
1228
+ const tailLines = lines.slice(-5).join('\n');
1229
+ return { text: `## Fact history (${lines.length} rows)\n${tailLines}` };
1230
+ }
1231
+ return { text: `## Fact history (${lines.length} rows)\n${lines.join('\n')}` };
1232
+ }
1233
+ // Default: currently-valid facts only.
1234
+ const rows = temporalGetValidAt(db, new Date().toISOString());
1235
+ if (rows && rows.length > 0) {
1236
+ const lines = rows.map(r => JSON.stringify({
1237
+ subject: r.subject,
1238
+ predicate: r.predicate,
1239
+ object: r.object,
1240
+ confidence: r.confidence,
1241
+ valid_from: r.valid_from,
1242
+ memory_id: r.memory_id,
1243
+ source: r.source,
1244
+ }));
1245
+ if (detail_level === 'summary') {
1246
+ const tail = lines.slice(-5).join('\n');
1247
+ return { text: `## Currently-valid facts (${lines.length})\n${tail}` };
1248
+ }
1249
+ return { text: `## Currently-valid facts (${lines.length})\n${lines.join('\n')}` };
1250
+ }
1251
+ // SQL table empty -- fall through to JSONL back-compat path below.
1252
+ }
1253
+ if (!existsSync(FACTS_FILE)) {
1254
+ return { text: 'No structured facts extracted yet. Store memories with key:value lines, "X uses Y", or "decided to ..." phrases to populate.' };
1255
+ }
1256
+ const raw = readFileSync(FACTS_FILE, 'utf8');
1257
+ // detail_level === 'summary' → just count + a sample tail. Keeps token
1258
+ // usage bounded for session-start hydration.
1259
+ if (detail_level === 'summary') {
1260
+ const lines = raw.split('\n').filter(Boolean);
1261
+ const tail = lines.slice(-5).join('\n');
1262
+ return { text: `## Structured facts (${lines.length} total)\n${tail}` };
1263
+ }
1264
+ return { text: raw || 'No structured facts extracted yet.' };
1265
+ } catch (err) {
1266
+ return { text: `Facts feed unreadable: ${err.code || err.message}`, isError: true };
1267
+ }
1268
+ }
1269
+
952
1270
  const results = searchMemory(context_hint);
953
1271
  if (results.length === 0) return { text: `No memories matching: ${context_hint}` };
954
1272
  return { text: results.map(r => `[${r.source}] ${r.content}`).join('\n') };
@@ -997,12 +1315,53 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
997
1315
  const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
998
1316
  const journalEntry = `**${type}**${tagStr}: ${safeSummary || safeContent.substring(0, 200)}`;
999
1317
 
1318
+ // H5.6 — Semantic dedup BEFORE append. If this memory is a near-duplicate
1319
+ // of one already in the last N journal entries, short-circuit and return
1320
+ // the existing entry's id. handoff is exempt (always overwrites a single
1321
+ // file by design; deduping would silently drop a handoff swap).
1322
+ const dedupCfg = readDedupConfig();
1323
+ if (dedupCfg.enabled && type !== 'handoff') {
1324
+ const recents = getRecentMemoriesForDedup(dedupCfg.windowSize);
1325
+ // Dedup against the FULL journal-entry line shape -- that's what the next
1326
+ // call would see in `recents`, so comparing apples to apples.
1327
+ const dup = findNearDuplicate(journalEntry, recents);
1328
+ if (dup) {
1329
+ // Spec: emit a stderr line so the user/agent sees the elision.
1330
+ try {
1331
+ process.stderr.write(`[ijfw memory] dedup'd similar entry; keeping prior ${dup.match.id}\n`);
1332
+ } catch { /* stderr may be detached in test harness */ }
1333
+ return {
1334
+ text: `Dedup'd: similar memory already exists (${dup.match.id}, similarity=${dup.similarity.toFixed(2)}). Not appended.`,
1335
+ deduped: true,
1336
+ existing_id: dup.match.id,
1337
+ similarity: dup.similarity,
1338
+ };
1339
+ }
1340
+ }
1341
+
1000
1342
  // 1. Always append to journal (one-line timeline). Hard failure → report.
1001
1343
  const journalResult = appendToJournal(journalEntry);
1002
1344
  if (!journalResult.ok) {
1003
1345
  return { text: `Memory journal is not writable (${journalResult.code}) -- check .ijfw/ directory permissions and retry.`, isError: true };
1004
1346
  }
1005
1347
 
1348
+ // H5.5 — Fact extraction AFTER successful append. Best-effort: a failure
1349
+ // here is logged in the return text but does NOT poison the store result.
1350
+ // Memory-id ties facts.jsonl rows back to their journal entry.
1351
+ const factMeta = {
1352
+ ts: new Date().toISOString(),
1353
+ memory_id: factMemoryIdFor(journalEntry),
1354
+ source: `memory_store:${type}`,
1355
+ };
1356
+ const facts = extractFacts(safeContent);
1357
+ appendFactsToSidecar(facts, factMeta);
1358
+ // v1.5.0 audit H5.4 — mirror to bi-temporal SQL store. For each fact,
1359
+ // closes any prior currently-valid fact with the same (subject, predicate)
1360
+ // but different object before inserting. Same-object stores are a no-op.
1361
+ // Wrapped in a per-fact transaction inside temporal.js. Best-effort: any
1362
+ // failure is logged to stderr but never breaks the journal-or-JSONL path.
1363
+ writeFactsBitemporal(facts, factMeta);
1364
+
1006
1365
  // 2. Type-specific secondary writes. Each tracked so we report partial
1007
1366
  // success accurately rather than lying about "stored."
1008
1367
  const failures = [];
@@ -1107,22 +1466,60 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
1107
1466
  const updateNudge = composeUpdateNudge();
1108
1467
  if (updateNudge) parts.push(updateNudge, '');
1109
1468
 
1469
+ // v1.5.0 memory-moat M3 (INT.4): surface top-K recently-successful skills
1470
+ // at session start. The Wayland-pattern "more-you-use-it-better-it-gets"
1471
+ // feedback loop: every skill execution writes to skill_telemetry via the
1472
+ // state-SDK telemetry.record verb (INT.3); this block reads top-5 by
1473
+ // success-count and surfaces them as a hint to the model. Best-effort:
1474
+ // an unmigrated db, empty telemetry, or read failure all skip the block
1475
+ // silently — never breaks the prelude.
1476
+ try {
1477
+ const { topKSuccessfulSkills } = await import('./orchestrator/skill-telemetry.js');
1478
+ const Database = (await import('better-sqlite3')).default;
1479
+ const { join: joinP } = await import('node:path');
1480
+ const root = process.env.IJFW_PROJECT_DIR || process.cwd();
1481
+ const dbPath = joinP(root, '.ijfw', 'index', 'memory.db');
1482
+ if (existsSync(dbPath)) {
1483
+ const db = new Database(dbPath, { readonly: true });
1484
+ try {
1485
+ const top = topKSuccessfulSkills(db, { k: 5 });
1486
+ if (top.length > 0) {
1487
+ const names = top.map((r) => `${r.skill_id} (${r.success_count}×)`).join(', ');
1488
+ parts.push(
1489
+ '<ijfw-recommended-skills>',
1490
+ `Observed success this project: ${names}`,
1491
+ '</ijfw-recommended-skills>',
1492
+ '',
1493
+ );
1494
+ }
1495
+ } finally {
1496
+ try { db.close(); } catch { /* best-effort */ }
1497
+ }
1498
+ }
1499
+ } catch { /* best-effort; never block the prelude */ }
1500
+
1110
1501
  // 1.2.0 Phase 5: surface the DESIGN picker to platforms without a skills tree.
1111
1502
  // Skip when the project already has a DESIGN.md (contract exists; no picker).
1503
+ // Built into a standalone block so the abstention path below can re-emit it:
1504
+ // a fresh project with no memory AND no DESIGN.md is exactly when the picker
1505
+ // matters most, so it must survive the thin-memory short-circuit.
1506
+ let designPickerBlock = '';
1112
1507
  try {
1113
1508
  if (!existsSync(join(PROJECT_DIR, 'DESIGN.md'))) {
1114
1509
  const names = DESIGN_TEMPLATE_CATALOG.map(([n]) => n);
1115
- parts.push('## Design picker');
1116
- parts.push('No DESIGN.md in project. 12 curated templates available:');
1117
- parts.push(names.slice(0, 5).join(', ') + ',');
1118
- parts.push(names.slice(5, 10).join(', ') + ',');
1119
- parts.push(names.slice(10).join(', ') + '.');
1120
- parts.push('');
1121
- parts.push('Pick one: ijfw_memory_recall({context_hint: "design_template:<name>"}).');
1122
- parts.push('Full catalog with descriptions: ijfw_memory_recall({context_hint: "design_template"}).');
1123
- parts.push('');
1510
+ designPickerBlock = [
1511
+ '## Design picker',
1512
+ 'No DESIGN.md in project. 12 curated templates available:',
1513
+ names.slice(0, 5).join(', ') + ',',
1514
+ names.slice(5, 10).join(', ') + ',',
1515
+ names.slice(10).join(', ') + '.',
1516
+ '',
1517
+ 'Pick one: ijfw_memory_recall({context_hint: "design_template:<name>"}).',
1518
+ 'Full catalog with descriptions: ijfw_memory_recall({context_hint: "design_template"}).',
1519
+ ].join('\n');
1124
1520
  }
1125
- } catch { /* cwd unreadable -- skip picker block */ }
1521
+ } catch { /* project dir unreadable -- skip picker block */ }
1522
+ if (designPickerBlock) parts.push(designPickerBlock, '');
1126
1523
 
1127
1524
  // Team knowledge first -- shared decisions/patterns/stack rank above personal.
1128
1525
  const team = readTeamKnowledge();
@@ -1221,16 +1618,51 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
1221
1618
  // Best-effort; never fail the prelude on memory-feedback issues.
1222
1619
  }
1223
1620
 
1621
+ // v1.5.0 audit-MED-update-M8 (F-REL-2): surface the last-N partial-deploy
1622
+ // alerts so a half-deployed extension is visible at next session-start.
1623
+ // Wrapped in try/catch — alert read failure must NEVER fail the prelude.
1624
+ try {
1625
+ const { renderDeployAlertsForPrelude } = await import('./deploy-alerts.js');
1626
+ const block = await renderDeployAlertsForPrelude({ limit: 10 });
1627
+ if (block && typeof block === 'string' && block.length > 0) {
1628
+ parts.push(block);
1629
+ }
1630
+ } catch {
1631
+ // Best-effort; never fail the prelude on deploy-alert read issues.
1632
+ }
1633
+
1224
1634
  parts.push('</ijfw-memory>');
1225
1635
 
1226
1636
  const text = parts.join('\n');
1227
1637
  if (text.length < 60) {
1228
1638
  return { text: 'Fresh project -- no memory stored yet. Proceed normally.' };
1229
1639
  }
1640
+
1641
+ // v1.5.0 audit MED #11 (memory-engine.md F-FUN-7): abstention.
1642
+ // If memory exists but the body content is thin AND there are no recent
1643
+ // journal entries, surface an honest abstention so the LLM doesn't try
1644
+ // to over-fit a half-page of stale frontmatter to the user's prompt.
1645
+ // Threshold: total content chars below MIN_CONTENT_CHARS for the
1646
+ // knowledge/team/handoff sources combined AND zero recent journal lines.
1647
+ const MIN_CONTENT_CHARS = 200;
1648
+ const knowledgeChars = (knowledge ? knowledge.length : 0) + (team ? team.length : 0) + (handoff ? handoff.length : 0);
1649
+ const recentLines = recent ? recent.split('\n').filter(l => l.trim()).length : 0;
1650
+ if (knowledgeChars < MIN_CONTENT_CHARS && recentLines === 0) {
1651
+ const abstain = [
1652
+ '<ijfw-memory>',
1653
+ 'Memory present but nothing relevant to your prompt -- proceed and I\'ll store any decisions you make.',
1654
+ ];
1655
+ // The DESIGN picker is independent of memory richness — preserve it
1656
+ // through the abstention path, otherwise a fresh project never sees it.
1657
+ if (designPickerBlock) abstain.push('', designPickerBlock);
1658
+ abstain.push('</ijfw-memory>');
1659
+ return { text: abstain.join('\n') };
1660
+ }
1661
+
1230
1662
  return { text };
1231
1663
  }
1232
1664
 
1233
- function handleSearch({ query, limit = 10, scope = 'project', label }) {
1665
+ async function handleSearch({ query, limit = 10, scope = 'project', label }) {
1234
1666
  if (scope === 'sandbox') {
1235
1667
  if (label) {
1236
1668
  const content = readFromSandbox(label);
@@ -1258,7 +1690,49 @@ function handleSearch({ query, limit = 10, scope = 'project', label }) {
1258
1690
  }
1259
1691
  if (query.length > 500) query = query.substring(0, 500);
1260
1692
  if (scope !== 'project' && scope !== 'all') scope = 'project';
1261
- const results = searchMemory(query, limit, scope);
1693
+
1694
+ // v1.5.0 memory-moat M1 (INT.5): "dv:" prefix routes to the declarative
1695
+ // Dataview-grade query mode. Returns structured rows from the FTS5 +
1696
+ // memory_links/_tags/_meta join populated at write time by M1.3 /
1697
+ // INT.1. Best-effort: errors fall through to the standard NL/FTS5 path
1698
+ // so the new mode never breaks existing callers.
1699
+ if (query.startsWith('dv:')) {
1700
+ try {
1701
+ const body = query.slice(3).trim();
1702
+ const dvMod = await import('./memory/query-dataview.js');
1703
+ const fts5Mod = await import('./memory/fts5.js');
1704
+ const root = process.env.IJFW_PROJECT_DIR || process.cwd();
1705
+ const db = await fts5Mod.openDb(root);
1706
+ try {
1707
+ const parsed = dvMod.parseDataviewQuery(body);
1708
+ const result = dvMod.runDataviewQuery(db, parsed);
1709
+ const rows = result.rows.slice(0, limit);
1710
+ if (rows.length === 0) {
1711
+ return { text: `No results for dataview query: "${body}"`, mode: 'dataview', parsed };
1712
+ }
1713
+ // Render in the existing text shape so MCP clients that expect
1714
+ // a single string field keep working.
1715
+ return {
1716
+ text: rows
1717
+ .map((r) => `[id:${r.id} source:${r.source || '?'} created:${r.created_at}] ${(r.body || '').slice(0, 200)}`)
1718
+ .join('\n'),
1719
+ mode: 'dataview',
1720
+ parsed,
1721
+ rowCount: rows.length,
1722
+ };
1723
+ } finally {
1724
+ try { fts5Mod.closeDb(db); } catch { /* best-effort */ }
1725
+ }
1726
+ } catch (e) {
1727
+ return {
1728
+ text: `Dataview query failed: ${e && e.message ? e.message : String(e)}`,
1729
+ mode: 'dataview',
1730
+ isError: true,
1731
+ };
1732
+ }
1733
+ }
1734
+
1735
+ const results = await searchMemory(query, limit, scope);
1262
1736
  if (results.length === 0) {
1263
1737
  const where = scope === 'all' ? ' across all projects' : '';
1264
1738
  return { text: `No results for: "${query}"${where}` };
@@ -1471,11 +1945,68 @@ function handleMessage(msg) {
1471
1945
  result = { text: JSON.stringify(r, null, 2), isError: !!(r && r.error) };
1472
1946
  break;
1473
1947
  }
1948
+ case 'ijfw_state': {
1949
+ // v1.5.0 T13: single MCP face for the state-SDK. Routes every call
1950
+ // into the same `query(verb, payload, ctx)` core that the JS module
1951
+ // (`./orchestrator/state-sdk.js`) and the CLI colon-namespace
1952
+ // (`ijfw state:<verb>`) use. The retired `ijfw_subagent_post_done`
1953
+ // tool is now reachable as the `subagent.post-done` verb.
1954
+ const a = args || {};
1955
+ if (typeof a.verb !== 'string' || a.verb.length === 0) {
1956
+ result = { text: JSON.stringify({ ok: false, error: 'verb (string) is required' }), isError: true };
1957
+ break;
1958
+ }
1959
+ try {
1960
+ const { query } = await import('./orchestrator/state-sdk.js');
1961
+ const payload = (a.payload && typeof a.payload === 'object') ? a.payload : {};
1962
+ const ctx = {
1963
+ projectRoot: typeof a.projectRoot === 'string' && a.projectRoot.length > 0
1964
+ ? a.projectRoot
1965
+ : process.cwd(),
1966
+ };
1967
+ if (typeof a.subagentId === 'string' && a.subagentId.length > 0) ctx.subagentId = a.subagentId;
1968
+ if (typeof a.homeDir === 'string' && a.homeDir.length > 0) ctx.homeDir = a.homeDir;
1969
+ const r = await query(a.verb, payload, ctx);
1970
+ // A verdict-fail refusal (Model 4) is the verb's correct hard-block —
1971
+ // surface `isError: true` so the orchestrator-LLM treats it as a
1972
+ // hard stop rather than an advisory note (mirrors the prior
1973
+ // ijfw_subagent_post_done `block: true` contract).
1974
+ const refused = r && r.refused === true;
1975
+ result = { text: JSON.stringify(r, null, 2), isError: !!refused };
1976
+ } catch (err) {
1977
+ const msg = err && err.message ? err.message : String(err);
1978
+ result = { text: JSON.stringify({ ok: false, error: msg }), isError: true };
1979
+ }
1980
+ break;
1981
+ }
1474
1982
  case 'ijfw_update_apply': {
1475
1983
  const r = ijfwUpdateApply(args || {});
1476
1984
  result = { text: JSON.stringify(r, null, 2), isError: r && r.status === 'error' };
1477
1985
  break;
1478
1986
  }
1987
+ case 'ijfw_cross_audit_converge': {
1988
+ // v1.5.0-major W12-C N03: Trident-as-a-service.
1989
+ const a = args || {};
1990
+ if (!a.commitRange || typeof a.commitRange !== 'string') {
1991
+ result = { text: JSON.stringify({ error: 'commitRange (string) is required' }), isError: true };
1992
+ break;
1993
+ }
1994
+ const { runPhaseEConverge, defaultConvergeDispatch } = await import('./cross-orchestrator.js');
1995
+ try {
1996
+ const r = await runPhaseEConverge({
1997
+ commitRange: a.commitRange,
1998
+ maxIterations: typeof a.maxIterations === 'number' ? a.maxIterations : 3,
1999
+ lenses: Array.isArray(a.lenses) && a.lenses.length > 0 ? a.lenses : undefined,
2000
+ dispatch: defaultConvergeDispatch,
2001
+ projectRoot: process.cwd(),
2002
+ });
2003
+ const isErr = r.verdict === 'consensus_failed' || r.verdict === 'FAIL' || r.verdict === 'UNREACHABLE';
2004
+ result = { text: JSON.stringify(r, null, 2), isError: isErr };
2005
+ } catch (err) {
2006
+ result = { text: JSON.stringify({ error: err && err.message ? err.message : String(err) }), isError: true };
2007
+ }
2008
+ break;
2009
+ }
1479
2010
  case 'ijfw_memory_recall':
1480
2011
  result = handleRecall(args || {});
1481
2012
  emitRecallObservation(args || {});
@@ -1504,7 +2035,7 @@ function handleMessage(msg) {
1504
2035
  break;
1505
2036
  }
1506
2037
  }
1507
- result = handleSearch(searchArgs);
2038
+ result = await handleSearch(searchArgs);
1508
2039
  break;
1509
2040
  }
1510
2041
  case 'ijfw_memory_status':
@@ -1513,6 +2044,12 @@ function handleMessage(msg) {
1513
2044
  case 'ijfw_memory_prelude':
1514
2045
  result = await handlePrelude(args || {});
1515
2046
  break;
2047
+ case 'ijfw_memory_facts': {
2048
+ // v1.5.0 M5 (INT.6) -- surface bi-temporal facts read path.
2049
+ const mod = await import('./memory-facts-handler.js');
2050
+ result = await mod.handleMemoryFacts(args || {});
2051
+ break;
2052
+ }
1516
2053
  case 'ijfw_metrics':
1517
2054
  result = handleMetrics(args || {});
1518
2055
  break;
@@ -1665,4 +2202,11 @@ process.on('unhandledRejection', (err) => {
1665
2202
  // Export for tests (Node ESM allows this -- only consumed when imported, not on stdio run)
1666
2203
  // gatePermissionAndQuota is exported inline at its declaration above (B16/SEC-M-03)
1667
2204
  // so test-server-quota-integration.js can drive it without spinning a server.
1668
- export { sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH };
2205
+ // H5.5 / H5.6 expose handleStore/handleRecall + path/helpers so the ingest
2206
+ // integration test can drive the full pipeline without spawning a subprocess.
2207
+ export {
2208
+ sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH,
2209
+ handleStore, handleRecall, handleSearch, handlePrelude,
2210
+ MEMORY_DIR, FACTS_FILE, FACTS_DB_FILE,
2211
+ getFactsDb,
2212
+ };