agent-working-memory 0.13.1 → 0.14.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 (91) hide show
  1. package/README.md +200 -238
  2. package/dist/adapters/common.d.ts +6 -0
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +457 -362
  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 +24 -8
  8. package/dist/api/routes.js.map +1 -1
  9. package/dist/core/alias-map.d.ts +16 -0
  10. package/dist/core/alias-map.d.ts.map +1 -0
  11. package/dist/core/alias-map.js +102 -0
  12. package/dist/core/alias-map.js.map +1 -0
  13. package/dist/core/embeddings.d.ts +17 -0
  14. package/dist/core/embeddings.d.ts.map +1 -1
  15. package/dist/core/embeddings.js +50 -1
  16. package/dist/core/embeddings.js.map +1 -1
  17. package/dist/core/recall-config.d.ts +52 -0
  18. package/dist/core/recall-config.d.ts.map +1 -0
  19. package/dist/core/recall-config.js +110 -0
  20. package/dist/core/recall-config.js.map +1 -0
  21. package/dist/core/rerank-window.d.ts +61 -0
  22. package/dist/core/rerank-window.d.ts.map +1 -0
  23. package/dist/core/rerank-window.js +153 -0
  24. package/dist/core/rerank-window.js.map +1 -0
  25. package/dist/core/rerank2.d.ts +62 -0
  26. package/dist/core/rerank2.d.ts.map +1 -0
  27. package/dist/core/rerank2.js +75 -0
  28. package/dist/core/rerank2.js.map +1 -0
  29. package/dist/core/retrieval-text.d.ts +55 -0
  30. package/dist/core/retrieval-text.d.ts.map +1 -0
  31. package/dist/core/retrieval-text.js +87 -0
  32. package/dist/core/retrieval-text.js.map +1 -0
  33. package/dist/core/temporal-query.d.ts +61 -0
  34. package/dist/core/temporal-query.d.ts.map +1 -0
  35. package/dist/core/temporal-query.js +168 -0
  36. package/dist/core/temporal-query.js.map +1 -0
  37. package/dist/core/token-budget.d.ts +75 -0
  38. package/dist/core/token-budget.d.ts.map +1 -0
  39. package/dist/core/token-budget.js +136 -0
  40. package/dist/core/token-budget.js.map +1 -0
  41. package/dist/core/whoami.d.ts +11 -0
  42. package/dist/core/whoami.d.ts.map +1 -1
  43. package/dist/core/whoami.js +10 -0
  44. package/dist/core/whoami.js.map +1 -1
  45. package/dist/core/write-pipeline.d.ts.map +1 -1
  46. package/dist/core/write-pipeline.js +6 -3
  47. package/dist/core/write-pipeline.js.map +1 -1
  48. package/dist/engine/activation.d.ts.map +1 -1
  49. package/dist/engine/activation.js +135 -32
  50. package/dist/engine/activation.js.map +1 -1
  51. package/dist/hooks/prime.d.ts +77 -0
  52. package/dist/hooks/prime.d.ts.map +1 -0
  53. package/dist/hooks/prime.js +92 -0
  54. package/dist/hooks/prime.js.map +1 -0
  55. package/dist/hooks/sidecar.d.ts.map +1 -1
  56. package/dist/hooks/sidecar.js +39 -0
  57. package/dist/hooks/sidecar.js.map +1 -1
  58. package/dist/mcp.js +134 -102
  59. package/dist/mcp.js.map +1 -1
  60. package/dist/storage/pglite.d.ts.map +1 -1
  61. package/dist/storage/pglite.js +10 -2
  62. package/dist/storage/pglite.js.map +1 -1
  63. package/dist/storage/postgres.d.ts.map +1 -1
  64. package/dist/storage/postgres.js +10 -2
  65. package/dist/storage/postgres.js.map +1 -1
  66. package/dist/storage/sqlite.d.ts.map +1 -1
  67. package/dist/storage/sqlite.js +12 -2
  68. package/dist/storage/sqlite.js.map +1 -1
  69. package/dist/types/engram.d.ts +7 -0
  70. package/dist/types/engram.d.ts.map +1 -1
  71. package/package.json +3 -2
  72. package/src/adapters/common.ts +666 -567
  73. package/src/api/routes.ts +1015 -999
  74. package/src/core/alias-map.ts +97 -0
  75. package/src/core/embeddings.ts +172 -115
  76. package/src/core/recall-config.ts +115 -0
  77. package/src/core/rerank-window.ts +158 -0
  78. package/src/core/rerank2.ts +82 -0
  79. package/src/core/retrieval-text.ts +82 -0
  80. package/src/core/temporal-query.ts +193 -0
  81. package/src/core/token-budget.ts +160 -0
  82. package/src/core/whoami.ts +110 -92
  83. package/src/core/write-pipeline.ts +6 -3
  84. package/src/engine/activation.ts +1568 -1468
  85. package/src/hooks/prime.ts +136 -0
  86. package/src/hooks/sidecar.ts +43 -0
  87. package/src/mcp.ts +1422 -1387
  88. package/src/storage/pglite.ts +10 -2
  89. package/src/storage/postgres.ts +10 -2
  90. package/src/storage/sqlite.ts +12 -2
  91. package/src/types/engram.ts +7 -0
@@ -0,0 +1,97 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Project-dialect alias expansion.
5
+ *
6
+ * WHAT IT SOLVES
7
+ * --------------
8
+ * A memory tagged `topic=azure` whose body says "private plan… P1v3… app
9
+ * service" but never "azure". Asked for with "azure", it was not in the top 40.
10
+ * No general model knows that connection — it is local to this project — but it
11
+ * is recoverable from the store, because memories tagged `azure` share body
12
+ * vocabulary that the corpus at large does not.
13
+ *
14
+ * The map is mined offline (tests/realstore-eval/mine-aliases.ts) and is a plain
15
+ * JSON artifact: inspectable, cappable, diffable. That mattered immediately —
16
+ * reading the first mined map showed it had learned
17
+ * `agent -> discussed, turns, topics, summary`, i.e. session-summary boilerplate
18
+ * masquerading as dialect, and a hub guardrail was added before any measurement.
19
+ *
20
+ * GUARDRAIL 4 — "require at least one ORIGINAL query term" — is implemented by
21
+ * SCOPE rather than by an extra filter:
22
+ *
23
+ * - alias terms are added ONLY to the BM25 search string, widening what gets
24
+ * retrieved;
25
+ * - `queryTokens`, which drives textMatch scoring, stays ORIGINAL.
26
+ *
27
+ * So a candidate that matches only alias terms enters the pool with a near-zero
28
+ * textMatch and is filtered by the existing minScore gate, while a candidate
29
+ * that matches an original term too is scored normally. The alias terms buy
30
+ * REACH; the original terms still decide RELEVANCE. That is exactly the
31
+ * separation Codex asked for, and it needs no new gate to enforce.
32
+ */
33
+ import { readFileSync, existsSync } from 'node:fs';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { dirname, join } from 'node:path';
36
+
37
+ let cache: Record<string, string[]> | null = null;
38
+
39
+ function mapPath(): string {
40
+ if (process.env.AWM_ALIAS_MAP) return process.env.AWM_ALIAS_MAP;
41
+ const here = dirname(fileURLToPath(import.meta.url));
42
+ return join(here, '..', '..', 'data', 'alias-map.json');
43
+ }
44
+
45
+ /** Load the mined map once. Missing file is not an error — the feature is opt-in. */
46
+ export function aliasMap(): Record<string, string[]> {
47
+ if (cache) return cache;
48
+ const p = mapPath();
49
+ if (!existsSync(p)) { cache = {}; return cache; }
50
+ try {
51
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
52
+ cache = (parsed.map ?? parsed) as Record<string, string[]>;
53
+ } catch {
54
+ cache = {};
55
+ }
56
+ return cache;
57
+ }
58
+
59
+ /** Reset the cache — tests only. */
60
+ export function clearAliasCache(): void { cache = null; }
61
+
62
+ /** Whether alias expansion is enabled. Default OFF. */
63
+ export function aliasEnabled(): boolean {
64
+ return process.env.AWM_ALIASES === '1';
65
+ }
66
+
67
+ /** Max alias terms added to a single query, regardless of how many match. */
68
+ export function aliasQueryCap(): number {
69
+ const v = Number(process.env.AWM_ALIAS_QUERY_CAP ?? 8);
70
+ return Number.isFinite(v) && v > 0 ? v : 8;
71
+ }
72
+
73
+ /**
74
+ * Expand a query with dialect terms for any category word it mentions.
75
+ * Returns the added terms (never the rewritten query) so the caller decides
76
+ * which channel sees them — the whole safety property depends on alias terms
77
+ * reaching BM25 only, not the scoring tokens.
78
+ */
79
+ export function aliasTermsFor(query: string): string[] {
80
+ if (!aliasEnabled()) return [];
81
+ const m = aliasMap();
82
+ if (Object.keys(m).length === 0) return [];
83
+ const toks = new Set((query.toLowerCase().match(/[a-z][a-z0-9_.-]{3,}/g) ?? []));
84
+ const out: string[] = [];
85
+ const seen = new Set<string>(toks);
86
+ for (const t of toks) {
87
+ const aliases = m[t];
88
+ if (!aliases) continue;
89
+ for (const a of aliases) {
90
+ if (seen.has(a)) continue; // never re-add a term the query already has
91
+ seen.add(a);
92
+ out.push(a);
93
+ if (out.length >= aliasQueryCap()) return out;
94
+ }
95
+ }
96
+ return out;
97
+ }
@@ -1,115 +1,172 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Embedding Engine - vector embeddings via the ML worker pool.
5
- *
6
- * Default model: bge-small-en-v1.5 (384 dimensions, ~90MB, MTEB retrieval-optimized).
7
- * Configurable via AWM_EMBED_MODEL env var.
8
- *
9
- * AWM 0.8.x: inference dispatches through ml-worker.ts. The worker_threads
10
- * path was planned but reverted to in-process because onnxruntime-node's
11
- * native bindings store V8 handles that don't cross isolate boundaries
12
- * safely — see ml-worker.ts for the full status. The dispatch abstraction
13
- * is preserved for a future child_process or HTTP sidecar pool.
14
- * `AWM_ML_INPROCESS=1` is honored as a no-op (in-process is now the default).
15
- *
16
- * NOTE: Changing the model invalidates existing embeddings.
17
- * Set AWM_EMBED_MODEL=Xenova/all-MiniLM-L6-v2 for backward compatibility.
18
- */
19
-
20
- import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
21
- import { dispatchEmbed, registerInProcessHandlers } from './ml-worker.js';
22
- import { noteModelLoad } from './write-telemetry.js';
23
- import { ensureModelCacheDir } from './model-cache.js';
24
-
25
- const MODEL_ID = process.env.AWM_EMBED_MODEL ?? 'Xenova/bge-small-en-v1.5';
26
- const DIMENSIONS = parseInt(process.env.AWM_EMBED_DIMS ?? '384', 10);
27
- const POOLING = (process.env.AWM_EMBED_POOLING ?? 'mean') as 'cls' | 'mean';
28
-
29
- // --- In-process fallback (used by tests and crash recovery) ---
30
-
31
- let inProcessInstance: FeatureExtractionPipeline | null = null;
32
- let inProcessInitPromise: Promise<FeatureExtractionPipeline> | null = null;
33
-
34
- async function loadInProcess(): Promise<FeatureExtractionPipeline> {
35
- if (inProcessInstance) return inProcessInstance;
36
- if (inProcessInitPromise) return inProcessInitPromise;
37
- const tLoadStart = performance.now();
38
- ensureModelCacheDir();
39
- inProcessInitPromise = pipeline('feature-extraction', MODEL_ID, { dtype: 'fp32' }).then(pipe => {
40
- inProcessInstance = pipe;
41
- noteModelLoad(performance.now() - tLoadStart);
42
- console.error(`Embedding model loaded in-process: ${MODEL_ID} (${DIMENSIONS}d)`);
43
- return pipe;
44
- });
45
- return inProcessInitPromise;
46
- }
47
-
48
- async function inProcessEmbed(args: { texts: string[]; pooling: 'cls' | 'mean'; dimensions: number }): Promise<number[][]> {
49
- const { texts, pooling, dimensions } = args;
50
- if (texts.length === 0) return [];
51
- const embedder = await loadInProcess();
52
- const result = await embedder(texts, { pooling, normalize: true });
53
- const data = result.data as Float32Array;
54
- const vectors: number[][] = [];
55
- for (let i = 0; i < texts.length; i++) {
56
- vectors.push(Array.from(data.slice(i * dimensions, (i + 1) * dimensions)));
57
- }
58
- return vectors;
59
- }
60
-
61
- // Register the in-process handler with the pool (used in test mode and as fallback)
62
- registerInProcessHandlers({ embed: inProcessEmbed });
63
-
64
- // --- Public API ---
65
-
66
- /**
67
- * Get or initialize the embedding pipeline (singleton).
68
- * Kept for backwards compat — returns the in-process pipeline only.
69
- * Most consumers should use embed() / embedBatch() which dispatch
70
- * to the worker pool by default.
71
- */
72
- export async function getEmbedder(): Promise<FeatureExtractionPipeline> {
73
- return loadInProcess();
74
- }
75
-
76
- /**
77
- * Generate an embedding vector for a text string.
78
- * Dispatches to the worker pool (or in-process fallback).
79
- */
80
- export async function embed(text: string): Promise<number[]> {
81
- const vectors = await dispatchEmbed({ texts: [text], pooling: POOLING, dimensions: DIMENSIONS });
82
- return vectors[0] ?? new Array(DIMENSIONS).fill(0);
83
- }
84
-
85
- /**
86
- * Generate embeddings for multiple texts in a batch.
87
- * More efficient than calling embed() in a loop — the worker batches the
88
- * tokenization + forward pass.
89
- */
90
- export async function embedBatch(texts: string[]): Promise<number[][]> {
91
- if (texts.length === 0) return [];
92
- return dispatchEmbed({ texts, pooling: POOLING, dimensions: DIMENSIONS });
93
- }
94
-
95
- /** Get the current embedding model ID (for version tracking in stored embeddings) */
96
- export function getModelId(): string {
97
- return MODEL_ID;
98
- }
99
-
100
- /**
101
- * Cosine similarity between two normalized vectors.
102
- * Since vectors are pre-normalized, this is just the dot product.
103
- */
104
- export function cosineSimilarity(a: number[], b: number[]): number {
105
- if (a.length !== b.length || a.length === 0) return 0;
106
- let dot = 0;
107
- for (let i = 0; i < a.length; i++) {
108
- dot += a[i] * b[i];
109
- }
110
- // Clamp to [-1, 1] to handle floating point drift
111
- return Math.max(-1, Math.min(1, dot));
112
- }
113
-
114
- /** Vector dimensions for this model */
115
- export const EMBEDDING_DIMENSIONS = DIMENSIONS;
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Embedding Engine - vector embeddings via the ML worker pool.
5
+ *
6
+ * Default model: bge-small-en-v1.5 (384 dimensions, ~90MB, MTEB retrieval-optimized).
7
+ * Configurable via AWM_EMBED_MODEL env var.
8
+ *
9
+ * AWM 0.8.x: inference dispatches through ml-worker.ts. The worker_threads
10
+ * path was planned but reverted to in-process because onnxruntime-node's
11
+ * native bindings store V8 handles that don't cross isolate boundaries
12
+ * safely — see ml-worker.ts for the full status. The dispatch abstraction
13
+ * is preserved for a future child_process or HTTP sidecar pool.
14
+ * `AWM_ML_INPROCESS=1` is honored as a no-op (in-process is now the default).
15
+ *
16
+ * NOTE: Changing the model invalidates existing embeddings.
17
+ * Set AWM_EMBED_MODEL=Xenova/all-MiniLM-L6-v2 for backward compatibility.
18
+ */
19
+
20
+ import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
21
+ import { dispatchEmbed, registerInProcessHandlers } from './ml-worker.js';
22
+ import { noteModelLoad } from './write-telemetry.js';
23
+ import { ensureModelCacheDir } from './model-cache.js';
24
+
25
+ const MODEL_ID = process.env.AWM_EMBED_MODEL ?? 'Xenova/bge-small-en-v1.5';
26
+ const DIMENSIONS = parseInt(process.env.AWM_EMBED_DIMS ?? '384', 10);
27
+ const POOLING = (process.env.AWM_EMBED_POOLING ?? 'mean') as 'cls' | 'mean';
28
+
29
+ // --- In-process fallback (used by tests and crash recovery) ---
30
+
31
+ let inProcessInstance: FeatureExtractionPipeline | null = null;
32
+ let inProcessInitPromise: Promise<FeatureExtractionPipeline> | null = null;
33
+
34
+ async function loadInProcess(): Promise<FeatureExtractionPipeline> {
35
+ if (inProcessInstance) return inProcessInstance;
36
+ if (inProcessInitPromise) return inProcessInitPromise;
37
+ const tLoadStart = performance.now();
38
+ ensureModelCacheDir();
39
+ inProcessInitPromise = pipeline('feature-extraction', MODEL_ID, { dtype: 'fp32' }).then(pipe => {
40
+ inProcessInstance = pipe;
41
+ noteModelLoad(performance.now() - tLoadStart);
42
+ console.error(`Embedding model loaded in-process: ${MODEL_ID} (${DIMENSIONS}d)`);
43
+ return pipe;
44
+ });
45
+ return inProcessInitPromise;
46
+ }
47
+
48
+ async function inProcessEmbed(args: { texts: string[]; pooling: 'cls' | 'mean'; dimensions: number }): Promise<number[][]> {
49
+ const { texts, pooling, dimensions } = args;
50
+ if (texts.length === 0) return [];
51
+ const embedder = await loadInProcess();
52
+ const result = await embedder(texts, { pooling, normalize: true });
53
+ const data = result.data as Float32Array;
54
+ const vectors: number[][] = [];
55
+ for (let i = 0; i < texts.length; i++) {
56
+ vectors.push(Array.from(data.slice(i * dimensions, (i + 1) * dimensions)));
57
+ }
58
+ return vectors;
59
+ }
60
+
61
+ // Register the in-process handler with the pool (used in test mode and as fallback)
62
+ registerInProcessHandlers({ embed: inProcessEmbed });
63
+
64
+ // --- Public API ---
65
+
66
+ /**
67
+ * Get or initialize the embedding pipeline (singleton).
68
+ * Kept for backwards compat — returns the in-process pipeline only.
69
+ * Most consumers should use embed() / embedBatch() which dispatch
70
+ * to the worker pool by default.
71
+ */
72
+ export async function getEmbedder(): Promise<FeatureExtractionPipeline> {
73
+ return loadInProcess();
74
+ }
75
+
76
+ /**
77
+ * Generate an embedding vector for a text string.
78
+ * Dispatches to the worker pool (or in-process fallback).
79
+ */
80
+ export async function embed(text: string): Promise<number[]> {
81
+ const vectors = await dispatchEmbed({ texts: [text], pooling: POOLING, dimensions: DIMENSIONS });
82
+ return vectors[0] ?? new Array(DIMENSIONS).fill(0);
83
+ }
84
+
85
+ /**
86
+ * Generate embeddings for multiple texts in a batch.
87
+ * More efficient than calling embed() in a loop — the worker batches the
88
+ * tokenization + forward pass.
89
+ */
90
+ export async function embedBatch(texts: string[]): Promise<number[][]> {
91
+ if (texts.length === 0) return [];
92
+ return dispatchEmbed({ texts, pooling: POOLING, dimensions: DIMENSIONS });
93
+ }
94
+
95
+ /** Get the current embedding model ID (for version tracking in stored embeddings) */
96
+ export function getModelId(): string {
97
+ return MODEL_ID;
98
+ }
99
+
100
+ /**
101
+ * Dimension-mismatch telemetry.
102
+ *
103
+ * A mismatch means the vector channel silently scores 0 for the affected memories —
104
+ * recall still returns results (BM25 is unaffected) but they are quietly much worse.
105
+ * That is invisible without this counter, and a half-migrated corpus is the realistic
106
+ * way it happens: change AWM_EMBED_MODEL without re-embedding, or interrupt a
107
+ * migration, and the un-migrated rows drop out of vector scoring with no error.
108
+ */
109
+ let dimMismatchCount = 0;
110
+ let dimMismatchWarned = false;
111
+ let dimMismatchSample: { expected: number; got: number } | null = null;
112
+
113
+ /** Observed embedding-dimension mismatches. Surfaced by /health and memory_whoami. */
114
+ export function embeddingHealth(): {
115
+ dimensionMismatches: number;
116
+ expectedDimensions: number;
117
+ sample: { expected: number; got: number } | null;
118
+ } {
119
+ return {
120
+ dimensionMismatches: dimMismatchCount,
121
+ expectedDimensions: DIMENSIONS,
122
+ sample: dimMismatchSample,
123
+ };
124
+ }
125
+
126
+ /** Test-only: reset the mismatch counter. */
127
+ export function __resetEmbeddingHealth(): void {
128
+ dimMismatchCount = 0;
129
+ dimMismatchWarned = false;
130
+ dimMismatchSample = null;
131
+ }
132
+
133
+ /**
134
+ * Cosine similarity between two normalized vectors.
135
+ * Since vectors are pre-normalized, this is just the dot product.
136
+ *
137
+ * Returns 0 on a dimension mismatch — the maths is undefined otherwise — but COUNTS
138
+ * it and warns once, because a silent 0 here reads as "these memories are irrelevant"
139
+ * rather than "this corpus is half-migrated". Deliberately does not throw: this runs
140
+ * over every candidate on every recall, so throwing would escalate degraded quality
141
+ * into an outage.
142
+ */
143
+ export function cosineSimilarity(a: number[], b: number[]): number {
144
+ if (a.length !== b.length) {
145
+ // length 0 is a legitimate not-yet-embedded state, not a corpus mismatch
146
+ if (a.length !== 0 && b.length !== 0) {
147
+ dimMismatchCount++;
148
+ if (!dimMismatchSample) dimMismatchSample = { expected: a.length, got: b.length };
149
+ if (!dimMismatchWarned) {
150
+ dimMismatchWarned = true;
151
+ console.error(
152
+ `[awm] EMBEDDING DIMENSION MISMATCH: ${a.length}d vs ${b.length}d. ` +
153
+ `Affected memories score 0 on the vector channel and will rank far too low. ` +
154
+ `This usually means the corpus is half-migrated — re-embed the whole store ` +
155
+ `or revert AWM_EMBED_MODEL/AWM_EMBED_DIMS. Warning shown once per process; ` +
156
+ `see embeddingHealth() / GET /health for the running count.`
157
+ );
158
+ }
159
+ }
160
+ return 0;
161
+ }
162
+ if (a.length === 0) return 0;
163
+ let dot = 0;
164
+ for (let i = 0; i < a.length; i++) {
165
+ dot += a[i] * b[i];
166
+ }
167
+ // Clamp to [-1, 1] to handle floating point drift
168
+ return Math.max(-1, Math.min(1, dot));
169
+ }
170
+
171
+ /** Vector dimensions for this model */
172
+ export const EMBEDDING_DIMENSIONS = DIMENSIONS;
@@ -0,0 +1,115 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Effective recall configuration — self-reporting, so a measurement can PROVE
5
+ * which configuration produced it.
6
+ *
7
+ * WHY THIS EXISTS
8
+ * ---------------
9
+ * On 2026-08-23 a benchmark comparison reported "no effect" for a change that
10
+ * demonstrably worked. The cause was not AWM: two arms shared a port, teardown
11
+ * left the first server alive, the second arm health-checked the survivor, and
12
+ * both arms measured the SAME baseline process. Identical inputs, identical
13
+ * outputs, a confident and completely wrong conclusion.
14
+ *
15
+ * Port hygiene fixes that instance. It does not fix the class. The class is:
16
+ * **nothing verified that the system measured was the system configured.**
17
+ * The same gap bit the D11 spreading-activation re-test, where the tracer's arm
18
+ * label omitted `AWM_SPREAD_INHIBIT`, so two materially different arms both
19
+ * printed `arm=spread` and had to be told apart by diffing output by hand.
20
+ *
21
+ * The durable fix is for the running system to state its own effective recall
22
+ * configuration, so a harness can assert it and fail LOUDLY instead of silently
23
+ * measuring the wrong thing. Any new recall flag added to RECALL_FLAGS is
24
+ * automatically covered by every consumer — the eval tracer's arm label, the
25
+ * `/health` payload, and the benchmark driver's assertion.
26
+ *
27
+ * Adding a flag here is the ONLY step required to make it visible everywhere.
28
+ */
29
+
30
+ /**
31
+ * Every environment flag that can change what `activate()` returns.
32
+ * Keep this list current — an unlisted flag is an invisible experiment.
33
+ */
34
+ export const RECALL_FLAGS = [
35
+ // Second-stage reorder (phase 9b)
36
+ 'AWM_RERANK2',
37
+ 'AWM_RERANK2_K',
38
+ // Cross-encoder passage selection
39
+ 'AWM_RERANK_WINDOW',
40
+ 'AWM_RERANK_TRUNC',
41
+ 'AWM_RERANK_POOL',
42
+ 'AWM_RERANK_TAGS',
43
+ 'AWM_RERANK_TAGS_LEN',
44
+ // Derived retrieval text (embedding side)
45
+ 'AWM_RETRIEVAL_TEXT',
46
+ // Project-dialect alias expansion
47
+ 'AWM_ALIASES',
48
+ 'AWM_ALIAS_QUERY_CAP',
49
+ // Embedding model — changes every stored vector, so it belongs here
50
+ 'AWM_EMBED_MODEL',
51
+ 'AWM_EMBED_DIMS',
52
+ 'AWM_DISABLE_RERANK_SKIP',
53
+ // Spreading activation (D11 — parked)
54
+ 'AWM_SPREAD',
55
+ 'AWM_SPREAD_INJECT',
56
+ 'AWM_SPREAD_INHIBIT',
57
+ 'AWM_SPREAD_ITERS',
58
+ 'AWM_SPREAD_DAMPING',
59
+ 'AWM_SPREAD_BUDGET',
60
+ 'AWM_SPREAD_BOOST',
61
+ // Candidate pool / retrieval breadth
62
+ 'AWM_TOPN_MULT',
63
+ 'AWM_BROAD_EDGES',
64
+ 'AWM_ENTITY_FETCH',
65
+ 'AWM_ENTITY_INDEX_FETCH',
66
+ 'AWM_QUERY_BRIDGE',
67
+ 'AWM_AUTOTAG',
68
+ // Diagnostic escape hatches that alter ranking
69
+ 'AWM_DISABLE_POOL_FILTER',
70
+ 'AWM_DISABLE_EXPANSION_CACHE',
71
+ 'AWM_DISABLE_SLIM_CACHE',
72
+ 'AWM_ABSTAIN_GATE_K',
73
+ ] as const;
74
+
75
+ export type RecallFlag = (typeof RECALL_FLAGS)[number];
76
+
77
+ /** Only the flags actually set, in declaration order. */
78
+ export function activeRecallConfig(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
79
+ const out: Record<string, string> = {};
80
+ for (const k of RECALL_FLAGS) {
81
+ const v = env[k];
82
+ if (v !== undefined && v !== '') out[k] = v;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Short, stable, comparable label for the active configuration.
89
+ * `default` when nothing is set. Sorted so it never depends on declaration
90
+ * order or on how the process was launched.
91
+ */
92
+ export function recallConfigFingerprint(env: NodeJS.ProcessEnv = process.env): string {
93
+ const active = activeRecallConfig(env);
94
+ const keys = Object.keys(active).sort();
95
+ if (keys.length === 0) return 'default';
96
+ return keys.map(k => `${k.replace(/^AWM_/, '').toLowerCase()}=${active[k]}`).join(',');
97
+ }
98
+
99
+ /**
100
+ * Assert the running configuration contains the expected flag values.
101
+ * Returns the mismatches; empty array means the system is configured as
102
+ * intended. Harnesses should treat a non-empty result as fatal — it means the
103
+ * thing being measured is not the thing that was configured.
104
+ */
105
+ export function diffRecallConfig(
106
+ expected: Record<string, string>,
107
+ env: NodeJS.ProcessEnv = process.env,
108
+ ): Array<{ flag: string; expected: string; actual: string | undefined }> {
109
+ const active = activeRecallConfig(env);
110
+ const bad: Array<{ flag: string; expected: string; actual: string | undefined }> = [];
111
+ for (const [flag, want] of Object.entries(expected)) {
112
+ if (active[flag] !== want) bad.push({ flag, expected: want, actual: active[flag] });
113
+ }
114
+ return bad;
115
+ }
@@ -0,0 +1,158 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Passage selection for cross-encoder reranking.
5
+ *
6
+ * THE PROBLEM
7
+ * -----------
8
+ * Reranking truncates each passage to a fixed prefix (historically the first
9
+ * 400 chars). That truncation exists for a real reason: cross-encoders pad to
10
+ * the longest passage in the batch, so one 5,000-char memory in a 40-item pool
11
+ * drags every passage to ~512 tokens and costs 3-4x. The reranker is already
12
+ * ~90% of warm recall latency, so "just send everything" is not available.
13
+ *
14
+ * But a PREFIX is the wrong 400 chars. Measured on the live 29.8k store:
15
+ * - canonical memories median 1,965 chars, 98.7% exceed 400
16
+ * - the reranker cannot see 78.8% of each canonical memory's vocabulary
17
+ * - 99.9% of long canonical memories carry identifiers only past char 400
18
+ * And measured on tests/longmem-eval: moving the answer from char 150 to char
19
+ * 700 takes success@1 from 100% to 0%, with the gold's cross-encoder score
20
+ * collapsing 0.986 -> 0.000 while its BM25 score barely moves. The memory stays
21
+ * retrievable and stops being rankable.
22
+ *
23
+ * THE FIX
24
+ * -------
25
+ * Spend the same character budget on the window that actually contains the
26
+ * query's terms, instead of on whatever happens to be at the top of the memory.
27
+ * Cost is unchanged — same budget, same batch padding, same inference — so this
28
+ * buys ranking quality without buying latency.
29
+ *
30
+ * The concept line is always kept: it is short, it is the memory's title, and
31
+ * it is what a human wrote to summarise the thing.
32
+ */
33
+
34
+ /** Cheap tokenizer for locating query terms inside a passage. */
35
+ function terms(query: string): string[] {
36
+ const raw = query.toLowerCase().match(/[a-z0-9_][a-z0-9_.\-]{1,}/g) ?? [];
37
+ const STOP = new Set([
38
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'what', 'where', 'when',
39
+ 'which', 'does', 'did', 'was', 'were', 'are', 'is', 'be', 'to', 'of', 'in',
40
+ 'on', 'at', 'by', 'it', 'as', 'do', 'how', 'why', 'a', 'an', 'or',
41
+ ]);
42
+ return Array.from(new Set(raw.filter(t => t.length >= 3 && !STOP.has(t))));
43
+ }
44
+
45
+ /**
46
+ * Choose the `budget`-char window of `content` densest in query terms.
47
+ * Returns the head of the content when nothing matches — the old behaviour,
48
+ * which is the right fallback: with no query signal there is no reason to
49
+ * prefer any other part of the memory.
50
+ */
51
+ export function densestWindow(content: string, query: string, budget: number): string {
52
+ if (content.length <= budget) return content;
53
+
54
+ const toks = terms(query);
55
+ if (toks.length === 0) return content.slice(0, budget);
56
+
57
+ const lower = content.toLowerCase();
58
+ const hits: number[] = [];
59
+ for (const t of toks) {
60
+ let from = 0;
61
+ for (;;) {
62
+ const i = lower.indexOf(t, from);
63
+ if (i < 0) break;
64
+ hits.push(i);
65
+ from = i + t.length;
66
+ }
67
+ }
68
+ if (hits.length === 0) return content.slice(0, budget);
69
+ hits.sort((a, b) => a - b);
70
+
71
+ // Slide a window anchored slightly before each hit; keep the one covering most.
72
+ let bestStart = 0;
73
+ let bestCount = -1;
74
+ for (let i = 0; i < hits.length; i++) {
75
+ const start = Math.max(0, hits[i] - Math.floor(budget / 5));
76
+ let count = 0;
77
+ for (let j = i; j < hits.length && hits[j] - start < budget; j++) count++;
78
+ if (count > bestCount) { bestCount = count; bestStart = start; }
79
+ }
80
+
81
+ // Snap to a word boundary so the cross-encoder is not handed a split token.
82
+ let start = bestStart;
83
+ if (start > 0) {
84
+ const sp = content.indexOf(' ', start);
85
+ if (sp >= 0 && sp - start < 40) start = sp + 1;
86
+ }
87
+ const slice = content.slice(start, start + budget);
88
+ return (start > 0 ? '…' : '') + slice + (start + budget < content.length ? '…' : '');
89
+ }
90
+
91
+ /**
92
+ * Build the passage handed to the cross-encoder for one candidate.
93
+ *
94
+ * `mode`:
95
+ * - `'prefix'` (default) — legacy behaviour, the first `budget` chars.
96
+ * - `'query'` — the `budget`-char window densest in query terms.
97
+ */
98
+ export function buildRerankPassage(
99
+ concept: string,
100
+ content: string,
101
+ query: string,
102
+ budget: number,
103
+ mode: 'prefix' | 'query',
104
+ tags?: string[],
105
+ ): string {
106
+ const body = mode === 'query'
107
+ ? densestWindow(content, query, budget)
108
+ : (content.length > budget ? content.slice(0, budget) : content);
109
+ // Topical tags, when enabled. The cross-encoder decides final order since
110
+ // phase 9b, and it cannot see tags at all — measured on the live store, 66.2%
111
+ // of topical tag terms never appear in the body, so that vocabulary is
112
+ // invisible to the stage that now decides ranking. Appended (not substituted)
113
+ // and length-capped so it cannot crowd out the content window.
114
+ const extra = rerankTagText(tags);
115
+ return extra ? `${concept}: ${body} ${extra}` : `${concept}: ${body}`;
116
+ }
117
+
118
+ /** Whether topical tags are appended to the rerank passage. Default OFF. */
119
+ export function rerankTagsEnabled(): boolean {
120
+ return process.env.AWM_RERANK_TAGS === '1';
121
+ }
122
+
123
+ /** Character budget for the appended tag text. */
124
+ export function rerankTagBudget(): number {
125
+ const v = Number(process.env.AWM_RERANK_TAGS_LEN ?? 80);
126
+ return Number.isFinite(v) && v > 0 ? v : 80;
127
+ }
128
+
129
+ /**
130
+ * Render topical tags as plain terms for the cross-encoder.
131
+ * Only `topic=` / `proj=` / `project=` carry query vocabulary; date/person/
132
+ * ticket tags are identifiers the body usually already contains, and adding
133
+ * them would spend the budget without adding reachable words.
134
+ */
135
+ export function rerankTagText(tags?: string[]): string {
136
+ if (!rerankTagsEnabled() || !tags || tags.length === 0) return '';
137
+ const words = new Set<string>();
138
+ for (const t of tags) {
139
+ const m = /^(?:topic|proj|project)=(.+)$/i.exec(t);
140
+ if (!m) continue;
141
+ for (const w of m[1].toLowerCase().split(/[-_\s]+/)) {
142
+ if (w.length > 2) words.add(w);
143
+ }
144
+ }
145
+ if (words.size === 0) return '';
146
+ return `[${[...words].join(' ').slice(0, rerankTagBudget())}]`;
147
+ }
148
+
149
+ /** Character budget for a rerank passage. */
150
+ export function rerankTruncation(): number {
151
+ const v = Number(process.env.AWM_RERANK_TRUNC ?? 400);
152
+ return Number.isFinite(v) && v > 0 ? v : 400;
153
+ }
154
+
155
+ /** Passage selection mode. Default `prefix` preserves shipped behaviour. */
156
+ export function rerankWindowMode(): 'prefix' | 'query' {
157
+ return process.env.AWM_RERANK_WINDOW === 'query' ? 'query' : 'prefix';
158
+ }