agent-working-memory 0.16.1 → 0.18.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 +321 -318
- package/dist/adapters/common.d.ts +34 -0
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +528 -444
- package/dist/adapters/common.js.map +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +19 -0
- package/dist/api/routes.js.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +31 -29
- package/dist/cli/migrate.js.map +1 -1
- package/dist/core/asked-value.d.ts +37 -0
- package/dist/core/asked-value.d.ts.map +1 -0
- package/dist/core/asked-value.js +77 -0
- package/dist/core/asked-value.js.map +1 -0
- package/dist/core/conflicts.d.ts +89 -0
- package/dist/core/conflicts.d.ts.map +1 -0
- package/dist/core/conflicts.js +267 -0
- package/dist/core/conflicts.js.map +1 -0
- package/dist/core/embeddings.d.ts +14 -2
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +32 -10
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/format-recall.d.ts +21 -1
- package/dist/core/format-recall.d.ts.map +1 -1
- package/dist/core/format-recall.js +60 -5
- package/dist/core/format-recall.js.map +1 -1
- package/dist/core/ml-worker.d.ts +24 -1
- package/dist/core/ml-worker.d.ts.map +1 -1
- package/dist/core/ml-worker.js +165 -4
- package/dist/core/ml-worker.js.map +1 -1
- package/dist/core/model-daemon-client.d.ts +77 -0
- package/dist/core/model-daemon-client.d.ts.map +1 -0
- package/dist/core/model-daemon-client.js +383 -0
- package/dist/core/model-daemon-client.js.map +1 -0
- package/dist/core/model-daemon-server.d.ts +71 -0
- package/dist/core/model-daemon-server.d.ts.map +1 -0
- package/dist/core/model-daemon-server.js +363 -0
- package/dist/core/model-daemon-server.js.map +1 -0
- package/dist/core/model-daemon.d.ts +137 -0
- package/dist/core/model-daemon.d.ts.map +1 -0
- package/dist/core/model-daemon.js +231 -0
- package/dist/core/model-daemon.js.map +1 -0
- package/dist/core/query-expander.d.ts +5 -0
- package/dist/core/query-expander.d.ts.map +1 -1
- package/dist/core/query-expander.js +3 -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 +5 -0
- package/dist/core/recall-config.js.map +1 -1
- package/dist/core/rerank-window.d.ts +7 -2
- package/dist/core/rerank-window.d.ts.map +1 -1
- package/dist/core/rerank-window.js +9 -4
- package/dist/core/rerank-window.js.map +1 -1
- package/dist/core/rerank2.d.ts +52 -2
- package/dist/core/rerank2.d.ts.map +1 -1
- package/dist/core/rerank2.js +60 -3
- package/dist/core/rerank2.js.map +1 -1
- package/dist/core/reranker.d.ts +19 -0
- package/dist/core/reranker.d.ts.map +1 -1
- package/dist/core/reranker.js +76 -12
- package/dist/core/reranker.js.map +1 -1
- package/dist/core/salience.d.ts +2 -0
- package/dist/core/salience.d.ts.map +1 -1
- package/dist/core/salience.js +45 -18
- package/dist/core/salience.js.map +1 -1
- package/dist/core/trust.d.ts +32 -0
- package/dist/core/trust.d.ts.map +1 -0
- package/dist/core/trust.js +66 -0
- package/dist/core/trust.js.map +1 -0
- package/dist/core/validity.d.ts +21 -0
- package/dist/core/validity.d.ts.map +1 -0
- package/dist/core/validity.js +41 -0
- package/dist/core/validity.js.map +1 -0
- package/dist/core/whoami.d.ts +3 -0
- package/dist/core/whoami.d.ts.map +1 -1
- package/dist/core/whoami.js +14 -0
- package/dist/core/whoami.js.map +1 -1
- package/dist/core/write-pipeline.d.ts +48 -0
- package/dist/core/write-pipeline.d.ts.map +1 -1
- package/dist/core/write-pipeline.js +104 -4
- package/dist/core/write-pipeline.js.map +1 -1
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +27 -4
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +67 -5
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/hooks/prime-query.d.ts +57 -0
- package/dist/hooks/prime-query.d.ts.map +1 -0
- package/dist/hooks/prime-query.js +388 -0
- package/dist/hooks/prime-query.js.map +1 -0
- package/dist/hooks/prime.d.ts +8 -0
- package/dist/hooks/prime.d.ts.map +1 -1
- package/dist/hooks/prime.js +21 -2
- package/dist/hooks/prime.js.map +1 -1
- package/dist/hooks/sidecar.d.ts +7 -0
- package/dist/hooks/sidecar.d.ts.map +1 -1
- package/dist/hooks/sidecar.js +10 -2
- package/dist/hooks/sidecar.js.map +1 -1
- package/dist/index.js +11 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +148 -103
- package/dist/mcp.js.map +1 -1
- package/dist/model-daemon.d.ts +8 -0
- package/dist/model-daemon.d.ts.map +1 -0
- package/dist/model-daemon.js +45 -0
- package/dist/model-daemon.js.map +1 -0
- package/dist/storage/factory.d.ts.map +1 -1
- package/dist/storage/factory.js +5 -0
- package/dist/storage/factory.js.map +1 -1
- package/dist/storage/pglite-schema.d.ts.map +1 -1
- package/dist/storage/pglite-schema.js +176 -170
- package/dist/storage/pglite-schema.js.map +1 -1
- package/dist/storage/pglite.d.ts +5 -0
- package/dist/storage/pglite.d.ts.map +1 -1
- package/dist/storage/pglite.js +165 -153
- package/dist/storage/pglite.js.map +1 -1
- package/dist/storage/postgres.d.ts +5 -0
- package/dist/storage/postgres.d.ts.map +1 -1
- package/dist/storage/postgres.js +165 -153
- package/dist/storage/postgres.js.map +1 -1
- package/dist/storage/sqlite.d.ts +22 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +400 -363
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +33 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +106 -106
- 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 +16 -5
- package/src/adapters/common.ts +881 -800
- package/src/api/routes.ts +20 -0
- package/src/cli/migrate.ts +309 -307
- package/src/core/asked-value.ts +74 -0
- package/src/core/conflicts.ts +280 -0
- package/src/core/embeddings.ts +35 -10
- package/src/core/format-recall.ts +83 -25
- package/src/core/ml-worker.ts +458 -281
- package/src/core/model-daemon-client.ts +374 -0
- package/src/core/model-daemon-server.ts +344 -0
- package/src/core/model-daemon.ts +243 -0
- package/src/core/query-expander.ts +127 -126
- package/src/core/recall-config.ts +5 -0
- package/src/core/rerank-window.ts +164 -159
- package/src/core/rerank2.ts +143 -82
- package/src/core/reranker.ts +78 -11
- package/src/core/salience.ts +45 -18
- package/src/core/trust.ts +65 -0
- package/src/core/validity.ts +47 -0
- package/src/core/whoami.ts +136 -120
- package/src/core/write-pipeline.ts +143 -4
- package/src/engine/activation.ts +27 -4
- package/src/engine/consolidation.ts +950 -887
- package/src/hooks/prime-query.ts +375 -0
- package/src/hooks/prime.ts +25 -2
- package/src/hooks/sidecar.ts +13 -3
- package/src/index.ts +255 -248
- package/src/mcp.ts +1521 -1475
- package/src/model-daemon.ts +48 -0
- package/src/storage/factory.ts +25 -19
- package/src/storage/pglite-schema.ts +199 -193
- package/src/storage/pglite.ts +1513 -1498
- package/src/storage/postgres.ts +1613 -1598
- package/src/storage/sqlite.ts +2056 -2019
- package/src/types/engram.ts +30 -0
package/src/core/rerank2.ts
CHANGED
|
@@ -1,82 +1,143 @@
|
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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, and
|
|
43
|
+
* optionally the stale penalty Phase 8c applied to it (see below). */
|
|
44
|
+
export interface RerankScored {
|
|
45
|
+
phaseScores: { rerankerScore: number; stalePenalty?: number };
|
|
46
|
+
/** The Phase 7 blended score (composite + reranker). Carries decay, so recency. */
|
|
47
|
+
score?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* NEAR-TIES (0.18.1, found by MWA when it moved to rerank2-on defaults)
|
|
52
|
+
* -----------------------------------------------------------------------
|
|
53
|
+
* The cross-encoder judges relevance to the query and nothing else. Three
|
|
54
|
+
* same-topic facts written in October, December and March ("the book club
|
|
55
|
+
* chose a fantasy novel / science fiction / a mystery") score within a few
|
|
56
|
+
* hundredths of each other for "what did the book club most recently
|
|
57
|
+
* choose", and a sort by reranker score alone put the newest LAST. The blend
|
|
58
|
+
* carries decay, so it knows which is newest; replacing it outright threw
|
|
59
|
+
* that away exactly where the cross-encoder has nothing to say.
|
|
60
|
+
*
|
|
61
|
+
* The first fix tried was a score epsilon: within `AWM_RERANK2_TIE_EPS` the
|
|
62
|
+
* blended score decides. MEASURED at 0.03 on the private snapshot (2026-10-19):
|
|
63
|
+
* identifier s@1 93.0% -> 78.7%, topic 92.2% -> 84.4%. The cross-encoder's
|
|
64
|
+
* scores saturate near 1.0 for the top candidates, so hundredths separate most
|
|
65
|
+
* top-2 pairs and are informative there; the blend loses to it 77% of the time
|
|
66
|
+
* on disagreements. So the epsilon DEFAULTS TO 0 (off) and exists only for
|
|
67
|
+
* experiments. The recency rule lives where it belongs instead: among results
|
|
68
|
+
* the conflict detector marks as two versions of one fact, the newest goes
|
|
69
|
+
* first (conflicts.ts `newestFirstAmongConflicts`, applied in activation.ts).
|
|
70
|
+
* That cannot touch a suite whose golds are single facts.
|
|
71
|
+
*/
|
|
72
|
+
export function rerank2TieEpsilon(): number {
|
|
73
|
+
const v = Number(process.env.AWM_RERANK2_TIE_EPS ?? 0);
|
|
74
|
+
return Number.isFinite(v) && v >= 0 ? v : 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* THE STALE-SUPPRESSION HOLE (found 2026-10-19, MWA evaluation probe)
|
|
79
|
+
* --------------------------------------------------------------------
|
|
80
|
+
* Phase 8c multiplies a superseded (or, since 2026-10-19, expired) memory's
|
|
81
|
+
* `score` by 0.15 so its successor dominates. This stage sorted the window by
|
|
82
|
+
* raw `rerankerScore`, which never carried that penalty — so with
|
|
83
|
+
* `AWM_RERANK2=1` (what `awm setup` writes into every Claude Code install) a
|
|
84
|
+
* superseded memory came back at rank 1 ABOVE its replacement whenever the
|
|
85
|
+
* cross-encoder liked its wording better. The supersession test ran with the
|
|
86
|
+
* reranker off, so nothing caught it. `tests/core/rerank2.test.ts` now pins the
|
|
87
|
+
* corrected behaviour: the sort key is `rerankerScore × stalePenalty`, the same
|
|
88
|
+
* factor Phase 8c used, so stale rows sort below current ones here exactly as
|
|
89
|
+
* they do in the blend. The soundness guards below are unchanged.
|
|
90
|
+
*/
|
|
91
|
+
export function rerankSortKey(r: RerankScored): number {
|
|
92
|
+
return r.phaseScores.rerankerScore * (r.phaseScores.stalePenalty ?? 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Re-sort the first `k` entries of `ranked` by descending `rerankerScore`,
|
|
97
|
+
* leaving the tail untouched. Returns a new array; never mutates the input.
|
|
98
|
+
*
|
|
99
|
+
* Returns `ranked` unchanged when reordering would be unsound:
|
|
100
|
+
* - `k <= 1`, or fewer than 2 entries to order
|
|
101
|
+
* - any entry in the window has `rerankerScore <= 0`, which means the
|
|
102
|
+
* cross-encoder did not score it (skipped, failed, or timed out). Sorting
|
|
103
|
+
* on a zero would scramble an otherwise sound composite ordering — the
|
|
104
|
+
* reranker is wrapped in try/catch and a 10s timeout upstream, so this is a
|
|
105
|
+
* real path, not a theoretical one.
|
|
106
|
+
*/
|
|
107
|
+
export function reorderByReranker<T extends RerankScored>(ranked: T[], k: number): T[] {
|
|
108
|
+
if (k <= 1 || ranked.length <= 1) return ranked;
|
|
109
|
+
|
|
110
|
+
const window = Math.min(k, ranked.length);
|
|
111
|
+
if (window <= 1) return ranked;
|
|
112
|
+
|
|
113
|
+
const head = ranked.slice(0, window);
|
|
114
|
+
for (const r of head) {
|
|
115
|
+
if (!(r.phaseScores.rerankerScore > 0)) return ranked; // also catches NaN
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const eps = rerank2TieEpsilon();
|
|
119
|
+
const sorted = head.slice().sort((a, b) => {
|
|
120
|
+
const d = rerankSortKey(b) - rerankSortKey(a);
|
|
121
|
+
if (eps > 0 && Math.abs(d) < eps) return (b.score ?? 0) - (a.score ?? 0); // experiment only; see above
|
|
122
|
+
return d;
|
|
123
|
+
});
|
|
124
|
+
return sorted.concat(ranked.slice(window));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Window size for the second stage. */
|
|
128
|
+
export function rerank2WindowSize(): number {
|
|
129
|
+
return Number(process.env.AWM_RERANK2_K ?? 10);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether the second stage is enabled. Default ON since 2026-10-19; set
|
|
134
|
+
* `AWM_RERANK2=0` to disable. It was opt-in (`=1`) from 2026-08-24, injected
|
|
135
|
+
* into every config `awm setup` wrote — and a config that did not carry the
|
|
136
|
+
* three strings ran degraded for 47 days without anyone noticing, because
|
|
137
|
+
* degraded recall looks exactly like working recall. The measured-good
|
|
138
|
+
* configuration is now what the code does when nothing is set; the env var
|
|
139
|
+
* is the escape hatch, like the other diagnostics knobs.
|
|
140
|
+
*/
|
|
141
|
+
export function rerank2Enabled(): boolean {
|
|
142
|
+
return process.env.AWM_RERANK2 !== '0';
|
|
143
|
+
}
|
package/src/core/reranker.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type PreTrainedTokenizer,
|
|
20
20
|
type PreTrainedModel,
|
|
21
21
|
} from '@huggingface/transformers';
|
|
22
|
-
import { dispatchRerank, registerInProcessHandlers } from './ml-worker.js';
|
|
22
|
+
import { dispatchRerank, registerInferenceConfig, registerInProcessHandlers } from './ml-worker.js';
|
|
23
23
|
import { ensureModelCacheDir } from './model-cache.js';
|
|
24
24
|
|
|
25
25
|
const DEFAULT_MODEL = 'Xenova/ms-marco-MiniLM-L-6-v2';
|
|
@@ -67,11 +67,60 @@ function sigmoid(x: number): number {
|
|
|
67
67
|
return 1 / (1 + Math.exp(-x));
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Input-shape caps for the cross-encoder. They exist for MEMORY, and one of them also
|
|
72
|
+
* fixes RANKING.
|
|
73
|
+
*
|
|
74
|
+
* onnxruntime-node keeps a CPU memory arena per model that grows to the largest tensor
|
|
75
|
+
* it has ever run and never shrinks. A cross-encoder's attention tensor is
|
|
76
|
+
* batch x heads x seq x seq floats, so its size is quadratic in the padded pair length.
|
|
77
|
+
* Measured 2026-10-16 on AWM 0.17.0: six Claude Code sessions held 13.2 GB, JS heap flat
|
|
78
|
+
* at ~85 MB, and one process reranking 24 long pairs kept ~1.75 GB after going idle.
|
|
79
|
+
* The shapes come from the prime hook, which recalls with the WHOLE user prompt (pastes
|
|
80
|
+
* included), and from recall's 24-40 passage pool: 40 pairs at 512 tokens make one
|
|
81
|
+
* attention tensor of 40 x 12 x 512 x 512 x 4 B = ~500 MB.
|
|
82
|
+
*
|
|
83
|
+
* - AWM_RERANK_BATCH (default 8): passages are scored in chunks. A pair's score depends
|
|
84
|
+
* only on that pair (padding is masked), so this changes memory, not order.
|
|
85
|
+
* - AWM_RERANK_QUERY_TOKENS (default 128): a longer query keeps its first and last
|
|
86
|
+
* halves. transformers.js truncates a too-long pair by cutting tokens off the END,
|
|
87
|
+
* which is the passage: before this cap, a ~510-token query left the passage no tokens
|
|
88
|
+
* at all, and every memory was scored against the query alone. Passages are already
|
|
89
|
+
* capped at ~400 chars (rerank-window.ts), so a 128-token query typically keeps a
|
|
90
|
+
* pair under ~260 tokens (more for CJK text or a raised AWM_RERANK_TRUNC).
|
|
91
|
+
*
|
|
92
|
+
* An explicit 0 disables a cap. Anything else that is not a non-negative integer (empty,
|
|
93
|
+
* 'off', -1) falls back to the default, as AWM_RERANK_TRUNC does: a typo must not quietly
|
|
94
|
+
* bring back the unbounded shape.
|
|
95
|
+
*/
|
|
96
|
+
function shapeCap(name: string, fallback: number): number {
|
|
97
|
+
const v = Number(process.env[name]?.trim() || NaN);
|
|
98
|
+
return Number.isInteger(v) && v >= 0 ? v : fallback;
|
|
99
|
+
}
|
|
100
|
+
const RERANK_BATCH = shapeCap('AWM_RERANK_BATCH', 8);
|
|
101
|
+
const RERANK_QUERY_TOKENS = shapeCap('AWM_RERANK_QUERY_TOKENS', 128);
|
|
74
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The query the cross-encoder sees: unchanged when it fits the cap, else its first and
|
|
105
|
+
* last halves. Both ends, because a pasted prompt puts the question either before the
|
|
106
|
+
* paste or after it. Special tokens typed into a long query ([SEP], [UNK]) are dropped by
|
|
107
|
+
* the decode; a query under the cap passes through untouched.
|
|
108
|
+
*/
|
|
109
|
+
export function capRerankQuery(tok: PreTrainedTokenizer, query: string, maxTokens = RERANK_QUERY_TOKENS): string {
|
|
110
|
+
if (maxTokens <= 0) return query;
|
|
111
|
+
const ids = tok.encode(query, { add_special_tokens: false });
|
|
112
|
+
if (ids.length <= maxTokens) return query;
|
|
113
|
+
const head = Math.ceil(maxTokens / 2);
|
|
114
|
+
const tail = maxTokens - head;
|
|
115
|
+
const decode = (part: number[]) => tok.decode(part, { skip_special_tokens: true, clean_up_tokenization_spaces: true });
|
|
116
|
+
// A tail that starts mid-word decodes with a leading '##' continuation marker, which
|
|
117
|
+
// would re-encode as two stray '#' tokens.
|
|
118
|
+
return tail > 0
|
|
119
|
+
? `${decode(ids.slice(0, head))} ${decode(ids.slice(ids.length - tail)).replace(/^##/, '')}`
|
|
120
|
+
: decode(ids.slice(0, head));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function scoreChunk(query: string, passages: string[], offset: number): Promise<Array<{ index: number; score: number }>> {
|
|
75
124
|
// Batch path
|
|
76
125
|
try {
|
|
77
126
|
const queries = passages.map(() => query);
|
|
@@ -94,9 +143,8 @@ async function inProcessRerank(args: { query: string; passages: string[] }): Pro
|
|
|
94
143
|
const results: Array<{ index: number; score: number }> = [];
|
|
95
144
|
for (let i = 0; i < passages.length; i++) {
|
|
96
145
|
const rawLogit = Number(data[i] ?? 0);
|
|
97
|
-
results.push({ index: i, score: sigmoid(rawLogit) });
|
|
146
|
+
results.push({ index: offset + i, score: sigmoid(rawLogit) });
|
|
98
147
|
}
|
|
99
|
-
results.sort((a, b) => b.score - a.score);
|
|
100
148
|
return results;
|
|
101
149
|
} catch {
|
|
102
150
|
// Per-passage fallback (the original 0.7.13 path)
|
|
@@ -113,18 +161,37 @@ async function inProcessRerank(args: { query: string; passages: string[] }): Pro
|
|
|
113
161
|
const output = await model!(inputs);
|
|
114
162
|
const logits = output.logits ?? output.last_hidden_state;
|
|
115
163
|
const rawLogit = logits.data[0] as number;
|
|
116
|
-
results.push({ index: i, score: sigmoid(rawLogit) });
|
|
164
|
+
results.push({ index: offset + i, score: sigmoid(rawLogit) });
|
|
117
165
|
} catch {
|
|
118
|
-
results.push({ index: i, score: 0 });
|
|
166
|
+
results.push({ index: offset + i, score: 0 });
|
|
119
167
|
}
|
|
120
168
|
}
|
|
121
|
-
results.sort((a, b) => b.score - a.score);
|
|
122
169
|
return results;
|
|
123
170
|
}
|
|
124
171
|
}
|
|
125
172
|
|
|
173
|
+
/** Exported with its caps as parameters so tests can compare settings in one process. */
|
|
174
|
+
export async function inProcessRerank(
|
|
175
|
+
args: { query: string; passages: string[] },
|
|
176
|
+
caps: { batch?: number; queryTokens?: number } = {},
|
|
177
|
+
): Promise<Array<{ index: number; score: number }>> {
|
|
178
|
+
const { passages } = args;
|
|
179
|
+
if (passages.length === 0) return [];
|
|
180
|
+
await ensureLoaded();
|
|
181
|
+
const query = capRerankQuery(tokenizer!, args.query, caps.queryTokens ?? RERANK_QUERY_TOKENS);
|
|
182
|
+
const batch = caps.batch ?? RERANK_BATCH;
|
|
183
|
+
const size = batch > 0 ? batch : passages.length;
|
|
184
|
+
const results: Array<{ index: number; score: number }> = [];
|
|
185
|
+
for (let b = 0; b < passages.length; b += size) {
|
|
186
|
+
results.push(...await scoreChunk(query, passages.slice(b, b + size), b));
|
|
187
|
+
}
|
|
188
|
+
results.sort((a, b) => b.score - a.score);
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
|
|
126
192
|
// Register the in-process handler with the pool
|
|
127
|
-
registerInProcessHandlers({ rerank: inProcessRerank });
|
|
193
|
+
registerInProcessHandlers({ rerank: (args) => inProcessRerank(args) });
|
|
194
|
+
registerInferenceConfig({ rerankModel: MODEL_ID, rerankDtype: DTYPE, rerankBatch: RERANK_BATCH, rerankQueryTokens: RERANK_QUERY_TOKENS });
|
|
128
195
|
|
|
129
196
|
// --- Public API ---
|
|
130
197
|
|
package/src/core/salience.ts
CHANGED
|
@@ -16,42 +16,69 @@ import type { IEngramStore as EngramStore } from '../storage/store.js';
|
|
|
16
16
|
export type SalienceEventType = 'decision' | 'friction' | 'surprise' | 'causal' | 'observation' | 'user_feedback';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Auto-detect user-feedback memories: content that begins with a
|
|
20
|
-
* name + a feedback verb. These memories represent direct human
|
|
21
|
-
* must never be discarded.
|
|
22
|
-
* "
|
|
23
|
-
* "
|
|
24
|
-
* "
|
|
19
|
+
* Auto-detect user-feedback memories: content that begins with a configured
|
|
20
|
+
* stakeholder's name + a feedback verb. These memories represent direct human
|
|
21
|
+
* decisions and must never be discarded. With AWM_FEEDBACK_NAMES="Ada,Grace":
|
|
22
|
+
* "Ada verbatim: 'programs-first, like the CRM'"
|
|
23
|
+
* "Grace said the certification cycle resets on promotion"
|
|
24
|
+
* "Ada directed that tier 1 has no grace period"
|
|
25
25
|
*
|
|
26
26
|
* Why this exists: the BM25 novelty check collapses near-duplicates regardless
|
|
27
27
|
* of whether the content is a NEW decision or a repeat observation. User
|
|
28
|
-
* feedback often shares terminology with prior memories (
|
|
29
|
-
*
|
|
30
|
-
* 2026-05-06T19:08:47). Detecting "
|
|
31
|
-
* the salience filter entirely.
|
|
28
|
+
* feedback often shares terminology with prior memories (the same handful of
|
|
29
|
+
* domain nouns recur in every one) and gets discarded at salience 0.14 (verified
|
|
30
|
+
* in activity log 2026-05-06T19:08:47). Detecting "<name> said X" → canonical
|
|
31
|
+
* class bypasses the salience filter entirely.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* Set via AWM_FEEDBACK_NAMES (comma list). A name must be followed by whitespace
|
|
34
|
+
* and then a verb, so a longer name sharing a configured prefix ("Adaline" for a
|
|
35
|
+
* configured "Ada") does not match.
|
|
35
36
|
*/
|
|
36
|
-
// D4 (2026-07-30): names/verbs are configurable so the library is not coupled
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
37
|
+
// D4 (2026-07-30): names/verbs are configurable so the library is not coupled to one
|
|
38
|
+
// organization's staff list. AWM_FEEDBACK_NAMES / AWM_FEEDBACK_VERBS take comma lists.
|
|
39
|
+
//
|
|
40
|
+
// 2026-10-11: the default is now EMPTY, which disables the detector until an operator
|
|
41
|
+
// configures it. It previously shipped 22 real first names — a deployment's own staff
|
|
42
|
+
// list, published in the npm tarball (`src/` is in package.json `files`) and readable by
|
|
43
|
+
// anyone who installed the package. That is other people's data, and a general-purpose
|
|
44
|
+
// library has no business carrying it. The flip was the decision of record from the start
|
|
45
|
+
// (docs/archive/eval-decisions-2026-07-30.md:37, "installer seeds Robert's current values
|
|
46
|
+
// so behavior is unchanged for him"); D4 shipped only the configurability half and
|
|
47
|
+
// deferred the flip to 1.0. It is brought forward here because the exposure is live.
|
|
48
|
+
//
|
|
49
|
+
// Operators who relied on the built-in list must now set AWM_FEEDBACK_NAMES explicitly —
|
|
50
|
+
// in the MCP server's `env` block, since that is the only place guaranteed to be read
|
|
51
|
+
// (the .env loader in cli.ts/mcp.ts resolves against process.cwd(), which an MCP server
|
|
52
|
+
// does not control). Without it, "<name> said X" no longer auto-promotes to canonical;
|
|
53
|
+
// an explicit memory_class: 'canonical' on the write still does.
|
|
54
|
+
const DEFAULT_FEEDBACK_NAMES = '';
|
|
41
55
|
const DEFAULT_FEEDBACK_VERBS = 'said,verbatim,feedback,asked,wants,prefers,requested,directed,decided,confirmed,clarified,chose,specified,explained';
|
|
42
56
|
function csvToAlternation(env: string | undefined, fallback: string): string {
|
|
43
57
|
return (env ?? fallback).split(',').map(x => x.trim()).filter(Boolean)
|
|
44
58
|
.map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
45
59
|
.join('|');
|
|
46
60
|
}
|
|
61
|
+
const FEEDBACK_NAMES_ALTERNATION = csvToAlternation(process.env.AWM_FEEDBACK_NAMES, DEFAULT_FEEDBACK_NAMES);
|
|
47
62
|
const USER_FEEDBACK_PATTERN = new RegExp(
|
|
48
|
-
'^(' +
|
|
63
|
+
'^(' + FEEDBACK_NAMES_ALTERNATION + ')\\s+(' +
|
|
49
64
|
csvToAlternation(process.env.AWM_FEEDBACK_VERBS, DEFAULT_FEEDBACK_VERBS) + ')\\b',
|
|
50
65
|
'i',
|
|
51
66
|
);
|
|
52
67
|
|
|
68
|
+
/** True when at least one feedback name is configured; otherwise the detector is off. */
|
|
69
|
+
export function userFeedbackDetectionEnabled(): boolean {
|
|
70
|
+
return FEEDBACK_NAMES_ALTERNATION.length > 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
/** Returns true if the content looks like direct user feedback that should auto-promote to canonical. */
|
|
54
74
|
export function detectUserFeedback(content: string): boolean {
|
|
75
|
+
// No configured names means the detector is OFF, as a decision rather than as an
|
|
76
|
+
// accident of the regex. An empty name list compiles to `^()\s+(said|asked|...)`, whose
|
|
77
|
+
// empty first group matches the empty string — so the pattern degrades to "starts with
|
|
78
|
+
// whitespace then a feedback verb". That is unreachable today only because the content
|
|
79
|
+
// is trimmed one line below, which makes correctness depend on the order of two
|
|
80
|
+
// unrelated statements. Check the configuration instead.
|
|
81
|
+
if (!userFeedbackDetectionEnabled()) return false;
|
|
55
82
|
if (typeof content !== 'string' || content.length === 0) return false;
|
|
56
83
|
return USER_FEEDBACK_PATTERN.test(content.trim());
|
|
57
84
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Trust tiers on writes and memories, and the one rule the write pipeline
|
|
5
|
+
* applies with them: a merge or a replacement must never RAISE the trust of
|
|
6
|
+
* text, and must never retire a more-trusted memory on the word of a
|
|
7
|
+
* less-trusted write.
|
|
8
|
+
*
|
|
9
|
+
* Found by MWA BL-17 (2026-10-14/15), five isolated-review rounds with
|
|
10
|
+
* probes: the pipeline merged a write into an existing memory on concept
|
|
11
|
+
* equality and the merged engram KEPT THE OLD ENGRAM'S TAGS — so a poisoned
|
|
12
|
+
* skill merged into a clean one came back trust=medium and primeable; a
|
|
13
|
+
* planted untrusted decoy under the same concept let a poisoned near-copy
|
|
14
|
+
* merge into the trusted fact; an archived trusted fact was revived with
|
|
15
|
+
* poisoned text merged in; and an R3 "correction" (eventType surprise) from
|
|
16
|
+
* untrusted text retired a trusted memory. MWA works around all of it in its
|
|
17
|
+
* own wrapper. Any other client writing mixed-trust content was exposed.
|
|
18
|
+
*
|
|
19
|
+
* AWM has no trust model of its own, and this does not add one. It reads the
|
|
20
|
+
* tag MWA's memory spine already writes (`trust=<tier>`) and refuses the
|
|
21
|
+
* dangerous direction. The write is still stored — as its own engram, with
|
|
22
|
+
* its own tags — so nothing is lost and the conflict mark can say the two
|
|
23
|
+
* disagree. Untagged writes and memories are unaffected: the rule only fires
|
|
24
|
+
* when the write is EXPLICITLY untrusted, or when both sides carry a tier and
|
|
25
|
+
* the write's is lower.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Ordered tiers; higher is more trusted. */
|
|
29
|
+
const TIERS: Record<string, number> = {
|
|
30
|
+
untrusted: 0, none: 0, quarantine: 0,
|
|
31
|
+
low: 1,
|
|
32
|
+
medium: 2, med: 2,
|
|
33
|
+
high: 3, trusted: 3, verified: 3, owner: 3,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The tier a `trust=<tier>` tag declares, or null when there is none. */
|
|
37
|
+
export function trustTier(tags: readonly string[] | null | undefined): number | null {
|
|
38
|
+
if (!tags) return null;
|
|
39
|
+
let found: number | null = null;
|
|
40
|
+
for (const t of tags) {
|
|
41
|
+
const m = /^trust=([a-z]+)$/i.exec(t.trim());
|
|
42
|
+
if (!m) continue;
|
|
43
|
+
const v = TIERS[m[1].toLowerCase()];
|
|
44
|
+
if (v === undefined) continue;
|
|
45
|
+
// Several tags: the lowest wins — a memory is as trusted as its least trusted label.
|
|
46
|
+
found = found === null ? v : Math.min(found, v);
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when `writeTags` must not merge into, supersede or revive a memory
|
|
53
|
+
* carrying `targetTags`.
|
|
54
|
+
*/
|
|
55
|
+
export function mergeBlockedByTrust(
|
|
56
|
+
writeTags: readonly string[] | null | undefined,
|
|
57
|
+
targetTags: readonly string[] | null | undefined,
|
|
58
|
+
): boolean {
|
|
59
|
+
const w = trustTier(writeTags);
|
|
60
|
+
if (w === null) return false; // untagged write: no rule
|
|
61
|
+
const t = trustTier(targetTags);
|
|
62
|
+
if (w === 0) return t === null || t > 0; // explicitly untrusted: only into untrusted
|
|
63
|
+
if (t === null) return false; // tagged write, untagged target: no rule
|
|
64
|
+
return w < t;
|
|
65
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Temporal validity (D8) — the one place that decides whether a memory's
|
|
5
|
+
* `valid_from` / `valid_to` window holds at a given moment.
|
|
6
|
+
*
|
|
7
|
+
* Until 2026-10-19 nothing read these fields except the two recall formatters,
|
|
8
|
+
* which printed `[valid until …]` on expired memories exactly as on current
|
|
9
|
+
* ones. The live store was serving 482 memories past their `valid_to` as
|
|
10
|
+
* current (97 active, 385 archived). The guidance given to agents ("use
|
|
11
|
+
* `valid_to` … so the memory expires") promised behaviour the code did not have.
|
|
12
|
+
*
|
|
13
|
+
* Ranking (activation.ts Phase 8c), the MCP recall format and the prime hook
|
|
14
|
+
* all go through here so they cannot disagree about what "expired" means.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type ValidityState = 'current' | 'expired' | 'not-yet-valid';
|
|
18
|
+
|
|
19
|
+
/** The fields this needs — any engram-shaped object qualifies. */
|
|
20
|
+
export interface TemporallyBounded {
|
|
21
|
+
validFrom?: string | null;
|
|
22
|
+
validTo?: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A date-only `valid_to` ("2026-01-15") means "through that day", so it is
|
|
27
|
+
* compared against the END of that day in UTC. A full datetime is taken as
|
|
28
|
+
* written. An unparseable value is ignored: a bad bound must not hide a memory.
|
|
29
|
+
*/
|
|
30
|
+
function boundMs(value: string, endOfDay: boolean): number {
|
|
31
|
+
const t = Date.parse(value);
|
|
32
|
+
if (!Number.isFinite(t)) return NaN;
|
|
33
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value.trim());
|
|
34
|
+
return dateOnly && endOfDay ? t + 86_400_000 - 1 : t;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validityState(e: TemporallyBounded, now: number = Date.now()): ValidityState {
|
|
38
|
+
if (e.validTo) {
|
|
39
|
+
const until = boundMs(e.validTo, true);
|
|
40
|
+
if (Number.isFinite(until) && now > until) return 'expired';
|
|
41
|
+
}
|
|
42
|
+
if (e.validFrom) {
|
|
43
|
+
const from = boundMs(e.validFrom, false);
|
|
44
|
+
if (Number.isFinite(from) && now < from) return 'not-yet-valid';
|
|
45
|
+
}
|
|
46
|
+
return 'current';
|
|
47
|
+
}
|