agent-working-memory 0.15.9 → 0.16.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/AGENTS.md +36 -0
- package/README.md +23 -12
- package/dist/adapters/common.d.ts +1 -1
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +33 -20
- package/dist/adapters/common.js.map +1 -1
- package/dist/core/embeddings.d.ts +1 -1
- package/dist/core/embeddings.js +1 -1
- package/dist/core/entity-extract.js +1 -1
- package/dist/core/query-expander.d.ts +4 -2
- package/dist/core/query-expander.d.ts.map +1 -1
- package/dist/core/query-expander.js +4 -2
- package/dist/core/query-expander.js.map +1 -1
- package/dist/core/recall-config.d.ts +1 -1
- package/dist/core/recall-config.d.ts.map +1 -1
- package/dist/core/recall-config.js +6 -1
- package/dist/core/recall-config.js.map +1 -1
- package/dist/core/recall-telemetry.d.ts +15 -0
- package/dist/core/recall-telemetry.d.ts.map +1 -0
- package/dist/core/recall-telemetry.js +108 -0
- package/dist/core/recall-telemetry.js.map +1 -0
- package/dist/core/rerank-window.d.ts +5 -4
- package/dist/core/rerank-window.d.ts.map +1 -1
- package/dist/core/rerank-window.js +5 -4
- package/dist/core/rerank-window.js.map +1 -1
- package/dist/core/reranker.d.ts +2 -1
- package/dist/core/reranker.d.ts.map +1 -1
- package/dist/core/reranker.js +23 -3
- package/dist/core/reranker.js.map +1 -1
- package/dist/core/salience.js +1 -1
- package/dist/core/salience.js.map +1 -1
- package/dist/core/token-budget.d.ts +2 -2
- package/dist/core/token-budget.js +2 -2
- package/dist/core/whoami.d.ts +1 -1
- package/dist/engine/activation.d.ts +30 -13
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +159 -50
- package/dist/engine/activation.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +98 -98
- package/dist/mcp.js.map +1 -1
- package/dist/storage/pglite.d.ts +28 -0
- package/dist/storage/pglite.d.ts.map +1 -1
- package/dist/storage/pglite.js +55 -0
- package/dist/storage/pglite.js.map +1 -1
- package/dist/storage/postgres.d.ts +28 -0
- package/dist/storage/postgres.d.ts.map +1 -1
- package/dist/storage/postgres.js +55 -0
- package/dist/storage/postgres.js.map +1 -1
- package/dist/storage/sqlite.d.ts +28 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +56 -0
- package/dist/storage/sqlite.js.map +1 -1
- package/llms.txt +437 -0
- package/package.json +7 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/README.md +1 -1
- package/plugin/bin/awm-mcp-launcher.cjs +2 -2
- package/plugin/skills/awm-memory/SKILL.md +33 -20
- package/src/adapters/common.ts +33 -20
- package/src/core/embeddings.ts +1 -1
- package/src/core/entity-extract.ts +1 -1
- package/src/core/query-expander.ts +4 -2
- package/src/core/recall-config.ts +6 -1
- package/src/core/recall-telemetry.ts +131 -0
- package/src/core/rerank-window.ts +159 -158
- package/src/core/reranker.ts +24 -3
- package/src/core/salience.ts +1 -1
- package/src/core/token-budget.ts +160 -160
- package/src/core/whoami.ts +120 -120
- package/src/engine/activation.ts +159 -51
- package/src/index.ts +1 -1
- package/src/mcp.ts +1475 -1475
- package/src/storage/pglite.ts +49 -0
- package/src/storage/postgres.ts +49 -0
- package/src/storage/sqlite.ts +51 -0
|
@@ -1,158 +1,159 @@
|
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
hits.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* - `'
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
* 83% of warm recall latency (measured, `npm run profile:recall`), so "just send
|
|
13
|
+
* everything" is not available.
|
|
14
|
+
*
|
|
15
|
+
* But a PREFIX is the wrong 400 chars. Measured on the live 29.8k store:
|
|
16
|
+
* - canonical memories median 1,965 chars, 98.7% exceed 400
|
|
17
|
+
* - the reranker cannot see 78.8% of each canonical memory's vocabulary
|
|
18
|
+
* - 99.9% of long canonical memories carry identifiers only past char 400
|
|
19
|
+
* And measured on tests/longmem-eval: moving the answer from char 150 to char
|
|
20
|
+
* 700 takes success@1 from 100% to 0%, with the gold's cross-encoder score
|
|
21
|
+
* collapsing 0.986 -> 0.000 while its BM25 score barely moves. The memory stays
|
|
22
|
+
* retrievable and stops being rankable.
|
|
23
|
+
*
|
|
24
|
+
* THE FIX
|
|
25
|
+
* -------
|
|
26
|
+
* Spend the same character budget on the window that actually contains the
|
|
27
|
+
* query's terms, instead of on whatever happens to be at the top of the memory.
|
|
28
|
+
* Cost is unchanged — same budget, same batch padding, same inference — so this
|
|
29
|
+
* buys ranking quality without buying latency.
|
|
30
|
+
*
|
|
31
|
+
* The concept line is always kept: it is short, it is the memory's title, and
|
|
32
|
+
* it is what a human wrote to summarise the thing.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Cheap tokenizer for locating query terms inside a passage. */
|
|
36
|
+
function terms(query: string): string[] {
|
|
37
|
+
const raw = query.toLowerCase().match(/[a-z0-9_][a-z0-9_.\-]{1,}/g) ?? [];
|
|
38
|
+
const STOP = new Set([
|
|
39
|
+
'the', 'and', 'for', 'with', 'that', 'this', 'from', 'what', 'where', 'when',
|
|
40
|
+
'which', 'does', 'did', 'was', 'were', 'are', 'is', 'be', 'to', 'of', 'in',
|
|
41
|
+
'on', 'at', 'by', 'it', 'as', 'do', 'how', 'why', 'a', 'an', 'or',
|
|
42
|
+
]);
|
|
43
|
+
return Array.from(new Set(raw.filter(t => t.length >= 3 && !STOP.has(t))));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Choose the `budget`-char window of `content` densest in query terms.
|
|
48
|
+
* Returns the head of the content when nothing matches — the old behavior,
|
|
49
|
+
* which is the right fallback: with no query signal there is no reason to
|
|
50
|
+
* prefer any other part of the memory.
|
|
51
|
+
*/
|
|
52
|
+
export function densestWindow(content: string, query: string, budget: number): string {
|
|
53
|
+
if (content.length <= budget) return content;
|
|
54
|
+
|
|
55
|
+
const toks = terms(query);
|
|
56
|
+
if (toks.length === 0) return content.slice(0, budget);
|
|
57
|
+
|
|
58
|
+
const lower = content.toLowerCase();
|
|
59
|
+
const hits: number[] = [];
|
|
60
|
+
for (const t of toks) {
|
|
61
|
+
let from = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const i = lower.indexOf(t, from);
|
|
64
|
+
if (i < 0) break;
|
|
65
|
+
hits.push(i);
|
|
66
|
+
from = i + t.length;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (hits.length === 0) return content.slice(0, budget);
|
|
70
|
+
hits.sort((a, b) => a - b);
|
|
71
|
+
|
|
72
|
+
// Slide a window anchored slightly before each hit; keep the one covering most.
|
|
73
|
+
let bestStart = 0;
|
|
74
|
+
let bestCount = -1;
|
|
75
|
+
for (let i = 0; i < hits.length; i++) {
|
|
76
|
+
const start = Math.max(0, hits[i] - Math.floor(budget / 5));
|
|
77
|
+
let count = 0;
|
|
78
|
+
for (let j = i; j < hits.length && hits[j] - start < budget; j++) count++;
|
|
79
|
+
if (count > bestCount) { bestCount = count; bestStart = start; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Snap to a word boundary so the cross-encoder is not handed a split token.
|
|
83
|
+
let start = bestStart;
|
|
84
|
+
if (start > 0) {
|
|
85
|
+
const sp = content.indexOf(' ', start);
|
|
86
|
+
if (sp >= 0 && sp - start < 40) start = sp + 1;
|
|
87
|
+
}
|
|
88
|
+
const slice = content.slice(start, start + budget);
|
|
89
|
+
return (start > 0 ? '…' : '') + slice + (start + budget < content.length ? '…' : '');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the passage handed to the cross-encoder for one candidate.
|
|
94
|
+
*
|
|
95
|
+
* `mode`:
|
|
96
|
+
* - `'prefix'` (default) — legacy behavior, the first `budget` chars.
|
|
97
|
+
* - `'query'` — the `budget`-char window densest in query terms.
|
|
98
|
+
*/
|
|
99
|
+
export function buildRerankPassage(
|
|
100
|
+
concept: string,
|
|
101
|
+
content: string,
|
|
102
|
+
query: string,
|
|
103
|
+
budget: number,
|
|
104
|
+
mode: 'prefix' | 'query',
|
|
105
|
+
tags?: string[],
|
|
106
|
+
): string {
|
|
107
|
+
const body = mode === 'query'
|
|
108
|
+
? densestWindow(content, query, budget)
|
|
109
|
+
: (content.length > budget ? content.slice(0, budget) : content);
|
|
110
|
+
// Topical tags, when enabled. The cross-encoder decides final order since
|
|
111
|
+
// phase 9b, and it cannot see tags at all — measured on the live store, 66.2%
|
|
112
|
+
// of topical tag terms never appear in the body, so that vocabulary is
|
|
113
|
+
// invisible to the stage that now decides ranking. Appended (not substituted)
|
|
114
|
+
// and length-capped so it cannot crowd out the content window.
|
|
115
|
+
const extra = rerankTagText(tags);
|
|
116
|
+
return extra ? `${concept}: ${body} ${extra}` : `${concept}: ${body}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether topical tags are appended to the rerank passage. Default OFF. */
|
|
120
|
+
export function rerankTagsEnabled(): boolean {
|
|
121
|
+
return process.env.AWM_RERANK_TAGS === '1';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Character budget for the appended tag text. */
|
|
125
|
+
export function rerankTagBudget(): number {
|
|
126
|
+
const v = Number(process.env.AWM_RERANK_TAGS_LEN ?? 80);
|
|
127
|
+
return Number.isFinite(v) && v > 0 ? v : 80;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Render topical tags as plain terms for the cross-encoder.
|
|
132
|
+
* Only `topic=` / `proj=` / `project=` carry query vocabulary; date/person/
|
|
133
|
+
* ticket tags are identifiers the body usually already contains, and adding
|
|
134
|
+
* them would spend the budget without adding reachable words.
|
|
135
|
+
*/
|
|
136
|
+
export function rerankTagText(tags?: string[]): string {
|
|
137
|
+
if (!rerankTagsEnabled() || !tags || tags.length === 0) return '';
|
|
138
|
+
const words = new Set<string>();
|
|
139
|
+
for (const t of tags) {
|
|
140
|
+
const m = /^(?:topic|proj|project)=(.+)$/i.exec(t);
|
|
141
|
+
if (!m) continue;
|
|
142
|
+
for (const w of m[1].toLowerCase().split(/[-_\s]+/)) {
|
|
143
|
+
if (w.length > 2) words.add(w);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (words.size === 0) return '';
|
|
147
|
+
return `[${[...words].join(' ').slice(0, rerankTagBudget())}]`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Character budget for a rerank passage. */
|
|
151
|
+
export function rerankTruncation(): number {
|
|
152
|
+
const v = Number(process.env.AWM_RERANK_TRUNC ?? 400);
|
|
153
|
+
return Number.isFinite(v) && v > 0 ? v : 400;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Passage selection mode. Default `prefix` preserves shipped behavior. */
|
|
157
|
+
export function rerankWindowMode(): 'prefix' | 'query' {
|
|
158
|
+
return process.env.AWM_RERANK_WINDOW === 'query' ? 'query' : 'prefix';
|
|
159
|
+
}
|
package/src/core/reranker.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Cross-Encoder Re-Ranker - scores (query, passage) pairs for relevance.
|
|
5
5
|
*
|
|
6
|
-
* Uses Xenova/ms-marco-MiniLM-L-6-v2 (
|
|
6
|
+
* Uses Xenova/ms-marco-MiniLM-L-6-v2 (91MB ONNX at the fp32 default; the 23MB
|
|
7
|
+
* quantized file is what AWM_RERANKER_DTYPE=q8 loads) trained on MS-MARCO
|
|
7
8
|
* passage ranking. Unlike bi-encoders, cross-encoders see both query and
|
|
8
9
|
* passage together via full attention - much better at judging if a
|
|
9
10
|
* passage actually answers a question.
|
|
@@ -24,6 +25,26 @@ import { ensureModelCacheDir } from './model-cache.js';
|
|
|
24
25
|
const DEFAULT_MODEL = 'Xenova/ms-marco-MiniLM-L-6-v2';
|
|
25
26
|
const MODEL_ID = process.env.AWM_RERANKER_MODEL || DEFAULT_MODEL;
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Weight precision for the cross-encoder. fp32 is the shipped default and the
|
|
30
|
+
* only value any published number was measured at.
|
|
31
|
+
*
|
|
32
|
+
* It is exposed because the cross-encoder is 83% of warm recall latency
|
|
33
|
+
* (measured, `npm run profile:recall`), which makes precision the largest
|
|
34
|
+
* single latency lever in the system — and an unmeasurable one while it was a
|
|
35
|
+
* literal. RE-MEASURED 2026-10-09 on the pipeline that ships: 'q8' cuts total
|
|
36
|
+
* recall latency 26-33% across three suites, for ONE QUERY in 450 — identifier
|
|
37
|
+
* success@1 unchanged at 93.0%, topic 92.2% -> 92.0%. Correct abstention is
|
|
38
|
+
* unmoved on all three suites; success@5 is unmoved on both private suites and
|
|
39
|
+
* moves +0.5pp on the public corpus, inside its 4pp seed-noise floor. It is deterministic (two runs agree
|
|
40
|
+
* on all 450 queries to 1e-6), and because no model is bundled it SHRINKS the
|
|
41
|
+
* first-run fetch: the ONNX file it loads is 23MB against fp32's 91MB. 'q4' is dominated: equal
|
|
42
|
+
* accuracy, only -3% latency, 6.1s cold start against fp32's 1.6s. The default
|
|
43
|
+
* is untouched because it is a product decision, not because the cost is
|
|
44
|
+
* unknown; see docs/recall-latency.md.
|
|
45
|
+
*/
|
|
46
|
+
const DTYPE = (process.env.AWM_RERANKER_DTYPE || 'fp32') as 'fp32' | 'fp16' | 'q8' | 'int8' | 'uint8' | 'q4';
|
|
47
|
+
|
|
27
48
|
// --- In-process fallback ---
|
|
28
49
|
|
|
29
50
|
let tokenizer: PreTrainedTokenizer | null = null;
|
|
@@ -36,8 +57,8 @@ async function ensureLoaded(): Promise<void> {
|
|
|
36
57
|
initPromise = (async () => {
|
|
37
58
|
ensureModelCacheDir();
|
|
38
59
|
tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID);
|
|
39
|
-
model = await AutoModelForSequenceClassification.from_pretrained(MODEL_ID, { dtype:
|
|
40
|
-
console.error(`Re-ranker model loaded in-process: ${MODEL_ID}`);
|
|
60
|
+
model = await AutoModelForSequenceClassification.from_pretrained(MODEL_ID, { dtype: DTYPE });
|
|
61
|
+
console.error(`Re-ranker model loaded in-process: ${MODEL_ID} (${DTYPE})`);
|
|
41
62
|
})();
|
|
42
63
|
return initPromise;
|
|
43
64
|
}
|
package/src/core/salience.ts
CHANGED
|
@@ -246,7 +246,7 @@ export function evaluateSalience(
|
|
|
246
246
|
case 'observation': break;
|
|
247
247
|
}
|
|
248
248
|
// Signal-weakness gate. The novelty score alone (~0.45 for fresh content)
|
|
249
|
-
// would clear the active threshold (0.4), so any
|
|
249
|
+
// would clear the active threshold (0.4), so any labeled event with no
|
|
250
250
|
// backing numerical signals lands as 'active' regardless of the label's
|
|
251
251
|
// semantics. That's wrong for friction/causal: those types describe events
|
|
252
252
|
// that *happened to the agent* and benefit from explicit intensity signals.
|