agent-working-memory 0.13.1 → 0.14.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.
- package/README.md +200 -238
- package/dist/adapters/common.d.ts +6 -0
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +457 -362
- package/dist/adapters/common.js.map +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +24 -8
- package/dist/api/routes.js.map +1 -1
- package/dist/core/alias-map.d.ts +16 -0
- package/dist/core/alias-map.d.ts.map +1 -0
- package/dist/core/alias-map.js +102 -0
- package/dist/core/alias-map.js.map +1 -0
- package/dist/core/embeddings.d.ts +17 -0
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +50 -1
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/recall-config.d.ts +52 -0
- package/dist/core/recall-config.d.ts.map +1 -0
- package/dist/core/recall-config.js +110 -0
- package/dist/core/recall-config.js.map +1 -0
- package/dist/core/rerank-window.d.ts +61 -0
- package/dist/core/rerank-window.d.ts.map +1 -0
- package/dist/core/rerank-window.js +153 -0
- package/dist/core/rerank-window.js.map +1 -0
- package/dist/core/rerank2.d.ts +62 -0
- package/dist/core/rerank2.d.ts.map +1 -0
- package/dist/core/rerank2.js +75 -0
- package/dist/core/rerank2.js.map +1 -0
- package/dist/core/retrieval-text.d.ts +55 -0
- package/dist/core/retrieval-text.d.ts.map +1 -0
- package/dist/core/retrieval-text.js +87 -0
- package/dist/core/retrieval-text.js.map +1 -0
- package/dist/core/temporal-query.d.ts +61 -0
- package/dist/core/temporal-query.d.ts.map +1 -0
- package/dist/core/temporal-query.js +168 -0
- package/dist/core/temporal-query.js.map +1 -0
- package/dist/core/token-budget.d.ts +75 -0
- package/dist/core/token-budget.d.ts.map +1 -0
- package/dist/core/token-budget.js +136 -0
- package/dist/core/token-budget.js.map +1 -0
- package/dist/core/whoami.d.ts +11 -0
- package/dist/core/whoami.d.ts.map +1 -1
- package/dist/core/whoami.js +10 -0
- package/dist/core/whoami.js.map +1 -1
- package/dist/core/write-pipeline.d.ts.map +1 -1
- package/dist/core/write-pipeline.js +6 -3
- package/dist/core/write-pipeline.js.map +1 -1
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +146 -32
- package/dist/engine/activation.js.map +1 -1
- package/dist/hooks/prime.d.ts +77 -0
- package/dist/hooks/prime.d.ts.map +1 -0
- package/dist/hooks/prime.js +92 -0
- package/dist/hooks/prime.js.map +1 -0
- package/dist/hooks/sidecar.d.ts.map +1 -1
- package/dist/hooks/sidecar.js +39 -0
- package/dist/hooks/sidecar.js.map +1 -1
- package/dist/mcp.js +160 -107
- package/dist/mcp.js.map +1 -1
- package/dist/storage/pglite.d.ts.map +1 -1
- package/dist/storage/pglite.js +10 -2
- package/dist/storage/pglite.js.map +1 -1
- package/dist/storage/postgres.d.ts.map +1 -1
- package/dist/storage/postgres.js +10 -2
- package/dist/storage/postgres.js.map +1 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +12 -2
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +31 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/adapters/common.ts +666 -567
- package/src/api/routes.ts +1015 -999
- package/src/core/alias-map.ts +97 -0
- package/src/core/embeddings.ts +172 -115
- package/src/core/recall-config.ts +115 -0
- package/src/core/rerank-window.ts +158 -0
- package/src/core/rerank2.ts +82 -0
- package/src/core/retrieval-text.ts +82 -0
- package/src/core/temporal-query.ts +193 -0
- package/src/core/token-budget.ts +160 -0
- package/src/core/whoami.ts +110 -92
- package/src/core/write-pipeline.ts +6 -3
- package/src/engine/activation.ts +1579 -1468
- package/src/hooks/prime.ts +136 -0
- package/src/hooks/sidecar.ts +43 -0
- package/src/mcp.ts +1443 -1387
- package/src/storage/pglite.ts +10 -2
- package/src/storage/postgres.ts +10 -2
- package/src/storage/sqlite.ts +12 -2
- package/src/types/engram.ts +32 -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
|
+
}
|
package/src/core/embeddings.ts
CHANGED
|
@@ -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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|