@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,317 @@
1
+ /**
2
+ * recovery/truncation.js — v1.5.0 T20: subagent truncation detection +
3
+ * recovery against the T4 intent-journal + T5 per-subagent event stream.
4
+ *
5
+ * ROLES:
6
+ * * `detectTruncation({events, journal, expectedTerminalVerb})` — pure
7
+ * classification of a subagent's event stream + journal state. Returns
8
+ * `{ truncated:boolean, reason:string }`. The "tell" signals (in
9
+ * priority order, per T19's reporter contract):
10
+ * 1. `events` ends with `outcome:'error'` → truncated:'error-terminated'
11
+ * 2. `events` is empty AND `journal` has an open
12
+ * begin (begin without commit) → truncated:'no-events-open-begin'
13
+ * 3. last event predates the wave's expected
14
+ * terminal verb (e.g. `subagent.post-done`) → truncated:'missing-terminal'
15
+ * 4. journal has any begin-without-commit
16
+ * regardless of event stream end → truncated:'open-partial'
17
+ * 5. else → truncated:false (clean)
18
+ *
19
+ * * `recoverSubagent({projectRoot, waveId, subId, sinceVerbId})` — calls
20
+ * `query('state.replay', { sinceVerbId })` which (per T4) snapshot-rolls-
21
+ * back overwrite-verb partials + seals append-verb partials. Returns
22
+ * `{ recovered:boolean, replayResult, verdict, reason }`.
23
+ *
24
+ * * `measureTruncationRate({ fixtures, runOne })` — iterates a corpus of
25
+ * fixtures, runs the recovery routine against each via `runOne`, and
26
+ * returns `{ corpusSize, truncatedCount, recoveredCount, unrecoveredCount,
27
+ * ratePostRecovery, baselineRate, byCategory[] }`. The "rate" we publish
28
+ * is the FRACTION OF FIXTURES WHERE A TRUNCATION OCCURRED AND RECOVERY
29
+ * COULD NOT RESTORE THE EXPECTED FINAL STATE — i.e. the truncation rate
30
+ * that SURVIVES recovery. Clean fixtures (no truncation injected) are
31
+ * part of the denominator because they prove recovery does not corrupt
32
+ * non-truncated runs.
33
+ *
34
+ * * `writeRateArtifact(projectRoot, result)` — atomically persists the
35
+ * measurement to `<projectRoot>/.ijfw/telemetry/truncation-rate.json`,
36
+ * fitting the T21 convergence-telemetry directory convention.
37
+ *
38
+ * NO PRODUCTION DEPENDENCIES; ESM; Node >=18.
39
+ */
40
+
41
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
42
+ import { join, dirname } from 'node:path';
43
+
44
+ import { writeAtomic } from '../lib/atomic-io.js';
45
+ import { query } from '../orchestrator/state-sdk.js';
46
+
47
+ // -- Constants ------------------------------------------------------------
48
+
49
+ /**
50
+ * Brief-locked threshold (BUILD-PLAN T20): the measured truncation rate that
51
+ * survives recovery MUST be at or below this fraction to pass. Halves the
52
+ * documented 62% baseline (see PLAN-CROSS-AUDIT-ADJUDICATION row M6).
53
+ */
54
+ export const TRUNCATION_RATE_THRESHOLD = 0.31;
55
+
56
+ /**
57
+ * Documented v1.4.x truncation-rate baseline (the rate WITHOUT this T20
58
+ * recovery layer). Published so the artifact is self-describing — anyone
59
+ * reading `.ijfw/telemetry/truncation-rate.json` can compute the improvement
60
+ * factor against this number.
61
+ */
62
+ export const TRUNCATION_BASELINE_RATE = 0.62;
63
+
64
+ /** Default expected terminal verb for a subagent's event stream. */
65
+ export const DEFAULT_TERMINAL_VERB = 'subagent.post-done';
66
+
67
+ // -- Detection -----------------------------------------------------------
68
+
69
+ /**
70
+ * Classify a subagent's event stream + journal as truncated-or-not.
71
+ *
72
+ * @param {object} args
73
+ * @param {object[]} args.events the event stream (T5 envelope shape)
74
+ * @param {object[]} [args.journal] intent-journal records visible to recovery
75
+ * @param {string} [args.expectedTerminalVerb] wave's expected terminal verb
76
+ * (default `subagent.post-done`)
77
+ * @returns {{truncated: false|string, reason: string, lastEvent: object|null}}
78
+ */
79
+ export function detectTruncation({
80
+ events, journal, expectedTerminalVerb,
81
+ } = {}) {
82
+ const ev = Array.isArray(events) ? events : [];
83
+ const j = Array.isArray(journal) ? journal : [];
84
+ const terminal = typeof expectedTerminalVerb === 'string' && expectedTerminalVerb
85
+ ? expectedTerminalVerb
86
+ : DEFAULT_TERMINAL_VERB;
87
+
88
+ const lastEvent = ev.length > 0 ? ev[ev.length - 1] : null;
89
+
90
+ // Build a quick view of journal partial-vs-committed.
91
+ const commits = new Set();
92
+ const begins = new Map();
93
+ for (const r of j) {
94
+ if (!r || typeof r.verbId !== 'string') continue;
95
+ if (r.phase === 'commit') commits.add(r.verbId);
96
+ else if (r.phase === 'begin') begins.set(r.verbId, r);
97
+ }
98
+ const openPartials = [];
99
+ for (const [verbId, beginRec] of begins) {
100
+ if (!commits.has(verbId)) openPartials.push(beginRec);
101
+ }
102
+
103
+ // 1. outcome:'error' tail — T19 contract: the subagent's stream ending
104
+ // with an error verb is the canonical truncation tell.
105
+ if (lastEvent && lastEvent.outcome === 'error') {
106
+ return { truncated: 'error-terminated', reason: `last event outcome='error' (verb=${lastEvent.verb})`, lastEvent };
107
+ }
108
+
109
+ // 2. No events at all BUT journal carries an open begin → subagent
110
+ // started a mutating verb and never emitted (truncated before tap).
111
+ if (ev.length === 0 && openPartials.length > 0) {
112
+ return {
113
+ truncated: 'no-events-open-begin',
114
+ reason: `no events, ${openPartials.length} open begin(s) in journal`,
115
+ lastEvent: null,
116
+ };
117
+ }
118
+
119
+ // 3. Last event is not the expected terminal verb. We accept either an
120
+ // exact terminal-verb match OR an outcome marker explicitly indicating
121
+ // a clean exit ('ok' with the terminal verb). Anything else with an
122
+ // open partial OR with no terminal at all is treated as truncated.
123
+ const lastIsTerminal = lastEvent && lastEvent.verb === terminal
124
+ && (lastEvent.outcome === 'ok' || lastEvent.outcome === 'advisory');
125
+
126
+ if (!lastIsTerminal && openPartials.length > 0) {
127
+ return {
128
+ truncated: 'open-partial',
129
+ reason: `open begin without commit (${openPartials.length}) and no terminal verb`,
130
+ lastEvent,
131
+ };
132
+ }
133
+
134
+ if (!lastIsTerminal && ev.length > 0) {
135
+ return {
136
+ truncated: 'missing-terminal',
137
+ reason: `last event verb='${lastEvent.verb}' (expected terminal '${terminal}')`,
138
+ lastEvent,
139
+ };
140
+ }
141
+
142
+ // 4. Clean: last event is the expected terminal AND no open partials.
143
+ if (openPartials.length > 0) {
144
+ // Edge: terminal verb fired but a partial mutating verb never committed.
145
+ // Treat as truncated — recovery should still seal/rollback the partial.
146
+ return {
147
+ truncated: 'open-partial',
148
+ reason: `terminal verb emitted but ${openPartials.length} open begin(s) remain`,
149
+ lastEvent,
150
+ };
151
+ }
152
+
153
+ return { truncated: false, reason: 'clean exit', lastEvent };
154
+ }
155
+
156
+ // -- Recovery ------------------------------------------------------------
157
+
158
+ /**
159
+ * Apply T4's `state.replay` to recover a truncated subagent. Snapshot-rolls
160
+ * back overwrite-verb partials; seals append-verb partials in place; leaves
161
+ * already-committed verbs untouched.
162
+ *
163
+ * @param {object} args
164
+ * @param {string} args.projectRoot the wave's project root
165
+ * @param {string} args.waveId the truncated subagent's wave
166
+ * @param {string} args.subId the truncated subagent's id
167
+ * @param {string} [args.sinceVerbId] scope replay to verbs at/after this id
168
+ * (default — full journal)
169
+ * @returns {Promise<{recovered:boolean, replayResult:object, reason:string}>}
170
+ */
171
+ export async function recoverSubagent({
172
+ projectRoot, waveId, subId, sinceVerbId,
173
+ } = {}) {
174
+ if (typeof projectRoot !== 'string' || !projectRoot) {
175
+ throw new Error('recovery/truncation: projectRoot required');
176
+ }
177
+ const payload = {};
178
+ if (typeof sinceVerbId === 'string' && sinceVerbId) payload.sinceVerbId = sinceVerbId;
179
+ const ctx = { projectRoot, subagentId: subId };
180
+ let replayResult;
181
+ try {
182
+ replayResult = await query('state.replay', payload, ctx);
183
+ } catch (err) {
184
+ return {
185
+ recovered: false,
186
+ replayResult: null,
187
+ reason: `replay threw: ${err?.message || err}`,
188
+ waveId,
189
+ subId,
190
+ };
191
+ }
192
+ const sealed = Array.isArray(replayResult?.sealed) ? replayResult.sealed.length : 0;
193
+ const rolledBack = Array.isArray(replayResult?.rolledBack) ? replayResult.rolledBack.length : 0;
194
+ const skipped = Array.isArray(replayResult?.skipped) ? replayResult.skipped.length : 0;
195
+ return {
196
+ recovered: replayResult?.ok === true,
197
+ replayResult,
198
+ reason: `replay ok=${replayResult?.ok} sealed=${sealed} rolledBack=${rolledBack} skipped=${skipped}`,
199
+ waveId,
200
+ subId,
201
+ };
202
+ }
203
+
204
+ // -- Corpus harness ------------------------------------------------------
205
+
206
+ /**
207
+ * Discover fixture subdirectories under a corpus root. Every subdir must
208
+ * carry a `meta.json`; subdirs without one are skipped (allows README files /
209
+ * stray dirs to coexist).
210
+ *
211
+ * @param {string} corpusDir
212
+ * @returns {{id:string, dir:string, meta:object}[]}
213
+ */
214
+ export function listFixtures(corpusDir) {
215
+ if (!existsSync(corpusDir)) return [];
216
+ const out = [];
217
+ for (const name of readdirSync(corpusDir).sort()) {
218
+ const dir = join(corpusDir, name);
219
+ const metaPath = join(dir, 'meta.json');
220
+ if (!existsSync(metaPath)) continue;
221
+ let meta;
222
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf8')); }
223
+ catch { continue; }
224
+ out.push({ id: name, dir, meta });
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * Run the recovery routine across a corpus + return a measurement.
231
+ *
232
+ * The caller supplies `runOne(fixture)` — a function that materialises the
233
+ * fixture into a real temp project, invokes `recoverSubagent`, and returns
234
+ * `{ truncated, recovered, expectedFinalStateMatches, category }`. We keep
235
+ * runOne pluggable so the test can use real `query()` while a future caller
236
+ * could plug in a different I/O backend without changing this aggregator.
237
+ *
238
+ * @param {object} args
239
+ * @param {{id:string, meta:object}[]} args.fixtures
240
+ * @param {(fixture:object) => Promise<{truncated:string|false, recovered:boolean, expectedFinalStateMatches:boolean, category:string}>} args.runOne
241
+ * @returns {Promise<{corpusSize, truncatedCount, recoveredCount,
242
+ * unrecoveredCount, ratePostRecovery, baselineRate,
243
+ * byCategory:Array, fixtures:Array}>}
244
+ */
245
+ export async function measureTruncationRate({ fixtures, runOne }) {
246
+ if (!Array.isArray(fixtures)) throw new Error('measureTruncationRate: fixtures[] required');
247
+ if (typeof runOne !== 'function') throw new Error('measureTruncationRate: runOne fn required');
248
+
249
+ const perFixture = [];
250
+ const byCategoryMap = new Map();
251
+ for (const fx of fixtures) {
252
+ const r = await runOne(fx);
253
+ const row = {
254
+ id: fx.id,
255
+ category: r.category || fx.meta?.category || 'unknown',
256
+ truncated: r.truncated,
257
+ recovered: r.recovered,
258
+ expectedFinalStateMatches: r.expectedFinalStateMatches,
259
+ };
260
+ perFixture.push(row);
261
+ const key = row.category;
262
+ if (!byCategoryMap.has(key)) {
263
+ byCategoryMap.set(key, {
264
+ category: key, total: 0, truncated: 0, unrecovered: 0,
265
+ });
266
+ }
267
+ const bucket = byCategoryMap.get(key);
268
+ bucket.total += 1;
269
+ if (row.truncated) bucket.truncated += 1;
270
+ if (!row.expectedFinalStateMatches) bucket.unrecovered += 1;
271
+ }
272
+
273
+ const corpusSize = perFixture.length;
274
+ const truncatedCount = perFixture.filter((r) => r.truncated).length;
275
+ // A fixture COUNTS AS UNRECOVERED when its post-recovery state does not
276
+ // match the expected final state. Clean fixtures contribute 0 (their
277
+ // expectedFinalStateMatches is required to be true regardless).
278
+ const unrecoveredCount = perFixture.filter((r) => !r.expectedFinalStateMatches).length;
279
+ const recoveredCount = corpusSize - unrecoveredCount;
280
+ const ratePostRecovery = corpusSize === 0 ? 0 : unrecoveredCount / corpusSize;
281
+
282
+ return {
283
+ corpusSize,
284
+ truncatedCount,
285
+ recoveredCount,
286
+ unrecoveredCount,
287
+ ratePostRecovery,
288
+ baselineRate: TRUNCATION_BASELINE_RATE,
289
+ threshold: TRUNCATION_RATE_THRESHOLD,
290
+ passed: ratePostRecovery <= TRUNCATION_RATE_THRESHOLD,
291
+ byCategory: Array.from(byCategoryMap.values()).sort(
292
+ (a, b) => a.category.localeCompare(b.category),
293
+ ),
294
+ fixtures: perFixture,
295
+ measuredAt: new Date().toISOString(),
296
+ };
297
+ }
298
+
299
+ // -- Artifact emission --------------------------------------------------
300
+
301
+ /**
302
+ * Persist the measurement under `<projectRoot>/.ijfw/telemetry/`. Atomic
303
+ * write (tmp-rename) via `writeAtomic`. Returns the absolute path written.
304
+ *
305
+ * @param {string} projectRoot
306
+ * @param {object} result value returned by `measureTruncationRate`
307
+ * @returns {string} absolute path of the artifact
308
+ */
309
+ export function writeRateArtifact(projectRoot, result) {
310
+ if (typeof projectRoot !== 'string' || !projectRoot) {
311
+ throw new Error('recovery/truncation: writeRateArtifact requires projectRoot');
312
+ }
313
+ const path = join(projectRoot, '.ijfw', 'telemetry', 'truncation-rate.json');
314
+ if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
315
+ writeAtomic(path, `${JSON.stringify(result, null, 2)}\n`);
316
+ return path;
317
+ }
package/src/redactor.js CHANGED
@@ -16,10 +16,31 @@ const PATTERNS = [
16
16
  { re: /ghp_[A-Za-z0-9]{20,}/g, label: 'github' },
17
17
  { re: /github_pat_[A-Za-z0-9_]{20,}/g, label: 'github' },
18
18
  { re: /gh[ousr]_[A-Za-z0-9]{30,}/g, label: 'github' }, // gho_/ghu_/ghs_/ghr_
19
+ // GitLab Personal Access Tokens (glpat-) + CI job tokens (glcbt-).
20
+ // GitLab PAT spec: `glpat-` + 20 base64url chars. We accept 20+ to be future-proof.
21
+ { re: /glpat-[A-Za-z0-9_-]{20,}/g, label: 'gitlab' },
22
+ { re: /glcbt-[A-Za-z0-9_-]{20,}/g, label: 'gitlab' }, // CI build token
23
+ { re: /gldt-[A-Za-z0-9_-]{20,}/g, label: 'gitlab' }, // GitLab deploy token
19
24
  // AWS permanent access key ID (AKIA) + temporary (ASIA) key ID.
20
25
  { re: /(?:AKIA|ASIA)[0-9A-Z]{16}/g, label: 'aws' },
26
+ // AWS secret access key — contextualized because bare 40-char base64 is
27
+ // catastrophically false-positive. Match only when paired with a known key
28
+ // name (AWS_SECRET_ACCESS_KEY=, aws_secret=, etc).
29
+ // eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output; pattern requires contextual prefix and is anchored to AWS secret key naming conventions.
30
+ { re: /(?:AWS|aws)[_-]?(?:SECRET|secret)[_-]?(?:ACCESS[_-]?)?(?:KEY|key)\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/g, label: 'aws' },
31
+ // Discord bot tokens — three base64url segments with fixed first-segment width (24).
32
+ // Must come BEFORE the generic JWT pattern so the structural difference is preserved.
33
+ // Format: <24 chars>.<6 chars>.<27+ chars>. Exclude any match whose first
34
+ // segment starts with `eyJ` to avoid stealing JWT matches.
35
+ // eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output; this is an anchored secret pattern, not user-controlled matching logic.
36
+ { re: /(?<![A-Za-z0-9_])(?!eyJ)[A-Za-z0-9_-]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}(?![A-Za-z0-9_])/g, label: 'discord' },
21
37
  // Authorization: Bearer <token>.
22
38
  { re: /Bearer\s+[A-Za-z0-9._~+/=-]{10,}/g, label: 'bearer' },
39
+ // Generic JWT (three base64url segments). Catches Supabase service-role keys,
40
+ // Auth0/Okta/Firebase ID tokens, and any bare JWT in tool output. Anchored on
41
+ // `eyJ` header (base64url of `{`) which all JWTs share.
42
+ // eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output; the `eyJ` anchor and three-segment shape minimise false positives.
43
+ { re: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, label: 'jwt' },
23
44
  // Slack bot / user / legacy tokens.
24
45
  { re: /xox[baprs]-[A-Za-z0-9-]{10,}/g, label: 'slack' },
25
46
  // Stripe live + test secret keys.
@@ -29,6 +50,32 @@ const PATTERNS = [
29
50
  { re: /npm_[A-Za-z0-9]{36}/g, label: 'npm' },
30
51
  // HuggingFace user tokens.
31
52
  { re: /hf_[A-Za-z0-9]{34,}/g, label: 'huggingface' },
53
+ // OpenAI organization IDs — `org-` followed by 24 alphanumeric chars.
54
+ // Not a secret per se, but often pasted alongside the API key and a
55
+ // valuable target identifier; redact in case of cross-tenant leakage.
56
+ { re: /\borg-[A-Za-z0-9]{24}\b/g, label: 'openai-org' },
57
+ // Vercel API tokens — 24 hex/alnum chars after a `vrcl_` style prefix; the
58
+ // newer (2026) Vercel CLI emits tokens prefixed with `vercel_pat_` or
59
+ // contextualised env vars. Cover both the explicit prefix and the env-var
60
+ // contextual form.
61
+ { re: /vercel_pat_[A-Za-z0-9_]{20,}/gi, label: 'vercel' },
62
+ // eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output and requires a Vercel context prefix before matching a token.
63
+ { re: /(?:VERCEL|vercel)[_-]?(?:API[_-]?)?TOKEN\s*[:=]\s*['"]?[A-Za-z0-9_-]{24,}['"]?/g, label: 'vercel' },
64
+ // Supabase service-role / anon keys. The JWT pattern above already catches
65
+ // the typical service-role JWT shape; this rule covers the legacy
66
+ // `sbp_` (project access) and `sb_` style tokens distributed via the CLI.
67
+ { re: /sbp_[A-Za-z0-9]{40,}/g, label: 'supabase' },
68
+ // Notion integration tokens — `secret_` + 43 chars (legacy) and `ntn_` + alnum (2026).
69
+ { re: /secret_[A-Za-z0-9]{43}/g, label: 'notion' },
70
+ { re: /ntn_[A-Za-z0-9_]{40,}/g, label: 'notion' },
71
+ // Linear API keys — `lin_api_` + alnum, `lin_oauth_` + alnum.
72
+ { re: /lin_api_[A-Za-z0-9]{32,}/g, label: 'linear' },
73
+ { re: /lin_oauth_[A-Za-z0-9]{32,}/g, label: 'linear' },
74
+ // Twilio Account SID + Auth Token. SID is `AC` + 32 hex; auth token is
75
+ // 32 hex which is high-FP so we require contextual naming.
76
+ { re: /AC[a-f0-9]{32}/g, label: 'twilio' },
77
+ // eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output; pattern requires a Twilio context prefix before matching.
78
+ { re: /(?:TWILIO|twilio)[_-]?(?:AUTH[_-]?)?TOKEN\s*[:=]\s*['"]?[a-f0-9]{32}['"]?/g, label: 'twilio' },
32
79
  // Azure Storage connection-string AccountKey (base64, 88 chars with padding).
33
80
  { re: /AccountKey=[A-Za-z0-9+/]{86,88}={0,2}/g, label: 'azure' },
34
81
  // GCP service-account private key PEM block.
@@ -47,6 +94,18 @@ const PATTERNS = [
47
94
  { re: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g, label: 'webhook' },
48
95
  { re: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/g, label: 'webhook' },
49
96
  { re: /https:\/\/[\w-]+\.webhook\.office\.com\/webhookb2\/[\w@/-]+/g, label: 'webhook' },
97
+ // v1.5.0 audit-LOW-tok-L1: PII patterns.
98
+ // Email -- conservative RFC-ish match. Bounds local-part to 1-64 chars,
99
+ // domain to a label.label pattern with TLD ≥2 chars. Avoids matching
100
+ // bare `@handle` references in prose.
101
+ { re: /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}(?![A-Za-z0-9])/g, label: 'email' },
102
+ // Phone -- E.164 international form (+CCNNNNNNNNNN, 10-15 digits total)
103
+ // plus common US/EU formats with separators. Anchored to word boundaries
104
+ // and a digit-density threshold to avoid eating commit SHAs / version
105
+ // strings. Conservative: requires either a leading `+` or one of
106
+ // `(NNN)`, `NNN-NNN-NNNN`, `NNN.NNN.NNNN` shapes.
107
+ { re: /(?<![\d+])\+[1-9]\d{1,3}[\s.-]?\d{2,4}[\s.-]?\d{2,4}[\s.-]?\d{2,4}(?![\d])/g, label: 'phone' },
108
+ { re: /(?<![\d-])(?:\(\d{3}\)\s?|\d{3}[.-])\d{3}[.-]\d{4}(?![\d-])/g, label: 'phone' },
50
109
  ];
51
110
 
52
111
  // INLINE rules match `key=value` style assignments. Value regex excludes
@@ -78,7 +137,7 @@ export function redactSecrets(s) {
78
137
  return out;
79
138
  }
80
139
 
81
- // classify(value) -> { clean: boolean, redacted_kind: string | null }
140
+ // classifyAnchored(value) -> { clean: boolean, redacted_kind: string | null }
82
141
  //
83
142
  // D-PILLAR-SPEC section 3 surface used by D2 entity extraction. Passes the
84
143
  // value through the same PATTERNS list redactSecrets uses; if any pattern
@@ -90,20 +149,30 @@ export function redactSecrets(s) {
90
149
  //
91
150
  // Important: PATTERNS are anchored implicitly via length minimums (e.g.
92
151
  // `sk-(?:proj-)?[A-Za-z0-9_-]{32,}`), but to avoid classifying a long file
93
- // path that happens to contain a token-shaped substring, classify() rejects
94
- // only when the pattern matches the FULL trimmed value. File paths and
95
- // function/identifier names are always shorter than the secret patterns'
152
+ // path that happens to contain a token-shaped substring, classifyAnchored()
153
+ // rejects only when the pattern matches the FULL trimmed value. File paths
154
+ // and function/identifier names are always shorter than the secret patterns'
96
155
  // minimum lengths, so the conservative cut-line is "match must equal the
97
156
  // candidate" -- a substring match doesn't trigger classification.
98
- export function classify(value) {
157
+ //
158
+ // Naming (v1.5.0 audit LOW #13): renamed conceptually from `classify` to
159
+ // `classifyAnchored` to signal the asymmetric contract -- callers must pass
160
+ // the candidate as a discrete value, NOT a free-form text body that contains
161
+ // the value somewhere inside. `classify` is retained as a back-compat alias.
162
+ export function classifyAnchored(value) {
99
163
  if (typeof value !== 'string') return { clean: true, redacted_kind: null };
100
164
  const v = value.trim();
101
165
  if (!v) return { clean: true, redacted_kind: null };
102
166
  for (const { re, label } of PATTERNS) {
103
167
  // Build a fresh non-global RegExp per check; the source PATTERNS use /g
104
- // for redactSecrets but classify needs a single full-value match.
168
+ // for redactSecrets but classifyAnchored needs a single full-value match.
105
169
  const r = new RegExp(`^(?:${re.source})$`, re.flags.replace('g', ''));
106
170
  if (r.test(v)) return { clean: false, redacted_kind: label };
107
171
  }
108
172
  return { clean: true, redacted_kind: null };
109
173
  }
174
+
175
+ // Back-compat alias. New callers should prefer `classifyAnchored` so the
176
+ // "value must be the whole candidate, not embedded in prose" contract is
177
+ // obvious at the call site.
178
+ export const classify = classifyAnchored;
@@ -15,6 +15,21 @@
15
15
  * Fail-closed invariant: if the file exists but is unparseable / malformed,
16
16
  * the caller MUST treat it as a deny. A corrupted state file is not a free
17
17
  * pass -- that would defeat the sandbox.
18
+ *
19
+ * ## Cross-platform enforcement boundary
20
+ * This module is the single tier-2 enforcement point for platforms without
21
+ * a native pre-tool hook lifecycle: Gemini CLI, Cursor, Windsurf, and
22
+ * Copilot (VS Code). All MCP tool calls from those platforms pass through
23
+ * `checkPermission()` at `server.js:98` BEFORE any handler executes.
24
+ * Hook-lifecycle platforms (Claude Code, Codex, Hermes, Wayland) get
25
+ * parallel enforcement via their own pre-tool-use hook scripts in addition
26
+ * to this MCP boundary.
27
+ *
28
+ * Coverage:
29
+ * - `test-runtime-mediator.js` unit-level primitives
30
+ * - `test-mcp-gate-integration.js` integration through the exported
31
+ * `gatePermissionAndQuota` (server.js:98)
32
+ * — locks in the four no-hook platforms.
18
33
  */
19
34
 
20
35
  import { readFile, mkdir, appendFile, rename, stat } from 'node:fs/promises';
package/src/sanitizer.js CHANGED
@@ -20,6 +20,16 @@ export function sanitizeContent(s) {
20
20
  // U+200B-U+200F, U+202A-U+202E, U+2066-U+2069, U+FEFF
21
21
  out = out.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, '');
22
22
 
23
+ // 2b. Strip Unicode tag-block chars (U+E0000-U+E007F) \u2014 the "ASCII Smuggler"
24
+ // prompt-injection vector. These codepoints are invisible to humans but map
25
+ // 1:1 to printable ASCII (U+E0041 = "A", U+E0061 = "a", etc.) and many LLMs
26
+ // interpret them as the corresponding text. An attacker can hide an
27
+ // instruction like "ignore all prior" inside otherwise-benign memory content.
28
+ // v1.5.1 H1.4 (audit memory-engine.md F-SEC-3).
29
+ // Ref: https://embracethered.com/blog/posts/2024/hiding-and-finding-text-with-unicode-tags/
30
+ // eslint-disable-next-line security/detect-unsafe-regex -- single-char Unicode range class; no quantifier; not backtrack-exploitable
31
+ out = out.replace(/[\u{E0000}-\u{E007F}]/gu, '');
32
+
23
33
  // 3. Defang ANY heading prefix (1+ hashes, optional whitespace) -- entry must
24
34
  // never produce a structural ## section that mimics a journal timestamp.
25
35
  out = out.replace(/^[ \t]*#+[ \t]+/gm, '> ');
@@ -0,0 +1,139 @@
1
+ // r17 (cold-tier wire-up): hybrid BM25+vector rerank step for searchMemory.
2
+ //
3
+ // Pure module, no side effects on import. server.js imports the rerank
4
+ // helper; test-search-hybrid.js imports the same helper without dragging in
5
+ // the MCP server's stdio bootstrap (which would hang the test runner).
6
+ //
7
+ // Behavior:
8
+ // - When IJFW_VECTORS is off (default) OR no opts.embedder is injected,
9
+ // this is a pure no-op and returns the input `ranked` array unchanged.
10
+ // - When IJFW_VECTORS=on AND the embedder is available (either via
11
+ // @xenova/transformers installed OR via opts.embedder injection), the
12
+ // BM25 top-K is reranked using cosine similarity over each result's
13
+ // snippet, blended with the BM25 score via vectors.hybridRerank.
14
+ // - Any failure during embedding falls back to BM25 with a single
15
+ // stderr warning per distinct reason. Never throws into the caller.
16
+
17
+ import { vectorsEnabled, getEmbedder, hybridRerank } from './vectors.js';
18
+ // v1.5.0 wire-W1.C: persistent embedding cache so repeated queries over a
19
+ // stable corpus don't pay the per-snippet embed cost twice. Cache is keyed
20
+ // on sha256(snippet) + model_id; falls back to live re-embed on any miss
21
+ // or when opts.db is absent.
22
+ import {
23
+ cacheKeyFor,
24
+ getCachedEmbedding,
25
+ setCachedEmbedding,
26
+ hasVectorCache,
27
+ } from './memory/embedding-cache.js';
28
+
29
+ // Throttle stderr noise — single warning per distinct failure reason.
30
+ let _vectorWarnedReason = null;
31
+
32
+ /**
33
+ * Optional hybrid rerank step. Returns the input `ranked` unchanged when
34
+ * vectors are disabled or the embedder cannot be loaded. On success, returns
35
+ * the merged ranking from `hybridRerank` (BM25 score + cosine similarity).
36
+ *
37
+ * @param {string} query
38
+ * @param {Array<{id, score, snippet, meta}>} ranked - BM25 top-K
39
+ * @param {object} opts
40
+ * @param {object} [opts.embedder] - injected for tests; defaults to getEmbedder()
41
+ * @param {number} [opts.wBm25] - BM25 weight (default 0.6 via vectors.js)
42
+ * @param {number} [opts.wVec] - vector weight (default 0.4 via vectors.js)
43
+ * @returns {Promise<Array>} reranked list (or `ranked` on no-op)
44
+ */
45
+ export async function maybeRerankWithVectors(query, ranked, opts = {}) {
46
+ // Skip the embedder load entirely when vectors are off — env check is the
47
+ // cheap path. Tests can force the embedder path by passing opts.embedder.
48
+ if (!opts.embedder && !vectorsEnabled()) return ranked;
49
+
50
+ let embedder = opts.embedder;
51
+ if (!embedder) {
52
+ try {
53
+ embedder = await getEmbedder();
54
+ } catch (err) {
55
+ // getEmbedder shouldn't throw (it returns {available:false}), but defend.
56
+ embedder = { available: false, reason: `getEmbedder-threw: ${err.message}` };
57
+ }
58
+ }
59
+ if (!embedder || !embedder.available) {
60
+ const reason = embedder?.reason || 'unavailable';
61
+ if (_vectorWarnedReason !== reason) {
62
+ _vectorWarnedReason = reason;
63
+ // Stderr only; stdout is the JSON-RPC framing channel.
64
+ process.stderr.write(
65
+ `IJFW: cold-tier vectors requested (IJFW_VECTORS=on) but embedder unavailable (${reason}). Falling back to BM25.\n`
66
+ );
67
+ }
68
+ return ranked;
69
+ }
70
+
71
+ try {
72
+ // v1.5.0 audit MED #6 (memory-engine.md F-SPD-1): batch-embed the
73
+ // query + all snippets in parallel via Promise.all. The previous
74
+ // sequential `for (... await embedder.embed)` loop serialised K+1
75
+ // calls -- when `embedder.embed` is an HTTP round-trip (e.g. a
76
+ // remote inference server) p99 was ~600ms for the top-K=10 case.
77
+ // Concurrent dispatch drops that to ~80ms (single-round-trip cost
78
+ // plus per-call overhead). For the local @xenova/transformers
79
+ // pipeline the calls still resolve serially under the hood, but
80
+ // Promise.all is no worse than the sequential await and lets future
81
+ // batch-aware embedders win without further changes.
82
+ //
83
+ // v1.5.0 wire-W1.C: when opts.db + opts.modelId are supplied AND the
84
+ // memory_entry_vectors table exists, route each embed through the
85
+ // persistent cache. The cache is keyed on sha256(text), so a second
86
+ // call with the same query + corpus serves entirely from SQLite —
87
+ // zero embedder calls, zero re-embed cost. The query embedding is
88
+ // also cached (queries repeat in long sessions / dashboard polls).
89
+ //
90
+ // cacheReady flips to false when the table is missing OR no db is
91
+ // passed; the rerank then degrades to the existing live-embed path
92
+ // with no observable behavior change.
93
+ const snippets = ranked.map((r) => r.snippet || '');
94
+ const cacheDb = opts.db || null;
95
+ const modelId = opts.modelId || embedder.modelId || null;
96
+ const cacheReady = cacheDb && typeof modelId === 'string' && modelId.length > 0 && hasVectorCache(cacheDb);
97
+
98
+ const embedWithCache = async (text) => {
99
+ if (!cacheReady) return embedder.embed(text);
100
+ const key = cacheKeyFor(text);
101
+ if (key === null) return embedder.embed(text);
102
+ const hit = getCachedEmbedding(cacheDb, key, modelId);
103
+ if (hit) return hit;
104
+ const vec = await embedder.embed(text);
105
+ setCachedEmbedding(cacheDb, key, modelId, vec);
106
+ return vec;
107
+ };
108
+
109
+ const [queryVec, ...docVecs] = await Promise.all([
110
+ embedWithCache(query),
111
+ ...snippets.map((s) => embedWithCache(s)),
112
+ ]);
113
+ const vectorScores = new Map();
114
+ for (let i = 0; i < ranked.length; i++) {
115
+ const docVec = docVecs[i] || [];
116
+ // Cosine over L2-normalized vectors === dot product.
117
+ let dot = 0;
118
+ const n = Math.min(queryVec.length, docVec.length);
119
+ for (let j = 0; j < n; j++) dot += queryVec[j] * docVec[j];
120
+ vectorScores.set(ranked[i].id, dot);
121
+ }
122
+ return hybridRerank(ranked, vectorScores, {
123
+ wBm25: opts.wBm25,
124
+ wVec: opts.wVec,
125
+ });
126
+ } catch (err) {
127
+ const reason = `embed-failed: ${err.message}`;
128
+ if (_vectorWarnedReason !== reason) {
129
+ _vectorWarnedReason = reason;
130
+ process.stderr.write(
131
+ `IJFW: cold-tier vectors hit an error mid-pipeline (${reason}). Falling back to BM25 for this query.\n`
132
+ );
133
+ }
134
+ return ranked;
135
+ }
136
+ }
137
+
138
+ // Test seam — reset the once-per-process warning gate.
139
+ export function _resetVectorWarnGate() { _vectorWarnedReason = null; }