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.
Files changed (77) hide show
  1. package/AGENTS.md +36 -0
  2. package/README.md +23 -12
  3. package/dist/adapters/common.d.ts +1 -1
  4. package/dist/adapters/common.d.ts.map +1 -1
  5. package/dist/adapters/common.js +33 -20
  6. package/dist/adapters/common.js.map +1 -1
  7. package/dist/core/embeddings.d.ts +1 -1
  8. package/dist/core/embeddings.js +1 -1
  9. package/dist/core/entity-extract.js +1 -1
  10. package/dist/core/query-expander.d.ts +4 -2
  11. package/dist/core/query-expander.d.ts.map +1 -1
  12. package/dist/core/query-expander.js +4 -2
  13. package/dist/core/query-expander.js.map +1 -1
  14. package/dist/core/recall-config.d.ts +1 -1
  15. package/dist/core/recall-config.d.ts.map +1 -1
  16. package/dist/core/recall-config.js +6 -1
  17. package/dist/core/recall-config.js.map +1 -1
  18. package/dist/core/recall-telemetry.d.ts +15 -0
  19. package/dist/core/recall-telemetry.d.ts.map +1 -0
  20. package/dist/core/recall-telemetry.js +108 -0
  21. package/dist/core/recall-telemetry.js.map +1 -0
  22. package/dist/core/rerank-window.d.ts +5 -4
  23. package/dist/core/rerank-window.d.ts.map +1 -1
  24. package/dist/core/rerank-window.js +5 -4
  25. package/dist/core/rerank-window.js.map +1 -1
  26. package/dist/core/reranker.d.ts +2 -1
  27. package/dist/core/reranker.d.ts.map +1 -1
  28. package/dist/core/reranker.js +23 -3
  29. package/dist/core/reranker.js.map +1 -1
  30. package/dist/core/salience.js +1 -1
  31. package/dist/core/salience.js.map +1 -1
  32. package/dist/core/token-budget.d.ts +2 -2
  33. package/dist/core/token-budget.js +2 -2
  34. package/dist/core/whoami.d.ts +1 -1
  35. package/dist/engine/activation.d.ts +30 -13
  36. package/dist/engine/activation.d.ts.map +1 -1
  37. package/dist/engine/activation.js +159 -50
  38. package/dist/engine/activation.js.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/mcp.js +98 -98
  42. package/dist/mcp.js.map +1 -1
  43. package/dist/storage/pglite.d.ts +28 -0
  44. package/dist/storage/pglite.d.ts.map +1 -1
  45. package/dist/storage/pglite.js +55 -0
  46. package/dist/storage/pglite.js.map +1 -1
  47. package/dist/storage/postgres.d.ts +28 -0
  48. package/dist/storage/postgres.d.ts.map +1 -1
  49. package/dist/storage/postgres.js +55 -0
  50. package/dist/storage/postgres.js.map +1 -1
  51. package/dist/storage/sqlite.d.ts +28 -0
  52. package/dist/storage/sqlite.d.ts.map +1 -1
  53. package/dist/storage/sqlite.js +56 -0
  54. package/dist/storage/sqlite.js.map +1 -1
  55. package/llms.txt +437 -0
  56. package/package.json +7 -1
  57. package/plugin/.claude-plugin/plugin.json +1 -1
  58. package/plugin/README.md +1 -1
  59. package/plugin/bin/awm-mcp-launcher.cjs +2 -2
  60. package/plugin/skills/awm-memory/SKILL.md +33 -20
  61. package/src/adapters/common.ts +33 -20
  62. package/src/core/embeddings.ts +1 -1
  63. package/src/core/entity-extract.ts +1 -1
  64. package/src/core/query-expander.ts +4 -2
  65. package/src/core/recall-config.ts +6 -1
  66. package/src/core/recall-telemetry.ts +131 -0
  67. package/src/core/rerank-window.ts +159 -158
  68. package/src/core/reranker.ts +24 -3
  69. package/src/core/salience.ts +1 -1
  70. package/src/core/token-budget.ts +160 -160
  71. package/src/core/whoami.ts +120 -120
  72. package/src/engine/activation.ts +159 -51
  73. package/src/index.ts +1 -1
  74. package/src/mcp.ts +1475 -1475
  75. package/src/storage/pglite.ts +49 -0
  76. package/src/storage/postgres.ts +49 -0
  77. 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
- * ~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
- }
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
+ }
@@ -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 (~22MB ONNX) trained on MS-MARCO
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: 'fp32' });
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
  }
@@ -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 labelled event with no
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.