agent-working-memory 0.9.1 → 0.11.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 (80) hide show
  1. package/README.md +89 -19
  2. package/dist/adapters/common.d.ts.map +1 -1
  3. package/dist/adapters/common.js +5 -1
  4. package/dist/adapters/common.js.map +1 -1
  5. package/dist/api/routes.d.ts.map +1 -1
  6. package/dist/api/routes.js +2 -1
  7. package/dist/api/routes.js.map +1 -1
  8. package/dist/cli/migrate.js +29 -29
  9. package/dist/cli.js +405 -224
  10. package/dist/cli.js.map +1 -1
  11. package/dist/coordination/circuit-breaker.js +23 -23
  12. package/dist/core/salience.d.ts.map +1 -1
  13. package/dist/core/salience.js +10 -1
  14. package/dist/core/salience.js.map +1 -1
  15. package/dist/core/write-pipeline.d.ts.map +1 -1
  16. package/dist/core/write-pipeline.js +5 -1
  17. package/dist/core/write-pipeline.js.map +1 -1
  18. package/dist/index.js +2 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/mcp.js +50 -3
  21. package/dist/mcp.js.map +1 -1
  22. package/dist/onboard/index.d.ts +68 -0
  23. package/dist/onboard/index.d.ts.map +1 -0
  24. package/dist/onboard/index.js +265 -0
  25. package/dist/onboard/index.js.map +1 -0
  26. package/dist/storage/factory.d.ts +1 -1
  27. package/dist/storage/factory.d.ts.map +1 -1
  28. package/dist/storage/factory.js +16 -2
  29. package/dist/storage/factory.js.map +1 -1
  30. package/dist/storage/pglite-schema.js +143 -143
  31. package/dist/storage/pglite.d.ts.map +1 -1
  32. package/dist/storage/pglite.js +8 -0
  33. package/dist/storage/pglite.js.map +1 -1
  34. package/dist/storage/postgres.d.ts +228 -0
  35. package/dist/storage/postgres.d.ts.map +1 -0
  36. package/dist/storage/postgres.js +1221 -0
  37. package/dist/storage/postgres.js.map +1 -0
  38. package/dist/version.d.ts +2 -0
  39. package/dist/version.d.ts.map +1 -0
  40. package/dist/version.js +27 -0
  41. package/dist/version.js.map +1 -0
  42. package/package.json +11 -1
  43. package/src/adapters/common.ts +5 -1
  44. package/src/api/index.ts +3 -3
  45. package/src/api/routes.ts +2 -1
  46. package/src/cli/migrate.ts +307 -307
  47. package/src/cli.ts +342 -273
  48. package/src/coordination/circuit-breaker.ts +83 -83
  49. package/src/coordination/failure-modes.ts +50 -50
  50. package/src/core/decay.ts +63 -63
  51. package/src/core/embeddings.ts +110 -110
  52. package/src/core/index.ts +5 -5
  53. package/src/core/logger.ts +36 -36
  54. package/src/core/ml-worker-entry.ts +194 -194
  55. package/src/core/ml-worker.ts +281 -281
  56. package/src/core/query-expander.ts +122 -122
  57. package/src/core/reranker.ts +119 -119
  58. package/src/core/salience.ts +10 -1
  59. package/src/core/write-pipeline.ts +5 -1
  60. package/src/engine/confidence.ts +120 -120
  61. package/src/engine/consolidation-scheduler.ts +242 -242
  62. package/src/engine/eval.ts +102 -102
  63. package/src/engine/eviction.ts +101 -101
  64. package/src/engine/index.ts +8 -8
  65. package/src/engine/retraction.ts +366 -366
  66. package/src/engine/staging.ts +74 -74
  67. package/src/index.ts +2 -1
  68. package/src/mcp.ts +62 -3
  69. package/src/onboard/index.ts +298 -0
  70. package/src/storage/factory.ts +15 -3
  71. package/src/storage/index.ts +3 -3
  72. package/src/storage/pglite-schema.ts +166 -166
  73. package/src/storage/pglite.ts +9 -0
  74. package/src/storage/postgres.ts +1475 -0
  75. package/src/storage/store.ts +80 -80
  76. package/src/types/agent.ts +67 -67
  77. package/src/types/checkpoint.ts +46 -46
  78. package/src/types/eval.ts +100 -100
  79. package/src/types/index.ts +6 -6
  80. package/src/version.ts +26 -0
@@ -1,122 +1,122 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Query Expander - rewrites queries with synonyms and related terms.
5
- *
6
- * Uses Xenova/flan-t5-small (~80MB ONNX) to expand search queries with
7
- * related terms that improve BM25 recall.
8
- *
9
- * AWM 0.8.x: inference dispatches through ml-worker.ts (currently in-process
10
- * — worker_threads reverted because onnxruntime-node bindings cross isolate
11
- * boundaries unsafely; see ml-worker.ts). The dispatch abstraction is
12
- * preserved for a future child_process / HTTP sidecar pool.
13
- *
14
- * The LRU cache + skip heuristic stay on the main thread — they're pure
15
- * filter/lookup logic that shouldn't pay IPC cost.
16
- */
17
-
18
- import { pipeline, type Text2TextGenerationPipeline } from '@huggingface/transformers';
19
- import { dispatchExpand, registerInProcessHandlers } from './ml-worker.js';
20
-
21
- const MODEL_ID = 'Xenova/flan-t5-small';
22
-
23
- // --- In-process fallback ---
24
-
25
- let inProcessInstance: Text2TextGenerationPipeline | null = null;
26
- let inProcessInitPromise: Promise<Text2TextGenerationPipeline> | null = null;
27
-
28
- async function loadInProcess(): Promise<Text2TextGenerationPipeline> {
29
- if (inProcessInstance) return inProcessInstance;
30
- if (inProcessInitPromise) return inProcessInitPromise;
31
- inProcessInitPromise = pipeline('text2text-generation', MODEL_ID, { dtype: 'fp32' }).then(pipe => {
32
- inProcessInstance = pipe as Text2TextGenerationPipeline;
33
- console.log(`Query expander loaded in-process: ${MODEL_ID}`);
34
- return inProcessInstance;
35
- });
36
- return inProcessInitPromise;
37
- }
38
-
39
- async function inProcessExpand(args: { prompt: string; maxNewTokens: number; noRepeatNgramSize: number }): Promise<string> {
40
- const expander = await loadInProcess();
41
- const result = await expander(args.prompt, {
42
- max_new_tokens: args.maxNewTokens,
43
- no_repeat_ngram_size: args.noRepeatNgramSize,
44
- });
45
- const text = Array.isArray(result) ? (result[0] as any)?.generated_text ?? '' : '';
46
- return String(text).trim();
47
- }
48
-
49
- registerInProcessHandlers({ expand: inProcessExpand });
50
-
51
- // --- Public API ---
52
-
53
- /** Kept for backwards compat. */
54
- export async function getExpander(): Promise<Text2TextGenerationPipeline> {
55
- return loadInProcess();
56
- }
57
-
58
- /**
59
- * LRU cache of normalized-query → expanded-query mappings.
60
- * Lives on the main thread — cache hits skip the worker IPC entirely.
61
- */
62
- const expansionCache = new Map<string, string>();
63
- const EXPANSION_CACHE_LIMIT = 500;
64
-
65
- /**
66
- * Skip expansion when the query is already specific (long or many tokens).
67
- */
68
- function shouldSkipExpansion(normalized: string): boolean {
69
- if (normalized.length === 0) return true;
70
- if (normalized.length > 50) return true;
71
- const tokens = new Set(normalized.split(/\s+/).filter(t => t.length > 2));
72
- return tokens.size >= 5;
73
- }
74
-
75
- /**
76
- * Expand a query with related terms and synonyms.
77
- * Returns the original query + generated expansion terms.
78
- * Falls back to the original query on any error.
79
- *
80
- * Dispatches inference to the worker pool. Cache + skip heuristic stay
81
- * on the main thread.
82
- */
83
- export async function expandQuery(originalQuery: string): Promise<string> {
84
- const normalized = originalQuery.toLowerCase().trim();
85
- const optimizationsEnabled = process.env.AWM_DISABLE_EXPANSION_CACHE !== '1';
86
-
87
- if (optimizationsEnabled) {
88
- if (shouldSkipExpansion(normalized)) return originalQuery;
89
- const cached = expansionCache.get(normalized);
90
- if (cached !== undefined) {
91
- // LRU touch
92
- expansionCache.delete(normalized);
93
- expansionCache.set(normalized, cached);
94
- return cached;
95
- }
96
- }
97
-
98
- try {
99
- const prompt = `Expand this search query with synonyms and related terms. Only output the additional terms, not the original query. Query: ${originalQuery}. Additional terms:`;
100
- const expansion = await dispatchExpand({ prompt, maxNewTokens: 25, noRepeatNgramSize: 2 });
101
- const finalQuery = expansion && expansion.length > 2
102
- ? `${originalQuery} ${expansion}`
103
- : originalQuery;
104
-
105
- if (optimizationsEnabled) {
106
- if (expansionCache.size >= EXPANSION_CACHE_LIMIT) {
107
- const oldestKey = expansionCache.keys().next().value;
108
- if (oldestKey !== undefined) expansionCache.delete(oldestKey);
109
- }
110
- expansionCache.set(normalized, finalQuery);
111
- }
112
-
113
- return finalQuery;
114
- } catch {
115
- return originalQuery;
116
- }
117
- }
118
-
119
- /** Clear the expansion cache (used by tests + cache invalidation). */
120
- export function clearExpansionCache(): void {
121
- expansionCache.clear();
122
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Query Expander - rewrites queries with synonyms and related terms.
5
+ *
6
+ * Uses Xenova/flan-t5-small (~80MB ONNX) to expand search queries with
7
+ * related terms that improve BM25 recall.
8
+ *
9
+ * AWM 0.8.x: inference dispatches through ml-worker.ts (currently in-process
10
+ * — worker_threads reverted because onnxruntime-node bindings cross isolate
11
+ * boundaries unsafely; see ml-worker.ts). The dispatch abstraction is
12
+ * preserved for a future child_process / HTTP sidecar pool.
13
+ *
14
+ * The LRU cache + skip heuristic stay on the main thread — they're pure
15
+ * filter/lookup logic that shouldn't pay IPC cost.
16
+ */
17
+
18
+ import { pipeline, type Text2TextGenerationPipeline } from '@huggingface/transformers';
19
+ import { dispatchExpand, registerInProcessHandlers } from './ml-worker.js';
20
+
21
+ const MODEL_ID = 'Xenova/flan-t5-small';
22
+
23
+ // --- In-process fallback ---
24
+
25
+ let inProcessInstance: Text2TextGenerationPipeline | null = null;
26
+ let inProcessInitPromise: Promise<Text2TextGenerationPipeline> | null = null;
27
+
28
+ async function loadInProcess(): Promise<Text2TextGenerationPipeline> {
29
+ if (inProcessInstance) return inProcessInstance;
30
+ if (inProcessInitPromise) return inProcessInitPromise;
31
+ inProcessInitPromise = pipeline('text2text-generation', MODEL_ID, { dtype: 'fp32' }).then(pipe => {
32
+ inProcessInstance = pipe as Text2TextGenerationPipeline;
33
+ console.log(`Query expander loaded in-process: ${MODEL_ID}`);
34
+ return inProcessInstance;
35
+ });
36
+ return inProcessInitPromise;
37
+ }
38
+
39
+ async function inProcessExpand(args: { prompt: string; maxNewTokens: number; noRepeatNgramSize: number }): Promise<string> {
40
+ const expander = await loadInProcess();
41
+ const result = await expander(args.prompt, {
42
+ max_new_tokens: args.maxNewTokens,
43
+ no_repeat_ngram_size: args.noRepeatNgramSize,
44
+ });
45
+ const text = Array.isArray(result) ? (result[0] as any)?.generated_text ?? '' : '';
46
+ return String(text).trim();
47
+ }
48
+
49
+ registerInProcessHandlers({ expand: inProcessExpand });
50
+
51
+ // --- Public API ---
52
+
53
+ /** Kept for backwards compat. */
54
+ export async function getExpander(): Promise<Text2TextGenerationPipeline> {
55
+ return loadInProcess();
56
+ }
57
+
58
+ /**
59
+ * LRU cache of normalized-query → expanded-query mappings.
60
+ * Lives on the main thread — cache hits skip the worker IPC entirely.
61
+ */
62
+ const expansionCache = new Map<string, string>();
63
+ const EXPANSION_CACHE_LIMIT = 500;
64
+
65
+ /**
66
+ * Skip expansion when the query is already specific (long or many tokens).
67
+ */
68
+ function shouldSkipExpansion(normalized: string): boolean {
69
+ if (normalized.length === 0) return true;
70
+ if (normalized.length > 50) return true;
71
+ const tokens = new Set(normalized.split(/\s+/).filter(t => t.length > 2));
72
+ return tokens.size >= 5;
73
+ }
74
+
75
+ /**
76
+ * Expand a query with related terms and synonyms.
77
+ * Returns the original query + generated expansion terms.
78
+ * Falls back to the original query on any error.
79
+ *
80
+ * Dispatches inference to the worker pool. Cache + skip heuristic stay
81
+ * on the main thread.
82
+ */
83
+ export async function expandQuery(originalQuery: string): Promise<string> {
84
+ const normalized = originalQuery.toLowerCase().trim();
85
+ const optimizationsEnabled = process.env.AWM_DISABLE_EXPANSION_CACHE !== '1';
86
+
87
+ if (optimizationsEnabled) {
88
+ if (shouldSkipExpansion(normalized)) return originalQuery;
89
+ const cached = expansionCache.get(normalized);
90
+ if (cached !== undefined) {
91
+ // LRU touch
92
+ expansionCache.delete(normalized);
93
+ expansionCache.set(normalized, cached);
94
+ return cached;
95
+ }
96
+ }
97
+
98
+ try {
99
+ const prompt = `Expand this search query with synonyms and related terms. Only output the additional terms, not the original query. Query: ${originalQuery}. Additional terms:`;
100
+ const expansion = await dispatchExpand({ prompt, maxNewTokens: 25, noRepeatNgramSize: 2 });
101
+ const finalQuery = expansion && expansion.length > 2
102
+ ? `${originalQuery} ${expansion}`
103
+ : originalQuery;
104
+
105
+ if (optimizationsEnabled) {
106
+ if (expansionCache.size >= EXPANSION_CACHE_LIMIT) {
107
+ const oldestKey = expansionCache.keys().next().value;
108
+ if (oldestKey !== undefined) expansionCache.delete(oldestKey);
109
+ }
110
+ expansionCache.set(normalized, finalQuery);
111
+ }
112
+
113
+ return finalQuery;
114
+ } catch {
115
+ return originalQuery;
116
+ }
117
+ }
118
+
119
+ /** Clear the expansion cache (used by tests + cache invalidation). */
120
+ export function clearExpansionCache(): void {
121
+ expansionCache.clear();
122
+ }
@@ -1,119 +1,119 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Cross-Encoder Re-Ranker - scores (query, passage) pairs for relevance.
5
- *
6
- * Uses Xenova/ms-marco-MiniLM-L-6-v2 (~22MB ONNX) trained on MS-MARCO
7
- * passage ranking. Unlike bi-encoders, cross-encoders see both query and
8
- * passage together via full attention - much better at judging if a
9
- * passage actually answers a question.
10
- *
11
- * AWM 0.8.x: inference dispatches through ml-worker.ts (currently in-process
12
- * — see ml-worker.ts for the worker_threads → in-process revert rationale).
13
- */
14
-
15
- import {
16
- AutoTokenizer,
17
- AutoModelForSequenceClassification,
18
- type PreTrainedTokenizer,
19
- type PreTrainedModel,
20
- } from '@huggingface/transformers';
21
- import { dispatchRerank, registerInProcessHandlers } from './ml-worker.js';
22
-
23
- const DEFAULT_MODEL = 'Xenova/ms-marco-MiniLM-L-6-v2';
24
- const MODEL_ID = process.env.AWM_RERANKER_MODEL || DEFAULT_MODEL;
25
-
26
- // --- In-process fallback ---
27
-
28
- let tokenizer: PreTrainedTokenizer | null = null;
29
- let model: PreTrainedModel | null = null;
30
- let initPromise: Promise<void> | null = null;
31
-
32
- async function ensureLoaded(): Promise<void> {
33
- if (tokenizer && model) return;
34
- if (initPromise) return initPromise;
35
- initPromise = (async () => {
36
- tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID);
37
- model = await AutoModelForSequenceClassification.from_pretrained(MODEL_ID, { dtype: 'fp32' });
38
- console.log(`Re-ranker model loaded in-process: ${MODEL_ID}`);
39
- })();
40
- return initPromise;
41
- }
42
-
43
- function sigmoid(x: number): number {
44
- return 1 / (1 + Math.exp(-x));
45
- }
46
-
47
- async function inProcessRerank(args: { query: string; passages: string[] }): Promise<Array<{ index: number; score: number }>> {
48
- const { query, passages } = args;
49
- if (passages.length === 0) return [];
50
- await ensureLoaded();
51
-
52
- // Batch path
53
- try {
54
- const queries = passages.map(() => query);
55
- const inputs = tokenizer!(queries, {
56
- text_pair: passages,
57
- padding: true,
58
- truncation: true,
59
- return_tensors: 'pt',
60
- });
61
- const output = await model!(inputs);
62
- const logits = output.logits ?? output.last_hidden_state;
63
- const data = logits.data as Float32Array | number[];
64
- const results: Array<{ index: number; score: number }> = [];
65
- for (let i = 0; i < passages.length; i++) {
66
- const rawLogit = Number(data[i] ?? 0);
67
- results.push({ index: i, score: sigmoid(rawLogit) });
68
- }
69
- results.sort((a, b) => b.score - a.score);
70
- return results;
71
- } catch {
72
- // Per-passage fallback (the original 0.7.13 path)
73
- const results: Array<{ index: number; score: number }> = [];
74
- for (let i = 0; i < passages.length; i++) {
75
- try {
76
- const inputs = tokenizer!(query, {
77
- text_pair: passages[i],
78
- padding: true,
79
- truncation: true,
80
- return_tensors: 'pt',
81
- });
82
- const output = await model!(inputs);
83
- const logits = output.logits ?? output.last_hidden_state;
84
- const rawLogit = logits.data[0] as number;
85
- results.push({ index: i, score: sigmoid(rawLogit) });
86
- } catch {
87
- results.push({ index: i, score: 0 });
88
- }
89
- }
90
- results.sort((a, b) => b.score - a.score);
91
- return results;
92
- }
93
- }
94
-
95
- // Register the in-process handler with the pool
96
- registerInProcessHandlers({ rerank: inProcessRerank });
97
-
98
- // --- Public API ---
99
-
100
- /** Kept for backwards compat. */
101
- export async function getReranker(): Promise<any> {
102
- await ensureLoaded();
103
- return model;
104
- }
105
-
106
- export interface RerankResult {
107
- index: number;
108
- score: number; // sigmoid-normalized relevance (0-1)
109
- }
110
-
111
- /**
112
- * Re-rank candidate passages against a query using the cross-encoder.
113
- * Returns results sorted by relevance score (descending).
114
- * Dispatches to the worker pool (or in-process fallback).
115
- */
116
- export async function rerank(query: string, passages: string[]): Promise<RerankResult[]> {
117
- if (passages.length === 0) return [];
118
- return dispatchRerank({ query, passages });
119
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Cross-Encoder Re-Ranker - scores (query, passage) pairs for relevance.
5
+ *
6
+ * Uses Xenova/ms-marco-MiniLM-L-6-v2 (~22MB ONNX) trained on MS-MARCO
7
+ * passage ranking. Unlike bi-encoders, cross-encoders see both query and
8
+ * passage together via full attention - much better at judging if a
9
+ * passage actually answers a question.
10
+ *
11
+ * AWM 0.8.x: inference dispatches through ml-worker.ts (currently in-process
12
+ * — see ml-worker.ts for the worker_threads → in-process revert rationale).
13
+ */
14
+
15
+ import {
16
+ AutoTokenizer,
17
+ AutoModelForSequenceClassification,
18
+ type PreTrainedTokenizer,
19
+ type PreTrainedModel,
20
+ } from '@huggingface/transformers';
21
+ import { dispatchRerank, registerInProcessHandlers } from './ml-worker.js';
22
+
23
+ const DEFAULT_MODEL = 'Xenova/ms-marco-MiniLM-L-6-v2';
24
+ const MODEL_ID = process.env.AWM_RERANKER_MODEL || DEFAULT_MODEL;
25
+
26
+ // --- In-process fallback ---
27
+
28
+ let tokenizer: PreTrainedTokenizer | null = null;
29
+ let model: PreTrainedModel | null = null;
30
+ let initPromise: Promise<void> | null = null;
31
+
32
+ async function ensureLoaded(): Promise<void> {
33
+ if (tokenizer && model) return;
34
+ if (initPromise) return initPromise;
35
+ initPromise = (async () => {
36
+ tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID);
37
+ model = await AutoModelForSequenceClassification.from_pretrained(MODEL_ID, { dtype: 'fp32' });
38
+ console.log(`Re-ranker model loaded in-process: ${MODEL_ID}`);
39
+ })();
40
+ return initPromise;
41
+ }
42
+
43
+ function sigmoid(x: number): number {
44
+ return 1 / (1 + Math.exp(-x));
45
+ }
46
+
47
+ async function inProcessRerank(args: { query: string; passages: string[] }): Promise<Array<{ index: number; score: number }>> {
48
+ const { query, passages } = args;
49
+ if (passages.length === 0) return [];
50
+ await ensureLoaded();
51
+
52
+ // Batch path
53
+ try {
54
+ const queries = passages.map(() => query);
55
+ const inputs = tokenizer!(queries, {
56
+ text_pair: passages,
57
+ padding: true,
58
+ truncation: true,
59
+ return_tensors: 'pt',
60
+ });
61
+ const output = await model!(inputs);
62
+ const logits = output.logits ?? output.last_hidden_state;
63
+ const data = logits.data as Float32Array | number[];
64
+ const results: Array<{ index: number; score: number }> = [];
65
+ for (let i = 0; i < passages.length; i++) {
66
+ const rawLogit = Number(data[i] ?? 0);
67
+ results.push({ index: i, score: sigmoid(rawLogit) });
68
+ }
69
+ results.sort((a, b) => b.score - a.score);
70
+ return results;
71
+ } catch {
72
+ // Per-passage fallback (the original 0.7.13 path)
73
+ const results: Array<{ index: number; score: number }> = [];
74
+ for (let i = 0; i < passages.length; i++) {
75
+ try {
76
+ const inputs = tokenizer!(query, {
77
+ text_pair: passages[i],
78
+ padding: true,
79
+ truncation: true,
80
+ return_tensors: 'pt',
81
+ });
82
+ const output = await model!(inputs);
83
+ const logits = output.logits ?? output.last_hidden_state;
84
+ const rawLogit = logits.data[0] as number;
85
+ results.push({ index: i, score: sigmoid(rawLogit) });
86
+ } catch {
87
+ results.push({ index: i, score: 0 });
88
+ }
89
+ }
90
+ results.sort((a, b) => b.score - a.score);
91
+ return results;
92
+ }
93
+ }
94
+
95
+ // Register the in-process handler with the pool
96
+ registerInProcessHandlers({ rerank: inProcessRerank });
97
+
98
+ // --- Public API ---
99
+
100
+ /** Kept for backwards compat. */
101
+ export async function getReranker(): Promise<any> {
102
+ await ensureLoaded();
103
+ return model;
104
+ }
105
+
106
+ export interface RerankResult {
107
+ index: number;
108
+ score: number; // sigmoid-normalized relevance (0-1)
109
+ }
110
+
111
+ /**
112
+ * Re-rank candidate passages against a query using the cross-encoder.
113
+ * Returns results sorted by relevance score (descending).
114
+ * Dispatches to the worker pool (or in-process fallback).
115
+ */
116
+ export async function rerank(query: string, passages: string[]): Promise<RerankResult[]> {
117
+ if (passages.length === 0) return [];
118
+ return dispatchRerank({ query, passages });
119
+ }
@@ -493,12 +493,21 @@ export async function computeNoveltyWithMatch(
493
493
  : typeof created === 'number' ? created : Date.parse(created);
494
494
  return Number.isFinite(createdMs) && createdMs >= cutoffMs;
495
495
  };
496
+ // Novelty PENALTY: an exact-concept recent match on EITHER channel (including cross-agent workspace
497
+ // results) is a near-duplicate for novelty-scoring purposes.
496
498
  const exactConceptRecent = allBm25.some(r => checkExactConcept(r.engram))
497
499
  || (topCosine ? checkExactConcept(topCosine.engram) : false);
500
+ // REINFORCE redirect: prefer an exact same-concept match as the matched engram so a true duplicate
501
+ // REINFORCES it (R1) instead of creating a new one even when a different-concept engram out-scores it.
502
+ // CRUCIALLY, only consider AGENT-SCOPED candidates — bm25Results and the cosine channel are scoped to
503
+ // this agent, but `wsResults` are OTHER agents' engrams; redirecting to one would make the write
504
+ // pipeline reinforce/supersede a foreign agent's memory (cross-agent contamination).
505
+ const exactMatch = bm25Results.find(r => checkExactConcept(r.engram))?.engram
506
+ ?? (topCosine && checkExactConcept(topCosine.engram) ? topCosine.engram : undefined);
498
507
  const conceptPenalty = exactConceptRecent ? 0.3 : 0;
499
508
 
500
509
  const novelty = Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
501
- return { novelty, matchedEngramId: combinedTop.engramId, matchScore: topScore };
510
+ return { novelty, matchedEngramId: exactMatch?.id ?? combinedTop.engramId, matchScore: topScore };
502
511
  } catch {
503
512
  return { novelty: 0.8, matchedEngramId: null, matchScore: 0 };
504
513
  }
@@ -220,7 +220,11 @@ export async function performWrite(
220
220
  let result: WriteResult | null = null;
221
221
  if (enableReinforcement && noveltyResult.matchedEngramId) {
222
222
  const matched = await store.getEngram(noveltyResult.matchedEngramId);
223
- if (matched) {
223
+ // Agent-scope guard: NEVER reinforce/supersede an engram that belongs to a DIFFERENT agent. A
224
+ // workspace/hive recall can surface another agent's same-concept engram as the match; mutating it
225
+ // (merge content, re-embed, bump confidence, or supersede) would corrupt that agent's memory. A
226
+ // cross-agent match falls through to createNewEngram so this agent gets its own copy.
227
+ if (matched && matched.agentId === input.agentId) {
224
228
  const newConcept = (input.concept ?? '').toLowerCase().trim();
225
229
  const matchedConcept = (matched.concept ?? '').toLowerCase().trim();
226
230
  const sameConcept = newConcept === matchedConcept && newConcept.length > 0;