agent-working-memory 0.13.0 → 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.
- 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 +52 -1
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/model-cache.d.ts +28 -0
- package/dist/core/model-cache.d.ts.map +1 -0
- package/dist/core/model-cache.js +50 -0
- package/dist/core/model-cache.js.map +1 -0
- package/dist/core/query-expander.d.ts.map +1 -1
- package/dist/core/query-expander.js +2 -0
- package/dist/core/query-expander.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/reranker.d.ts.map +1 -1
- package/dist/core/reranker.js +2 -0
- package/dist/core/reranker.js.map +1 -1
- 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 +135 -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 +134 -102
- 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 +7 -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 -113
- package/src/core/model-cache.ts +51 -0
- package/src/core/query-expander.ts +2 -0
- 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/reranker.ts +2 -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 +1568 -1468
- package/src/hooks/prime.ts +136 -0
- package/src/hooks/sidecar.ts +43 -0
- package/src/mcp.ts +1422 -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 +7 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +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. */
|
|
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
|
+
}
|
package/src/core/reranker.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type PreTrainedModel,
|
|
20
20
|
} from '@huggingface/transformers';
|
|
21
21
|
import { dispatchRerank, registerInProcessHandlers } from './ml-worker.js';
|
|
22
|
+
import { ensureModelCacheDir } from './model-cache.js';
|
|
22
23
|
|
|
23
24
|
const DEFAULT_MODEL = 'Xenova/ms-marco-MiniLM-L-6-v2';
|
|
24
25
|
const MODEL_ID = process.env.AWM_RERANKER_MODEL || DEFAULT_MODEL;
|
|
@@ -33,6 +34,7 @@ async function ensureLoaded(): Promise<void> {
|
|
|
33
34
|
if (tokenizer && model) return;
|
|
34
35
|
if (initPromise) return initPromise;
|
|
35
36
|
initPromise = (async () => {
|
|
37
|
+
ensureModelCacheDir();
|
|
36
38
|
tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID);
|
|
37
39
|
model = await AutoModelForSequenceClassification.from_pretrained(MODEL_ID, { dtype: 'fp32' });
|
|
38
40
|
console.error(`Re-ranker model loaded in-process: ${MODEL_ID}`);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Derived retrieval text — the machine-facing view of a memory.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS
|
|
7
|
+
* ---------------
|
|
8
|
+
* Measured on the live 11,294-engram store: 66.2% of topical tag terms never
|
|
9
|
+
* appear in the memory body, and 94.3% of tagged memories are missing at least
|
|
10
|
+
* one of their own topical terms. Tags are indexed by BM25 only — the embedding
|
|
11
|
+
* is built from `concept + content` (write-pipeline.ts) and so is the
|
|
12
|
+
* cross-encoder rerank passage (activation.ts). So that vocabulary is invisible
|
|
13
|
+
* to two of the three retrieval channels, including the one that decides final
|
|
14
|
+
* ordering since phase 9b.
|
|
15
|
+
*
|
|
16
|
+
* The consequence, observed on a real memory: "private plan memory peaked 88%,
|
|
17
|
+
* scale P1v3 -> P2v3" was NOT in the top 40 candidates for "azure app service
|
|
18
|
+
* plan capacity increase internal application". Its body contains no "azure",
|
|
19
|
+
* no "capacity", no "app service plan" — only `topic=azure` as a tag.
|
|
20
|
+
*
|
|
21
|
+
* THE SHAPE THAT MATTERS
|
|
22
|
+
* ----------------------
|
|
23
|
+
* This builds a DERIVED text used for embedding and reranking. It does NOT
|
|
24
|
+
* mutate `content`. That distinction is the whole design:
|
|
25
|
+
*
|
|
26
|
+
* - AWM's model slots have always been ADDITIVE — the embedder, expander and
|
|
27
|
+
* reranker score or expand, they never rewrite what was stored. Appending
|
|
28
|
+
* tag terms into the body would break that invariant, and a normaliser that
|
|
29
|
+
* silently edits stored memories is unrecoverable if it is wrong.
|
|
30
|
+
* - A derived view is recomputable. If the rule turns out to be bad, re-derive
|
|
31
|
+
* and re-embed; the source of truth was never touched.
|
|
32
|
+
* - And it can be BACKFILLED over the existing corpus, which body-only fixes
|
|
33
|
+
* (write-time guidance, future writes) cannot reach. 7,350 memories are
|
|
34
|
+
* already wrong.
|
|
35
|
+
*
|
|
36
|
+
* Only `topic=` / `proj=` / `project=` are included. Date, person and ticket
|
|
37
|
+
* tags are identifiers the body usually already carries, so adding them spends
|
|
38
|
+
* budget without adding reachable words.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Whether the derived retrieval text includes tag vocabulary. Default OFF. */
|
|
42
|
+
export function retrievalTextEnabled(): boolean {
|
|
43
|
+
return process.env.AWM_RETRIEVAL_TEXT === '1';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract the topical vocabulary a future question is likely to use.
|
|
48
|
+
* Deduplicated and lowercased; order follows first appearance so the output is
|
|
49
|
+
* deterministic for a given tag list (important: a non-deterministic embedding
|
|
50
|
+
* input would make re-embedding produce different vectors for the same memory).
|
|
51
|
+
*/
|
|
52
|
+
export function topicalTerms(tags?: string[]): string[] {
|
|
53
|
+
if (!tags || tags.length === 0) return [];
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
const seen = new Set<string>();
|
|
56
|
+
for (const t of tags) {
|
|
57
|
+
const m = /^(?:topic|proj|project)=(.+)$/i.exec(t);
|
|
58
|
+
if (!m) continue;
|
|
59
|
+
for (const w of m[1].toLowerCase().split(/[-_\s]+/)) {
|
|
60
|
+
if (w.length > 2 && !seen.has(w)) { seen.add(w); out.push(w); }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the text used for EMBEDDING a memory.
|
|
68
|
+
*
|
|
69
|
+
* When disabled this returns exactly `concept + ' ' + content`, byte-identical
|
|
70
|
+
* to the historical input — so leaving the flag off cannot change a single
|
|
71
|
+
* stored vector.
|
|
72
|
+
*/
|
|
73
|
+
export function buildRetrievalText(concept: string, content: string, tags?: string[]): string {
|
|
74
|
+
const base = `${concept} ${content}`;
|
|
75
|
+
if (!retrievalTextEnabled()) return base;
|
|
76
|
+
const terms = topicalTerms(tags);
|
|
77
|
+
if (terms.length === 0) return base;
|
|
78
|
+
// Appended at the END. The embedding model truncates beyond its context
|
|
79
|
+
// window, and the head of the content is what anchors the topic — putting tag
|
|
80
|
+
// terms first would displace the memory's actual subject in the vector.
|
|
81
|
+
return `${base} ${terms.join(' ')}`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Temporal expressions in a recall query.
|
|
5
|
+
*
|
|
6
|
+
* THE PROBLEM
|
|
7
|
+
* -----------
|
|
8
|
+
* Nothing in the pipeline parsed dates out of query text, and `ActivationQuery`
|
|
9
|
+
* had no date parameter. So "azure app plan from last Thursday or Friday" spent
|
|
10
|
+
* "last", "Thursday" and "Friday" as ordinary BM25 tokens — diluting the subject
|
|
11
|
+
* terms and matching `date=` tags right across the corpus.
|
|
12
|
+
*
|
|
13
|
+
* Measured on the real store (101 probes, tests/realstore-eval):
|
|
14
|
+
* subject only ............ 59.4% success@1 <- control
|
|
15
|
+
* + "from last Friday" .... 56.4% (-3.0pp)
|
|
16
|
+
* + "on 2026-05-01" ....... 51.5% (-7.9pp)
|
|
17
|
+
* ORACLE, week-filtered ... 96.0% (+36.6pp)
|
|
18
|
+
*
|
|
19
|
+
* So the most selective thing the user said was a PENALTY, and a working
|
|
20
|
+
* temporal filter is worth more than anything else measured on this store.
|
|
21
|
+
*
|
|
22
|
+
* DESIGN NOTES
|
|
23
|
+
* ------------
|
|
24
|
+
* - `asOf` is required for relative phrases. Using the wall clock would make a
|
|
25
|
+
* fixture non-reproducible: "last week" would silently mean something new on
|
|
26
|
+
* every run. Callers pass the query time explicitly.
|
|
27
|
+
* - Matching is deliberately CONSERVATIVE. A greedy matcher that fired on
|
|
28
|
+
* ordinary words would reshape every recall in the store, so each pattern is
|
|
29
|
+
* anchored on an unambiguous cue ("last", "yesterday", an ISO date, a month
|
|
30
|
+
* name). When nothing matches, this returns `null` and callers must treat
|
|
31
|
+
* that as a strict no-op.
|
|
32
|
+
* - The window is a PREFERENCE for callers, never a filter. People misremember
|
|
33
|
+
* dates — "last Thursday or Friday" is itself an admission of uncertainty —
|
|
34
|
+
* so a memory outside the window must stay reachable on subject strength.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export interface TemporalMatch {
|
|
38
|
+
/** Inclusive window start (UTC ms). */
|
|
39
|
+
from: number;
|
|
40
|
+
/** Exclusive window end (UTC ms). */
|
|
41
|
+
to: number;
|
|
42
|
+
/** The literal phrases matched, for logging and for stripping. */
|
|
43
|
+
matched: string[];
|
|
44
|
+
/** The query with temporal phrases removed, for the lexical channel. */
|
|
45
|
+
stripped: string;
|
|
46
|
+
/** How the window was derived — useful when explaining a recall. */
|
|
47
|
+
kind: 'relative-day' | 'relative-week' | 'relative-month' | 'recent' | 'absolute-date' | 'absolute-month';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DAY = 86400000;
|
|
51
|
+
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
52
|
+
const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june',
|
|
53
|
+
'july', 'august', 'september', 'october', 'november', 'december'];
|
|
54
|
+
|
|
55
|
+
const utcDay = (ms: number) => Math.floor(ms / DAY) * DAY;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a temporal expression out of `query`, anchored at `asOf`.
|
|
59
|
+
* Returns `null` when nothing matches — callers MUST treat that as a no-op.
|
|
60
|
+
*
|
|
61
|
+
* Windows are padded generously rather than tightly: the goal is to prefer a
|
|
62
|
+
* neighbourhood, not to pin an exact day the user probably misremembers.
|
|
63
|
+
*/
|
|
64
|
+
export function parseTemporal(query: string, asOf: Date | number): TemporalMatch | null {
|
|
65
|
+
const anchorMs = typeof asOf === 'number' ? asOf : asOf.getTime();
|
|
66
|
+
if (!Number.isFinite(anchorMs)) return null;
|
|
67
|
+
const q = query.toLowerCase();
|
|
68
|
+
const today = utcDay(anchorMs);
|
|
69
|
+
const matched: string[] = [];
|
|
70
|
+
|
|
71
|
+
const build = (from: number, to: number, kind: TemporalMatch['kind']): TemporalMatch => ({
|
|
72
|
+
from, to, matched,
|
|
73
|
+
stripped: stripPhrases(query, matched),
|
|
74
|
+
kind,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── absolute ISO date — ONLY with an explicit temporal cue ──
|
|
78
|
+
// A BARE date must not trigger this. Memories routinely carry a date as part
|
|
79
|
+
// of their SUBJECT ("Hive close-out 2026-04-24 PASS", "FIXED 2026-08-21 event
|
|
80
|
+
// River Glen"), where the date IS the fact, not a "when" filter. Treating
|
|
81
|
+
// those as temporal strips a strongly discriminative term and makes recall
|
|
82
|
+
// worse: measured, a bare-date rule fired on 98 of 1,316 (7.4%) real
|
|
83
|
+
// identifier queries and cost success@1 75.8% -> 75.0%. Same reason the month
|
|
84
|
+
// rule below requires an "in"/"during" cue.
|
|
85
|
+
const iso = q.match(/\b(on|from|since|before|after|around|during|dated)\s+(\d{4})-(\d{2})-(\d{2})\b/);
|
|
86
|
+
if (iso) {
|
|
87
|
+
matched.push(iso[0]);
|
|
88
|
+
const d = Date.UTC(+iso[2], +iso[3] - 1, +iso[4]);
|
|
89
|
+
// +/- 3 days: a remembered date is often a day or two off.
|
|
90
|
+
return build(d - 3 * DAY, d + 4 * DAY, 'absolute-date');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── "yesterday" / "today" ──
|
|
94
|
+
if (/\byesterday\b/.test(q)) {
|
|
95
|
+
matched.push('yesterday');
|
|
96
|
+
return build(today - DAY, today, 'relative-day');
|
|
97
|
+
}
|
|
98
|
+
if (/\btoday\b/.test(q)) {
|
|
99
|
+
matched.push('today');
|
|
100
|
+
return build(today, today + DAY, 'relative-day');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── "last <weekday>" / "on <weekday>" ──
|
|
104
|
+
const wd = q.match(/\b(?:last|this|on)\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/);
|
|
105
|
+
if (wd) {
|
|
106
|
+
matched.push(wd[0]);
|
|
107
|
+
// A second weekday ("Thursday or Friday") widens rather than confuses.
|
|
108
|
+
const second = q.match(/\bor\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/);
|
|
109
|
+
if (second) matched.push(second[0]);
|
|
110
|
+
const targets = [WEEKDAYS.indexOf(wd[1])];
|
|
111
|
+
if (second) targets.push(WEEKDAYS.indexOf(second[1]));
|
|
112
|
+
const anchorDow = new Date(today).getUTCDay();
|
|
113
|
+
const days = targets.map(t => {
|
|
114
|
+
let back = (anchorDow - t + 7) % 7;
|
|
115
|
+
if (back === 0) back = 7; // "last Friday" on a Friday means the previous one
|
|
116
|
+
return today - back * DAY;
|
|
117
|
+
});
|
|
118
|
+
// +/- 1 day of slack around the named day(s).
|
|
119
|
+
return build(Math.min(...days) - DAY, Math.max(...days) + 2 * DAY, 'relative-day');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── "last week" / "this week" / "past week" ──
|
|
123
|
+
if (/\b(?:last|this|past)\s+week\b/.test(q)) {
|
|
124
|
+
matched.push((q.match(/\b(?:last|this|past)\s+week\b/) as RegExpMatchArray)[0]);
|
|
125
|
+
return build(today - 14 * DAY, today + DAY, 'relative-week');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── "last month" / "this month" / "past month" ──
|
|
129
|
+
if (/\b(?:last|this|past)\s+month\b/.test(q)) {
|
|
130
|
+
matched.push((q.match(/\b(?:last|this|past)\s+month\b/) as RegExpMatchArray)[0]);
|
|
131
|
+
return build(today - 45 * DAY, today + DAY, 'relative-month');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── "last N days/weeks/months" ──
|
|
135
|
+
const lastN = q.match(/\b(?:last|past)\s+(\d{1,3})\s+(day|week|month)s?\b/);
|
|
136
|
+
if (lastN) {
|
|
137
|
+
matched.push(lastN[0]);
|
|
138
|
+
const n = +lastN[1];
|
|
139
|
+
const mult = lastN[2] === 'day' ? DAY : lastN[2] === 'week' ? 7 * DAY : 30 * DAY;
|
|
140
|
+
return build(today - n * mult, today + DAY, 'relative-week');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── "recently" / "recent" / "the other day" / "a few days ago" ──
|
|
144
|
+
if (/\b(?:recently|the other day|a few days ago|just now)\b/.test(q)) {
|
|
145
|
+
matched.push((q.match(/\b(?:recently|the other day|a few days ago|just now)\b/) as RegExpMatchArray)[0]);
|
|
146
|
+
return build(today - 14 * DAY, today + DAY, 'recent');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── "in <Month> [Year]" — requires the "in"/"during" cue so a project named
|
|
150
|
+
// "May" or a sentence containing "march" cannot trigger it accidentally.
|
|
151
|
+
const mon = q.match(/\b(?:in|during|back in)\s+(january|february|march|april|may|june|july|august|september|october|november|december)\b(?:\s+(\d{4}))?/);
|
|
152
|
+
if (mon) {
|
|
153
|
+
matched.push(mon[0]);
|
|
154
|
+
const mIdx = MONTHS.indexOf(mon[1]);
|
|
155
|
+
const anchorYear = new Date(today).getUTCFullYear();
|
|
156
|
+
const year = mon[2] ? +mon[2] : anchorYear;
|
|
157
|
+
const from = Date.UTC(year, mIdx, 1);
|
|
158
|
+
const to = Date.UTC(mIdx === 11 ? year + 1 : year, (mIdx + 1) % 12, 1);
|
|
159
|
+
return build(from, to, 'absolute-month');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Remove matched temporal phrases (and now-dangling connectives) from a query. */
|
|
166
|
+
function stripPhrases(query: string, phrases: string[]): string {
|
|
167
|
+
let out = query;
|
|
168
|
+
// Longest first, so "on 2026-05-01" is removed before the bare date.
|
|
169
|
+
for (const p of [...phrases].sort((a, b) => b.length - a.length)) {
|
|
170
|
+
out = out.replace(new RegExp(escapeRe(p), 'gi'), ' ');
|
|
171
|
+
}
|
|
172
|
+
return out
|
|
173
|
+
.replace(/\b(?:from|on|in|during|back in|since)\s*$/i, ' ')
|
|
174
|
+
.replace(/\s+(?:from|on|in|during)\s+(?=\s|$)/gi, ' ')
|
|
175
|
+
.replace(/\s{2,}/g, ' ')
|
|
176
|
+
.trim();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
180
|
+
|
|
181
|
+
/** Whether temporal handling is enabled. Default OFF. */
|
|
182
|
+
export function temporalEnabled(): boolean {
|
|
183
|
+
return process.env.AWM_TEMPORAL === '1';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Boost applied to a candidate whose creation time falls inside the window.
|
|
188
|
+
* A PREFERENCE, not a filter — see the design note above.
|
|
189
|
+
*/
|
|
190
|
+
export function temporalBoost(): number {
|
|
191
|
+
const v = Number(process.env.AWM_TEMPORAL_BOOST ?? 0.35);
|
|
192
|
+
return Number.isFinite(v) && v >= 0 ? v : 0.35;
|
|
193
|
+
}
|