agent-working-memory 0.17.0 → 0.18.1

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 (165) hide show
  1. package/README.md +321 -319
  2. package/dist/adapters/common.d.ts +34 -0
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +528 -446
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/api/routes.d.ts.map +1 -1
  7. package/dist/api/routes.js +19 -0
  8. package/dist/api/routes.js.map +1 -1
  9. package/dist/cli/migrate.d.ts.map +1 -1
  10. package/dist/cli/migrate.js +31 -29
  11. package/dist/cli/migrate.js.map +1 -1
  12. package/dist/core/asked-value.d.ts +37 -0
  13. package/dist/core/asked-value.d.ts.map +1 -0
  14. package/dist/core/asked-value.js +77 -0
  15. package/dist/core/asked-value.js.map +1 -0
  16. package/dist/core/conflicts.d.ts +89 -0
  17. package/dist/core/conflicts.d.ts.map +1 -0
  18. package/dist/core/conflicts.js +267 -0
  19. package/dist/core/conflicts.js.map +1 -0
  20. package/dist/core/embeddings.d.ts +14 -2
  21. package/dist/core/embeddings.d.ts.map +1 -1
  22. package/dist/core/embeddings.js +32 -10
  23. package/dist/core/embeddings.js.map +1 -1
  24. package/dist/core/format-recall.d.ts +21 -1
  25. package/dist/core/format-recall.d.ts.map +1 -1
  26. package/dist/core/format-recall.js +60 -5
  27. package/dist/core/format-recall.js.map +1 -1
  28. package/dist/core/ml-worker.d.ts +24 -1
  29. package/dist/core/ml-worker.d.ts.map +1 -1
  30. package/dist/core/ml-worker.js +165 -4
  31. package/dist/core/ml-worker.js.map +1 -1
  32. package/dist/core/model-daemon-client.d.ts +77 -0
  33. package/dist/core/model-daemon-client.d.ts.map +1 -0
  34. package/dist/core/model-daemon-client.js +383 -0
  35. package/dist/core/model-daemon-client.js.map +1 -0
  36. package/dist/core/model-daemon-server.d.ts +71 -0
  37. package/dist/core/model-daemon-server.d.ts.map +1 -0
  38. package/dist/core/model-daemon-server.js +363 -0
  39. package/dist/core/model-daemon-server.js.map +1 -0
  40. package/dist/core/model-daemon.d.ts +137 -0
  41. package/dist/core/model-daemon.d.ts.map +1 -0
  42. package/dist/core/model-daemon.js +231 -0
  43. package/dist/core/model-daemon.js.map +1 -0
  44. package/dist/core/query-expander.d.ts +5 -0
  45. package/dist/core/query-expander.d.ts.map +1 -1
  46. package/dist/core/query-expander.js +3 -2
  47. package/dist/core/query-expander.js.map +1 -1
  48. package/dist/core/recall-config.d.ts +1 -1
  49. package/dist/core/recall-config.d.ts.map +1 -1
  50. package/dist/core/recall-config.js +5 -0
  51. package/dist/core/recall-config.js.map +1 -1
  52. package/dist/core/rerank-window.d.ts +7 -2
  53. package/dist/core/rerank-window.d.ts.map +1 -1
  54. package/dist/core/rerank-window.js +9 -4
  55. package/dist/core/rerank-window.js.map +1 -1
  56. package/dist/core/rerank2.d.ts +52 -2
  57. package/dist/core/rerank2.d.ts.map +1 -1
  58. package/dist/core/rerank2.js +60 -3
  59. package/dist/core/rerank2.js.map +1 -1
  60. package/dist/core/reranker.d.ts +19 -0
  61. package/dist/core/reranker.d.ts.map +1 -1
  62. package/dist/core/reranker.js +76 -12
  63. package/dist/core/reranker.js.map +1 -1
  64. package/dist/core/trust.d.ts +32 -0
  65. package/dist/core/trust.d.ts.map +1 -0
  66. package/dist/core/trust.js +66 -0
  67. package/dist/core/trust.js.map +1 -0
  68. package/dist/core/validity.d.ts +21 -0
  69. package/dist/core/validity.d.ts.map +1 -0
  70. package/dist/core/validity.js +41 -0
  71. package/dist/core/validity.js.map +1 -0
  72. package/dist/core/whoami.d.ts +3 -0
  73. package/dist/core/whoami.d.ts.map +1 -1
  74. package/dist/core/whoami.js +14 -0
  75. package/dist/core/whoami.js.map +1 -1
  76. package/dist/core/write-pipeline.d.ts +48 -0
  77. package/dist/core/write-pipeline.d.ts.map +1 -1
  78. package/dist/core/write-pipeline.js +80 -3
  79. package/dist/core/write-pipeline.js.map +1 -1
  80. package/dist/engine/activation.d.ts.map +1 -1
  81. package/dist/engine/activation.js +27 -4
  82. package/dist/engine/activation.js.map +1 -1
  83. package/dist/engine/consolidation.d.ts.map +1 -1
  84. package/dist/engine/consolidation.js +53 -4
  85. package/dist/engine/consolidation.js.map +1 -1
  86. package/dist/hooks/prime-query.d.ts +57 -0
  87. package/dist/hooks/prime-query.d.ts.map +1 -0
  88. package/dist/hooks/prime-query.js +388 -0
  89. package/dist/hooks/prime-query.js.map +1 -0
  90. package/dist/hooks/prime.d.ts +8 -0
  91. package/dist/hooks/prime.d.ts.map +1 -1
  92. package/dist/hooks/prime.js +21 -2
  93. package/dist/hooks/prime.js.map +1 -1
  94. package/dist/hooks/sidecar.d.ts +7 -0
  95. package/dist/hooks/sidecar.d.ts.map +1 -1
  96. package/dist/hooks/sidecar.js +10 -2
  97. package/dist/hooks/sidecar.js.map +1 -1
  98. package/dist/index.js +11 -3
  99. package/dist/index.js.map +1 -1
  100. package/dist/mcp.js +148 -103
  101. package/dist/mcp.js.map +1 -1
  102. package/dist/model-daemon.d.ts +8 -0
  103. package/dist/model-daemon.d.ts.map +1 -0
  104. package/dist/model-daemon.js +45 -0
  105. package/dist/model-daemon.js.map +1 -0
  106. package/dist/storage/factory.d.ts.map +1 -1
  107. package/dist/storage/factory.js +5 -0
  108. package/dist/storage/factory.js.map +1 -1
  109. package/dist/storage/pglite-schema.d.ts.map +1 -1
  110. package/dist/storage/pglite-schema.js +176 -170
  111. package/dist/storage/pglite-schema.js.map +1 -1
  112. package/dist/storage/pglite.d.ts +5 -0
  113. package/dist/storage/pglite.d.ts.map +1 -1
  114. package/dist/storage/pglite.js +165 -153
  115. package/dist/storage/pglite.js.map +1 -1
  116. package/dist/storage/postgres.d.ts +5 -0
  117. package/dist/storage/postgres.d.ts.map +1 -1
  118. package/dist/storage/postgres.js +165 -153
  119. package/dist/storage/postgres.js.map +1 -1
  120. package/dist/storage/sqlite.d.ts +22 -0
  121. package/dist/storage/sqlite.d.ts.map +1 -1
  122. package/dist/storage/sqlite.js +400 -363
  123. package/dist/storage/sqlite.js.map +1 -1
  124. package/dist/types/engram.d.ts +33 -0
  125. package/dist/types/engram.d.ts.map +1 -1
  126. package/dist/types/engram.js.map +1 -1
  127. package/package.json +1 -1
  128. package/plugin/.claude-plugin/plugin.json +1 -1
  129. package/plugin/README.md +1 -1
  130. package/plugin/bin/awm-mcp-launcher.cjs +2 -2
  131. package/plugin/skills/awm-memory/SKILL.md +11 -2
  132. package/src/adapters/common.ts +881 -802
  133. package/src/api/routes.ts +20 -0
  134. package/src/cli/migrate.ts +309 -307
  135. package/src/core/asked-value.ts +74 -0
  136. package/src/core/conflicts.ts +280 -0
  137. package/src/core/embeddings.ts +35 -10
  138. package/src/core/format-recall.ts +83 -25
  139. package/src/core/ml-worker.ts +458 -281
  140. package/src/core/model-daemon-client.ts +374 -0
  141. package/src/core/model-daemon-server.ts +344 -0
  142. package/src/core/model-daemon.ts +243 -0
  143. package/src/core/query-expander.ts +127 -126
  144. package/src/core/recall-config.ts +5 -0
  145. package/src/core/rerank-window.ts +164 -159
  146. package/src/core/rerank2.ts +143 -82
  147. package/src/core/reranker.ts +78 -11
  148. package/src/core/trust.ts +65 -0
  149. package/src/core/validity.ts +47 -0
  150. package/src/core/whoami.ts +136 -120
  151. package/src/core/write-pipeline.ts +119 -3
  152. package/src/engine/activation.ts +27 -4
  153. package/src/engine/consolidation.ts +950 -899
  154. package/src/hooks/prime-query.ts +375 -0
  155. package/src/hooks/prime.ts +25 -2
  156. package/src/hooks/sidecar.ts +13 -3
  157. package/src/index.ts +255 -248
  158. package/src/mcp.ts +1521 -1475
  159. package/src/model-daemon.ts +48 -0
  160. package/src/storage/factory.ts +25 -19
  161. package/src/storage/pglite-schema.ts +199 -193
  162. package/src/storage/pglite.ts +1513 -1498
  163. package/src/storage/postgres.ts +1613 -1598
  164. package/src/storage/sqlite.ts +2056 -2019
  165. package/src/types/engram.ts +30 -0
@@ -1,82 +1,143 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Second-stage reordering — "rerank the rerank".
5
- *
6
- * THE PROBLEM
7
- * -----------
8
- * Phase 7 blends the cross-encoder's judgement with the composite score:
9
- *
10
- * score = compositeWeight * composite + rerankWeight * rerankerScore
11
- *
12
- * with `rerankWeight` capped at 0.7, so `composite` always keeps at least 30%
13
- * of the vote on final ordering. Composite carries decay, Hebbian and salience
14
- * terms — useful for deciding WHICH candidates deserve consideration, but a
15
- * poor judge of which one actually answers the question.
16
- *
17
- * Measured on LoCoMo (616 answerable probes, tests/rerank2-eval): the blend
18
- * disagrees with the cross-encoder about which item deserves rank 1 on **38.6%**
19
- * of queries. Where that disagreement is decidable — one of the two is the gold
20
- * evidence — the **cross-encoder is right 77% of the time** (61 vs 18).
21
- *
22
- * THE FIX
23
- * -------
24
- * Re-sort only the final returned window by `rerankerScore` alone. Simulated
25
- * gain: **+8.0pp success@1** (39.1% -> 47.1%), fixing 61 queries and breaking
26
- * 12, with every category improving.
27
- *
28
- * WHY THIS IS SAFE
29
- * ----------------
30
- * Applied AFTER the channel-agreement gate, AFTER computeRecallConfidence, and
31
- * AFTER the requireConfidence check. Those read rerankerScore maxima/margins and
32
- * the score distribution. Reordering a window afterwards changes neither its
33
- * membership nor any score, so **adversarial abstention is provably
34
- * unaffected**. Retuning `rerankWeight` inside phase 7 would NOT have that
35
- * property — it shifts `item.score`, hence which items clear `minScore`, which
36
- * is exactly what cost adversarial 73.4->71.0 when the pool was last widened.
37
- *
38
- * Margin-guarded variants were simulated and are strictly worse: gating on a
39
- * >0.15 reranker margin cuts breakage 12->1 but also cuts fixes 61->17.
40
- */
41
-
42
- /** Minimal shape this needs — anything carrying a cross-encoder score. */
43
- export interface RerankScored {
44
- phaseScores: { rerankerScore: number };
45
- }
46
-
47
- /**
48
- * Re-sort the first `k` entries of `ranked` by descending `rerankerScore`,
49
- * leaving the tail untouched. Returns a new array; never mutates the input.
50
- *
51
- * Returns `ranked` unchanged when reordering would be unsound:
52
- * - `k <= 1`, or fewer than 2 entries to order
53
- * - any entry in the window has `rerankerScore <= 0`, which means the
54
- * cross-encoder did not score it (skipped, failed, or timed out). Sorting
55
- * on a zero would scramble an otherwise sound composite ordering — the
56
- * reranker is wrapped in try/catch and a 10s timeout upstream, so this is a
57
- * real path, not a theoretical one.
58
- */
59
- export function reorderByReranker<T extends RerankScored>(ranked: T[], k: number): T[] {
60
- if (k <= 1 || ranked.length <= 1) return ranked;
61
-
62
- const window = Math.min(k, ranked.length);
63
- if (window <= 1) return ranked;
64
-
65
- const head = ranked.slice(0, window);
66
- for (const r of head) {
67
- if (!(r.phaseScores.rerankerScore > 0)) return ranked; // also catches NaN
68
- }
69
-
70
- const sorted = head.slice().sort((a, b) => b.phaseScores.rerankerScore - a.phaseScores.rerankerScore);
71
- return sorted.concat(ranked.slice(window));
72
- }
73
-
74
- /** Window size for the second stage. */
75
- export function rerank2WindowSize(): number {
76
- return Number(process.env.AWM_RERANK2_K ?? 10);
77
- }
78
-
79
- /** Whether the second stage is enabled. Default OFF pending the tracer verdict. */
80
- export function rerank2Enabled(): boolean {
81
- return process.env.AWM_RERANK2 === '1';
82
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Second-stage reordering — "rerank the rerank".
5
+ *
6
+ * THE PROBLEM
7
+ * -----------
8
+ * Phase 7 blends the cross-encoder's judgement with the composite score:
9
+ *
10
+ * score = compositeWeight * composite + rerankWeight * rerankerScore
11
+ *
12
+ * with `rerankWeight` capped at 0.7, so `composite` always keeps at least 30%
13
+ * of the vote on final ordering. Composite carries decay, Hebbian and salience
14
+ * terms — useful for deciding WHICH candidates deserve consideration, but a
15
+ * poor judge of which one actually answers the question.
16
+ *
17
+ * Measured on LoCoMo (616 answerable probes, tests/rerank2-eval): the blend
18
+ * disagrees with the cross-encoder about which item deserves rank 1 on **38.6%**
19
+ * of queries. Where that disagreement is decidable — one of the two is the gold
20
+ * evidence — the **cross-encoder is right 77% of the time** (61 vs 18).
21
+ *
22
+ * THE FIX
23
+ * -------
24
+ * Re-sort only the final returned window by `rerankerScore` alone. Simulated
25
+ * gain: **+8.0pp success@1** (39.1% -> 47.1%), fixing 61 queries and breaking
26
+ * 12, with every category improving.
27
+ *
28
+ * WHY THIS IS SAFE
29
+ * ----------------
30
+ * Applied AFTER the channel-agreement gate, AFTER computeRecallConfidence, and
31
+ * AFTER the requireConfidence check. Those read rerankerScore maxima/margins and
32
+ * the score distribution. Reordering a window afterwards changes neither its
33
+ * membership nor any score, so **adversarial abstention is provably
34
+ * unaffected**. Retuning `rerankWeight` inside phase 7 would NOT have that
35
+ * property — it shifts `item.score`, hence which items clear `minScore`, which
36
+ * is exactly what cost adversarial 73.4->71.0 when the pool was last widened.
37
+ *
38
+ * Margin-guarded variants were simulated and are strictly worse: gating on a
39
+ * >0.15 reranker margin cuts breakage 12->1 but also cuts fixes 61->17.
40
+ */
41
+
42
+ /** Minimal shape this needs — anything carrying a cross-encoder score, and
43
+ * optionally the stale penalty Phase 8c applied to it (see below). */
44
+ export interface RerankScored {
45
+ phaseScores: { rerankerScore: number; stalePenalty?: number };
46
+ /** The Phase 7 blended score (composite + reranker). Carries decay, so recency. */
47
+ score?: number;
48
+ }
49
+
50
+ /**
51
+ * NEAR-TIES (0.18.1, found by MWA when it moved to rerank2-on defaults)
52
+ * -----------------------------------------------------------------------
53
+ * The cross-encoder judges relevance to the query and nothing else. Three
54
+ * same-topic facts written in October, December and March ("the book club
55
+ * chose a fantasy novel / science fiction / a mystery") score within a few
56
+ * hundredths of each other for "what did the book club most recently
57
+ * choose", and a sort by reranker score alone put the newest LAST. The blend
58
+ * carries decay, so it knows which is newest; replacing it outright threw
59
+ * that away exactly where the cross-encoder has nothing to say.
60
+ *
61
+ * The first fix tried was a score epsilon: within `AWM_RERANK2_TIE_EPS` the
62
+ * blended score decides. MEASURED at 0.03 on the private snapshot (2026-10-19):
63
+ * identifier s@1 93.0% -> 78.7%, topic 92.2% -> 84.4%. The cross-encoder's
64
+ * scores saturate near 1.0 for the top candidates, so hundredths separate most
65
+ * top-2 pairs and are informative there; the blend loses to it 77% of the time
66
+ * on disagreements. So the epsilon DEFAULTS TO 0 (off) and exists only for
67
+ * experiments. The recency rule lives where it belongs instead: among results
68
+ * the conflict detector marks as two versions of one fact, the newest goes
69
+ * first (conflicts.ts `newestFirstAmongConflicts`, applied in activation.ts).
70
+ * That cannot touch a suite whose golds are single facts.
71
+ */
72
+ export function rerank2TieEpsilon(): number {
73
+ const v = Number(process.env.AWM_RERANK2_TIE_EPS ?? 0);
74
+ return Number.isFinite(v) && v >= 0 ? v : 0;
75
+ }
76
+
77
+ /**
78
+ * THE STALE-SUPPRESSION HOLE (found 2026-10-19, MWA evaluation probe)
79
+ * --------------------------------------------------------------------
80
+ * Phase 8c multiplies a superseded (or, since 2026-10-19, expired) memory's
81
+ * `score` by 0.15 so its successor dominates. This stage sorted the window by
82
+ * raw `rerankerScore`, which never carried that penalty — so with
83
+ * `AWM_RERANK2=1` (what `awm setup` writes into every Claude Code install) a
84
+ * superseded memory came back at rank 1 ABOVE its replacement whenever the
85
+ * cross-encoder liked its wording better. The supersession test ran with the
86
+ * reranker off, so nothing caught it. `tests/core/rerank2.test.ts` now pins the
87
+ * corrected behaviour: the sort key is `rerankerScore × stalePenalty`, the same
88
+ * factor Phase 8c used, so stale rows sort below current ones here exactly as
89
+ * they do in the blend. The soundness guards below are unchanged.
90
+ */
91
+ export function rerankSortKey(r: RerankScored): number {
92
+ return r.phaseScores.rerankerScore * (r.phaseScores.stalePenalty ?? 1);
93
+ }
94
+
95
+ /**
96
+ * Re-sort the first `k` entries of `ranked` by descending `rerankerScore`,
97
+ * leaving the tail untouched. Returns a new array; never mutates the input.
98
+ *
99
+ * Returns `ranked` unchanged when reordering would be unsound:
100
+ * - `k <= 1`, or fewer than 2 entries to order
101
+ * - any entry in the window has `rerankerScore <= 0`, which means the
102
+ * cross-encoder did not score it (skipped, failed, or timed out). Sorting
103
+ * on a zero would scramble an otherwise sound composite ordering — the
104
+ * reranker is wrapped in try/catch and a 10s timeout upstream, so this is a
105
+ * real path, not a theoretical one.
106
+ */
107
+ export function reorderByReranker<T extends RerankScored>(ranked: T[], k: number): T[] {
108
+ if (k <= 1 || ranked.length <= 1) return ranked;
109
+
110
+ const window = Math.min(k, ranked.length);
111
+ if (window <= 1) return ranked;
112
+
113
+ const head = ranked.slice(0, window);
114
+ for (const r of head) {
115
+ if (!(r.phaseScores.rerankerScore > 0)) return ranked; // also catches NaN
116
+ }
117
+
118
+ const eps = rerank2TieEpsilon();
119
+ const sorted = head.slice().sort((a, b) => {
120
+ const d = rerankSortKey(b) - rerankSortKey(a);
121
+ if (eps > 0 && Math.abs(d) < eps) return (b.score ?? 0) - (a.score ?? 0); // experiment only; see above
122
+ return d;
123
+ });
124
+ return sorted.concat(ranked.slice(window));
125
+ }
126
+
127
+ /** Window size for the second stage. */
128
+ export function rerank2WindowSize(): number {
129
+ return Number(process.env.AWM_RERANK2_K ?? 10);
130
+ }
131
+
132
+ /**
133
+ * Whether the second stage is enabled. Default ON since 2026-10-19; set
134
+ * `AWM_RERANK2=0` to disable. It was opt-in (`=1`) from 2026-08-24, injected
135
+ * into every config `awm setup` wrote — and a config that did not carry the
136
+ * three strings ran degraded for 47 days without anyone noticing, because
137
+ * degraded recall looks exactly like working recall. The measured-good
138
+ * configuration is now what the code does when nothing is set; the env var
139
+ * is the escape hatch, like the other diagnostics knobs.
140
+ */
141
+ export function rerank2Enabled(): boolean {
142
+ return process.env.AWM_RERANK2 !== '0';
143
+ }
@@ -19,7 +19,7 @@ import {
19
19
  type PreTrainedTokenizer,
20
20
  type PreTrainedModel,
21
21
  } from '@huggingface/transformers';
22
- import { dispatchRerank, registerInProcessHandlers } from './ml-worker.js';
22
+ import { dispatchRerank, registerInferenceConfig, registerInProcessHandlers } from './ml-worker.js';
23
23
  import { ensureModelCacheDir } from './model-cache.js';
24
24
 
25
25
  const DEFAULT_MODEL = 'Xenova/ms-marco-MiniLM-L-6-v2';
@@ -67,11 +67,60 @@ function sigmoid(x: number): number {
67
67
  return 1 / (1 + Math.exp(-x));
68
68
  }
69
69
 
70
- async function inProcessRerank(args: { query: string; passages: string[] }): Promise<Array<{ index: number; score: number }>> {
71
- const { query, passages } = args;
72
- if (passages.length === 0) return [];
73
- await ensureLoaded();
70
+ /**
71
+ * Input-shape caps for the cross-encoder. They exist for MEMORY, and one of them also
72
+ * fixes RANKING.
73
+ *
74
+ * onnxruntime-node keeps a CPU memory arena per model that grows to the largest tensor
75
+ * it has ever run and never shrinks. A cross-encoder's attention tensor is
76
+ * batch x heads x seq x seq floats, so its size is quadratic in the padded pair length.
77
+ * Measured 2026-10-16 on AWM 0.17.0: six Claude Code sessions held 13.2 GB, JS heap flat
78
+ * at ~85 MB, and one process reranking 24 long pairs kept ~1.75 GB after going idle.
79
+ * The shapes come from the prime hook, which recalls with the WHOLE user prompt (pastes
80
+ * included), and from recall's 24-40 passage pool: 40 pairs at 512 tokens make one
81
+ * attention tensor of 40 x 12 x 512 x 512 x 4 B = ~500 MB.
82
+ *
83
+ * - AWM_RERANK_BATCH (default 8): passages are scored in chunks. A pair's score depends
84
+ * only on that pair (padding is masked), so this changes memory, not order.
85
+ * - AWM_RERANK_QUERY_TOKENS (default 128): a longer query keeps its first and last
86
+ * halves. transformers.js truncates a too-long pair by cutting tokens off the END,
87
+ * which is the passage: before this cap, a ~510-token query left the passage no tokens
88
+ * at all, and every memory was scored against the query alone. Passages are already
89
+ * capped at ~400 chars (rerank-window.ts), so a 128-token query typically keeps a
90
+ * pair under ~260 tokens (more for CJK text or a raised AWM_RERANK_TRUNC).
91
+ *
92
+ * An explicit 0 disables a cap. Anything else that is not a non-negative integer (empty,
93
+ * 'off', -1) falls back to the default, as AWM_RERANK_TRUNC does: a typo must not quietly
94
+ * bring back the unbounded shape.
95
+ */
96
+ function shapeCap(name: string, fallback: number): number {
97
+ const v = Number(process.env[name]?.trim() || NaN);
98
+ return Number.isInteger(v) && v >= 0 ? v : fallback;
99
+ }
100
+ const RERANK_BATCH = shapeCap('AWM_RERANK_BATCH', 8);
101
+ const RERANK_QUERY_TOKENS = shapeCap('AWM_RERANK_QUERY_TOKENS', 128);
74
102
 
103
+ /**
104
+ * The query the cross-encoder sees: unchanged when it fits the cap, else its first and
105
+ * last halves. Both ends, because a pasted prompt puts the question either before the
106
+ * paste or after it. Special tokens typed into a long query ([SEP], [UNK]) are dropped by
107
+ * the decode; a query under the cap passes through untouched.
108
+ */
109
+ export function capRerankQuery(tok: PreTrainedTokenizer, query: string, maxTokens = RERANK_QUERY_TOKENS): string {
110
+ if (maxTokens <= 0) return query;
111
+ const ids = tok.encode(query, { add_special_tokens: false });
112
+ if (ids.length <= maxTokens) return query;
113
+ const head = Math.ceil(maxTokens / 2);
114
+ const tail = maxTokens - head;
115
+ const decode = (part: number[]) => tok.decode(part, { skip_special_tokens: true, clean_up_tokenization_spaces: true });
116
+ // A tail that starts mid-word decodes with a leading '##' continuation marker, which
117
+ // would re-encode as two stray '#' tokens.
118
+ return tail > 0
119
+ ? `${decode(ids.slice(0, head))} ${decode(ids.slice(ids.length - tail)).replace(/^##/, '')}`
120
+ : decode(ids.slice(0, head));
121
+ }
122
+
123
+ async function scoreChunk(query: string, passages: string[], offset: number): Promise<Array<{ index: number; score: number }>> {
75
124
  // Batch path
76
125
  try {
77
126
  const queries = passages.map(() => query);
@@ -94,9 +143,8 @@ async function inProcessRerank(args: { query: string; passages: string[] }): Pro
94
143
  const results: Array<{ index: number; score: number }> = [];
95
144
  for (let i = 0; i < passages.length; i++) {
96
145
  const rawLogit = Number(data[i] ?? 0);
97
- results.push({ index: i, score: sigmoid(rawLogit) });
146
+ results.push({ index: offset + i, score: sigmoid(rawLogit) });
98
147
  }
99
- results.sort((a, b) => b.score - a.score);
100
148
  return results;
101
149
  } catch {
102
150
  // Per-passage fallback (the original 0.7.13 path)
@@ -113,18 +161,37 @@ async function inProcessRerank(args: { query: string; passages: string[] }): Pro
113
161
  const output = await model!(inputs);
114
162
  const logits = output.logits ?? output.last_hidden_state;
115
163
  const rawLogit = logits.data[0] as number;
116
- results.push({ index: i, score: sigmoid(rawLogit) });
164
+ results.push({ index: offset + i, score: sigmoid(rawLogit) });
117
165
  } catch {
118
- results.push({ index: i, score: 0 });
166
+ results.push({ index: offset + i, score: 0 });
119
167
  }
120
168
  }
121
- results.sort((a, b) => b.score - a.score);
122
169
  return results;
123
170
  }
124
171
  }
125
172
 
173
+ /** Exported with its caps as parameters so tests can compare settings in one process. */
174
+ export async function inProcessRerank(
175
+ args: { query: string; passages: string[] },
176
+ caps: { batch?: number; queryTokens?: number } = {},
177
+ ): Promise<Array<{ index: number; score: number }>> {
178
+ const { passages } = args;
179
+ if (passages.length === 0) return [];
180
+ await ensureLoaded();
181
+ const query = capRerankQuery(tokenizer!, args.query, caps.queryTokens ?? RERANK_QUERY_TOKENS);
182
+ const batch = caps.batch ?? RERANK_BATCH;
183
+ const size = batch > 0 ? batch : passages.length;
184
+ const results: Array<{ index: number; score: number }> = [];
185
+ for (let b = 0; b < passages.length; b += size) {
186
+ results.push(...await scoreChunk(query, passages.slice(b, b + size), b));
187
+ }
188
+ results.sort((a, b) => b.score - a.score);
189
+ return results;
190
+ }
191
+
126
192
  // Register the in-process handler with the pool
127
- registerInProcessHandlers({ rerank: inProcessRerank });
193
+ registerInProcessHandlers({ rerank: (args) => inProcessRerank(args) });
194
+ registerInferenceConfig({ rerankModel: MODEL_ID, rerankDtype: DTYPE, rerankBatch: RERANK_BATCH, rerankQueryTokens: RERANK_QUERY_TOKENS });
128
195
 
129
196
  // --- Public API ---
130
197
 
@@ -0,0 +1,65 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Trust tiers on writes and memories, and the one rule the write pipeline
5
+ * applies with them: a merge or a replacement must never RAISE the trust of
6
+ * text, and must never retire a more-trusted memory on the word of a
7
+ * less-trusted write.
8
+ *
9
+ * Found by MWA BL-17 (2026-10-14/15), five isolated-review rounds with
10
+ * probes: the pipeline merged a write into an existing memory on concept
11
+ * equality and the merged engram KEPT THE OLD ENGRAM'S TAGS — so a poisoned
12
+ * skill merged into a clean one came back trust=medium and primeable; a
13
+ * planted untrusted decoy under the same concept let a poisoned near-copy
14
+ * merge into the trusted fact; an archived trusted fact was revived with
15
+ * poisoned text merged in; and an R3 "correction" (eventType surprise) from
16
+ * untrusted text retired a trusted memory. MWA works around all of it in its
17
+ * own wrapper. Any other client writing mixed-trust content was exposed.
18
+ *
19
+ * AWM has no trust model of its own, and this does not add one. It reads the
20
+ * tag MWA's memory spine already writes (`trust=<tier>`) and refuses the
21
+ * dangerous direction. The write is still stored — as its own engram, with
22
+ * its own tags — so nothing is lost and the conflict mark can say the two
23
+ * disagree. Untagged writes and memories are unaffected: the rule only fires
24
+ * when the write is EXPLICITLY untrusted, or when both sides carry a tier and
25
+ * the write's is lower.
26
+ */
27
+
28
+ /** Ordered tiers; higher is more trusted. */
29
+ const TIERS: Record<string, number> = {
30
+ untrusted: 0, none: 0, quarantine: 0,
31
+ low: 1,
32
+ medium: 2, med: 2,
33
+ high: 3, trusted: 3, verified: 3, owner: 3,
34
+ };
35
+
36
+ /** The tier a `trust=<tier>` tag declares, or null when there is none. */
37
+ export function trustTier(tags: readonly string[] | null | undefined): number | null {
38
+ if (!tags) return null;
39
+ let found: number | null = null;
40
+ for (const t of tags) {
41
+ const m = /^trust=([a-z]+)$/i.exec(t.trim());
42
+ if (!m) continue;
43
+ const v = TIERS[m[1].toLowerCase()];
44
+ if (v === undefined) continue;
45
+ // Several tags: the lowest wins — a memory is as trusted as its least trusted label.
46
+ found = found === null ? v : Math.min(found, v);
47
+ }
48
+ return found;
49
+ }
50
+
51
+ /**
52
+ * True when `writeTags` must not merge into, supersede or revive a memory
53
+ * carrying `targetTags`.
54
+ */
55
+ export function mergeBlockedByTrust(
56
+ writeTags: readonly string[] | null | undefined,
57
+ targetTags: readonly string[] | null | undefined,
58
+ ): boolean {
59
+ const w = trustTier(writeTags);
60
+ if (w === null) return false; // untagged write: no rule
61
+ const t = trustTier(targetTags);
62
+ if (w === 0) return t === null || t > 0; // explicitly untrusted: only into untrusted
63
+ if (t === null) return false; // tagged write, untagged target: no rule
64
+ return w < t;
65
+ }
@@ -0,0 +1,47 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Temporal validity (D8) — the one place that decides whether a memory's
5
+ * `valid_from` / `valid_to` window holds at a given moment.
6
+ *
7
+ * Until 2026-10-19 nothing read these fields except the two recall formatters,
8
+ * which printed `[valid until …]` on expired memories exactly as on current
9
+ * ones. The live store was serving 482 memories past their `valid_to` as
10
+ * current (97 active, 385 archived). The guidance given to agents ("use
11
+ * `valid_to` … so the memory expires") promised behaviour the code did not have.
12
+ *
13
+ * Ranking (activation.ts Phase 8c), the MCP recall format and the prime hook
14
+ * all go through here so they cannot disagree about what "expired" means.
15
+ */
16
+
17
+ export type ValidityState = 'current' | 'expired' | 'not-yet-valid';
18
+
19
+ /** The fields this needs — any engram-shaped object qualifies. */
20
+ export interface TemporallyBounded {
21
+ validFrom?: string | null;
22
+ validTo?: string | null;
23
+ }
24
+
25
+ /**
26
+ * A date-only `valid_to` ("2026-01-15") means "through that day", so it is
27
+ * compared against the END of that day in UTC. A full datetime is taken as
28
+ * written. An unparseable value is ignored: a bad bound must not hide a memory.
29
+ */
30
+ function boundMs(value: string, endOfDay: boolean): number {
31
+ const t = Date.parse(value);
32
+ if (!Number.isFinite(t)) return NaN;
33
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value.trim());
34
+ return dateOnly && endOfDay ? t + 86_400_000 - 1 : t;
35
+ }
36
+
37
+ export function validityState(e: TemporallyBounded, now: number = Date.now()): ValidityState {
38
+ if (e.validTo) {
39
+ const until = boundMs(e.validTo, true);
40
+ if (Number.isFinite(until) && now > until) return 'expired';
41
+ }
42
+ if (e.validFrom) {
43
+ const from = boundMs(e.validFrom, false);
44
+ if (Number.isFinite(from) && now < from) return 'not-yet-valid';
45
+ }
46
+ return 'current';
47
+ }