agent-working-memory 0.7.17 → 0.8.5

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 (186) hide show
  1. package/README.md +156 -15
  2. package/dist/adapters/common.js +202 -202
  3. package/dist/adapters/http.js +22 -22
  4. package/dist/api/routes.d.ts +1 -1
  5. package/dist/api/routes.d.ts.map +1 -1
  6. package/dist/api/routes.js +258 -79
  7. package/dist/api/routes.js.map +1 -1
  8. package/dist/cli/migrate.d.ts +42 -0
  9. package/dist/cli/migrate.d.ts.map +1 -0
  10. package/dist/cli/migrate.js +268 -0
  11. package/dist/cli/migrate.js.map +1 -0
  12. package/dist/cli.js +147 -103
  13. package/dist/cli.js.map +1 -1
  14. package/dist/coordination/circuit-breaker.d.ts +24 -0
  15. package/dist/coordination/circuit-breaker.d.ts.map +1 -0
  16. package/dist/coordination/circuit-breaker.js +70 -0
  17. package/dist/coordination/circuit-breaker.js.map +1 -0
  18. package/dist/coordination/failure-modes.d.ts +21 -0
  19. package/dist/coordination/failure-modes.d.ts.map +1 -0
  20. package/dist/coordination/failure-modes.js +49 -0
  21. package/dist/coordination/failure-modes.js.map +1 -0
  22. package/dist/coordination/peer-decisions.js +7 -7
  23. package/dist/coordination/routes.d.ts.map +1 -1
  24. package/dist/coordination/routes.js +44 -1
  25. package/dist/coordination/routes.js.map +1 -1
  26. package/dist/coordination/schema.d.ts.map +1 -1
  27. package/dist/coordination/schema.js +18 -0
  28. package/dist/coordination/schema.js.map +1 -1
  29. package/dist/coordination/schemas.d.ts +9 -9
  30. package/dist/coordination/stale.d.ts +9 -1
  31. package/dist/coordination/stale.d.ts.map +1 -1
  32. package/dist/coordination/stale.js +53 -9
  33. package/dist/coordination/stale.js.map +1 -1
  34. package/dist/core/embeddings.d.ts +16 -10
  35. package/dist/core/embeddings.d.ts.map +1 -1
  36. package/dist/core/embeddings.js +54 -38
  37. package/dist/core/embeddings.js.map +1 -1
  38. package/dist/core/ml-worker-entry.d.ts +17 -0
  39. package/dist/core/ml-worker-entry.d.ts.map +1 -0
  40. package/dist/core/ml-worker-entry.js +193 -0
  41. package/dist/core/ml-worker-entry.js.map +1 -0
  42. package/dist/core/ml-worker.d.ts +59 -0
  43. package/dist/core/ml-worker.d.ts.map +1 -0
  44. package/dist/core/ml-worker.js +253 -0
  45. package/dist/core/ml-worker.js.map +1 -0
  46. package/dist/core/query-expander.d.ts +13 -15
  47. package/dist/core/query-expander.d.ts.map +1 -1
  48. package/dist/core/query-expander.js +48 -54
  49. package/dist/core/query-expander.js.map +1 -1
  50. package/dist/core/reranker.d.ts +9 -19
  51. package/dist/core/reranker.d.ts.map +1 -1
  52. package/dist/core/reranker.js +33 -35
  53. package/dist/core/reranker.js.map +1 -1
  54. package/dist/core/salience.d.ts +32 -5
  55. package/dist/core/salience.d.ts.map +1 -1
  56. package/dist/core/salience.js +201 -28
  57. package/dist/core/salience.js.map +1 -1
  58. package/dist/core/write-pipeline.d.ts +10 -2
  59. package/dist/core/write-pipeline.d.ts.map +1 -1
  60. package/dist/core/write-pipeline.js +218 -49
  61. package/dist/core/write-pipeline.js.map +1 -1
  62. package/dist/engine/activation.d.ts +2 -2
  63. package/dist/engine/activation.d.ts.map +1 -1
  64. package/dist/engine/activation.js +238 -115
  65. package/dist/engine/activation.js.map +1 -1
  66. package/dist/engine/confidence.d.ts +62 -0
  67. package/dist/engine/confidence.d.ts.map +1 -0
  68. package/dist/engine/confidence.js +100 -0
  69. package/dist/engine/confidence.js.map +1 -0
  70. package/dist/engine/connections.d.ts +47 -8
  71. package/dist/engine/connections.d.ts.map +1 -1
  72. package/dist/engine/connections.js +83 -22
  73. package/dist/engine/connections.js.map +1 -1
  74. package/dist/engine/consolidation-scheduler.d.ts +46 -10
  75. package/dist/engine/consolidation-scheduler.d.ts.map +1 -1
  76. package/dist/engine/consolidation-scheduler.js +170 -50
  77. package/dist/engine/consolidation-scheduler.js.map +1 -1
  78. package/dist/engine/consolidation.d.ts +16 -2
  79. package/dist/engine/consolidation.d.ts.map +1 -1
  80. package/dist/engine/consolidation.js +143 -44
  81. package/dist/engine/consolidation.js.map +1 -1
  82. package/dist/engine/eval.d.ts +2 -2
  83. package/dist/engine/eval.d.ts.map +1 -1
  84. package/dist/engine/eval.js +9 -9
  85. package/dist/engine/eval.js.map +1 -1
  86. package/dist/engine/eviction.d.ts +4 -4
  87. package/dist/engine/eviction.d.ts.map +1 -1
  88. package/dist/engine/eviction.js +15 -15
  89. package/dist/engine/eviction.js.map +1 -1
  90. package/dist/engine/retraction.d.ts +93 -5
  91. package/dist/engine/retraction.d.ts.map +1 -1
  92. package/dist/engine/retraction.js +238 -19
  93. package/dist/engine/retraction.js.map +1 -1
  94. package/dist/engine/staging.d.ts +1 -1
  95. package/dist/engine/staging.d.ts.map +1 -1
  96. package/dist/engine/staging.js +3 -3
  97. package/dist/engine/staging.js.map +1 -1
  98. package/dist/hooks/sidecar.d.ts +1 -1
  99. package/dist/hooks/sidecar.d.ts.map +1 -1
  100. package/dist/hooks/sidecar.js +4 -4
  101. package/dist/hooks/sidecar.js.map +1 -1
  102. package/dist/index.js +48 -31
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp.js +167 -141
  105. package/dist/mcp.js.map +1 -1
  106. package/dist/storage/factory.d.ts +55 -0
  107. package/dist/storage/factory.d.ts.map +1 -0
  108. package/dist/storage/factory.js +135 -0
  109. package/dist/storage/factory.js.map +1 -0
  110. package/dist/storage/pglite-schema.d.ts +20 -0
  111. package/dist/storage/pglite-schema.d.ts.map +1 -0
  112. package/dist/storage/pglite-schema.js +165 -0
  113. package/dist/storage/pglite-schema.js.map +1 -0
  114. package/dist/storage/pglite.d.ts +187 -0
  115. package/dist/storage/pglite.d.ts.map +1 -0
  116. package/dist/storage/pglite.js +1114 -0
  117. package/dist/storage/pglite.js.map +1 -0
  118. package/dist/storage/sqlite.d.ts +117 -0
  119. package/dist/storage/sqlite.d.ts.map +1 -1
  120. package/dist/storage/sqlite.js +357 -8
  121. package/dist/storage/sqlite.js.map +1 -1
  122. package/dist/storage/store.d.ts +63 -0
  123. package/dist/storage/store.d.ts.map +1 -0
  124. package/dist/storage/store.js +27 -0
  125. package/dist/storage/store.js.map +1 -0
  126. package/dist/types/engram.d.ts +92 -2
  127. package/dist/types/engram.d.ts.map +1 -1
  128. package/dist/types/engram.js.map +1 -1
  129. package/package.json +3 -1
  130. package/src/adapters/claude-code.ts +218 -218
  131. package/src/adapters/codex.ts +252 -252
  132. package/src/adapters/common.ts +411 -411
  133. package/src/adapters/cursor.ts +135 -135
  134. package/src/adapters/http.ts +93 -93
  135. package/src/adapters/index.ts +31 -31
  136. package/src/adapters/types.ts +75 -75
  137. package/src/api/index.ts +3 -3
  138. package/src/api/routes.ts +970 -714
  139. package/src/cli/migrate.ts +307 -0
  140. package/src/cli.ts +755 -719
  141. package/src/coordination/circuit-breaker.ts +83 -0
  142. package/src/coordination/events.ts +90 -90
  143. package/src/coordination/failure-modes.ts +50 -0
  144. package/src/coordination/peer-decisions.ts +105 -105
  145. package/src/coordination/plugin-loader.ts +60 -60
  146. package/src/coordination/plugin.ts +44 -44
  147. package/src/coordination/routes.ts +56 -1
  148. package/src/coordination/schema.ts +13 -0
  149. package/src/coordination/stale.ts +80 -11
  150. package/src/coordination/types.ts +311 -311
  151. package/src/coordination/write-mutex.ts +69 -69
  152. package/src/core/auto-tagger.ts +168 -168
  153. package/src/core/decay.ts +63 -63
  154. package/src/core/embeddings.ts +110 -93
  155. package/src/core/index.ts +5 -5
  156. package/src/core/logger.ts +36 -36
  157. package/src/core/ml-worker-entry.ts +194 -0
  158. package/src/core/ml-worker.ts +281 -0
  159. package/src/core/query-expander.ts +122 -128
  160. package/src/core/reranker.ts +119 -125
  161. package/src/core/salience.ts +200 -33
  162. package/src/core/write-pipeline.ts +521 -343
  163. package/src/engine/activation.ts +235 -109
  164. package/src/engine/confidence.ts +120 -0
  165. package/src/engine/connections.ts +162 -103
  166. package/src/engine/consolidation-scheduler.ts +242 -125
  167. package/src/engine/consolidation.ts +138 -45
  168. package/src/engine/eval.ts +102 -102
  169. package/src/engine/eviction.ts +101 -101
  170. package/src/engine/index.ts +8 -8
  171. package/src/engine/retraction.ts +366 -116
  172. package/src/engine/staging.ts +74 -74
  173. package/src/hooks/sidecar.ts +5 -5
  174. package/src/index.ts +226 -212
  175. package/src/mcp.ts +1145 -1121
  176. package/src/storage/factory.ts +147 -0
  177. package/src/storage/index.ts +3 -3
  178. package/src/storage/pglite-schema.ts +166 -0
  179. package/src/storage/pglite.ts +1363 -0
  180. package/src/storage/sqlite.ts +402 -7
  181. package/src/storage/store.ts +80 -0
  182. package/src/types/agent.ts +67 -67
  183. package/src/types/checkpoint.ts +46 -46
  184. package/src/types/engram.ts +101 -3
  185. package/src/types/eval.ts +100 -100
  186. package/src/types/index.ts +6 -6
@@ -1,125 +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) which is trained on
7
- * MS-MARCO passage ranking. Unlike bi-encoders, cross-encoders see both
8
- * query and passage together via full attention much better at judging
9
- * if a passage actually answers a question.
10
- *
11
- * Uses direct tokenizer + model inference (NOT the text-classification
12
- * pipeline, which doesn't support text_pair and returns identical scores).
13
- *
14
- * Singleton pattern — call getReranker() to get the shared instance.
15
- */
16
-
17
- import {
18
- AutoTokenizer,
19
- AutoModelForSequenceClassification,
20
- type PreTrainedTokenizer,
21
- type PreTrainedModel,
22
- } from '@huggingface/transformers';
23
-
24
- const DEFAULT_MODEL = 'Xenova/ms-marco-MiniLM-L-6-v2';
25
- const MODEL_ID = process.env.AWM_RERANKER_MODEL || DEFAULT_MODEL;
26
- let tokenizer: PreTrainedTokenizer | null = null;
27
- let model: PreTrainedModel | null = null;
28
- let initPromise: Promise<void> | null = null;
29
-
30
- async function ensureLoaded(): Promise<void> {
31
- if (tokenizer && model) return;
32
- if (initPromise) return initPromise;
33
-
34
- initPromise = (async () => {
35
- tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID);
36
- model = await AutoModelForSequenceClassification.from_pretrained(MODEL_ID, {
37
- dtype: 'fp32',
38
- });
39
- console.log(`Re-ranker model loaded: ${MODEL_ID}`);
40
- })();
41
-
42
- return initPromise;
43
- }
44
-
45
- /** Kept for backwards compat — returns the model (unused externally). */
46
- export async function getReranker(): Promise<any> {
47
- await ensureLoaded();
48
- return model;
49
- }
50
-
51
- export interface RerankResult {
52
- index: number;
53
- score: number; // sigmoid-normalized relevance (0-1)
54
- }
55
-
56
- function sigmoid(x: number): number {
57
- return 1 / (1 + Math.exp(-x));
58
- }
59
-
60
- /**
61
- * Re-rank candidate passages against a query using the cross-encoder.
62
- * Returns results sorted by relevance score (descending).
63
- *
64
- * Batch inference (0.7.14+): tokenizes all query-passage pairs in one call
65
- * and runs a single model forward pass. Previously the loop tokenized + ran
66
- * the model once per passage, which serialized 15-30 inference calls.
67
- * Batching is roughly 3-5× faster on transformers.js because the
68
- * tokenizer/model overhead amortizes.
69
- *
70
- * Falls back to per-passage scoring if the batch path errors (e.g. model
71
- * doesn't support batched text_pair).
72
- */
73
- export async function rerank(
74
- query: string,
75
- passages: string[],
76
- ): Promise<RerankResult[]> {
77
- if (passages.length === 0) return [];
78
-
79
- await ensureLoaded();
80
-
81
- // Try batch inference first — much faster for typical pool sizes (15-30)
82
- try {
83
- const queries = passages.map(() => query);
84
- const inputs = tokenizer!(queries, {
85
- text_pair: passages,
86
- padding: true,
87
- truncation: true,
88
- return_tensors: 'pt',
89
- });
90
-
91
- const output = await model!(inputs);
92
- const logits = output.logits ?? output.last_hidden_state;
93
- const data = logits.data as Float32Array | number[];
94
-
95
- // logits.data is [batch_size] when there's a single output dim
96
- const results: RerankResult[] = [];
97
- for (let i = 0; i < passages.length; i++) {
98
- const rawLogit = Number(data[i] ?? 0);
99
- results.push({ index: i, score: sigmoid(rawLogit) });
100
- }
101
- results.sort((a, b) => b.score - a.score);
102
- return results;
103
- } catch {
104
- // Fall back to per-passage loop (the original 0.7.13 path)
105
- const results: RerankResult[] = [];
106
- for (let i = 0; i < passages.length; i++) {
107
- try {
108
- const inputs = tokenizer!(query, {
109
- text_pair: passages[i],
110
- padding: true,
111
- truncation: true,
112
- return_tensors: 'pt',
113
- });
114
- const output = await model!(inputs);
115
- const logits = output.logits ?? output.last_hidden_state;
116
- const rawLogit = logits.data[0] as number;
117
- results.push({ index: i, score: sigmoid(rawLogit) });
118
- } catch {
119
- results.push({ index: i, score: 0 });
120
- }
121
- }
122
- results.sort((a, b) => b.score - a.score);
123
- return results;
124
- }
125
- }
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
+ }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { SalienceFeatures, MemoryClass } from '../types/index.js';
14
- import type { EngramStore } from '../storage/sqlite.js';
14
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
15
15
 
16
16
  export type SalienceEventType = 'decision' | 'friction' | 'surprise' | 'causal' | 'observation' | 'user_feedback';
17
17
 
@@ -76,6 +76,46 @@ export function detectVerifiedFinding(content: string): boolean {
76
76
  return dateCount + idCount >= 2;
77
77
  }
78
78
 
79
+ /**
80
+ * Auto-detect trivial routine operations: file reads, status pings, log-line
81
+ * completions. These have high BM25 novelty (each one has different filenames,
82
+ * timestamps, attempt counts) but represent NO learning value — they're the
83
+ * sort of background chatter a working agent generates by the thousand.
84
+ *
85
+ * Why this exists: the novelty weight (0.45) puts a floor at ~0.45 for every
86
+ * write on a fresh agent, which prevents trivial observations from ever
87
+ * routing to 'discard'. self-test 1.2 ("File read completed successfully for
88
+ * file 0") explicitly asks for trivial → discard. We can't detect triviality
89
+ * from features alone — the caller passes surprise=0, effort=0 but novelty
90
+ * computes to 1.0 — so we need a content shape check.
91
+ *
92
+ * Pattern requires:
93
+ * - A routine verb phrase: "completed", "succeeded", "finished", "returned",
94
+ * "loaded", "saved", "read", "wrote", "synced", "pinged", "checked",
95
+ * "started", "stopped", "rotated", "flushed"
96
+ * - Generic operational noun: file/log/request/response/status/job/connection
97
+ * - Total length under ~150 chars (trivial events are short)
98
+ *
99
+ * Matched memories get a salience CAP at 0.10 (below the 0.2 stagingThreshold,
100
+ * so they route to 'discard'). Caller can still force-store via
101
+ * memory_class=canonical or memory_class=structural.
102
+ */
103
+ const TRIVIAL_VERB_PATTERN = /\b(completed|succeeded|finished|returned|loaded|saved|read|wrote|synced|pinged|checked|started|stopped|rotated|flushed)\b/i;
104
+ const TRIVIAL_NOUN_PATTERN = /\b(file|log|request|response|status|job|connection|task|cron|sync|tick|batch)\b/i;
105
+
106
+ /** Returns true if the content looks like a routine operational ping that adds no learning value. */
107
+ export function detectTrivialOperation(content: string): boolean {
108
+ if (typeof content !== 'string' || content.length === 0) return false;
109
+ const text = content.trim();
110
+ if (text.length > 150) return false;
111
+ if (!TRIVIAL_VERB_PATTERN.test(text)) return false;
112
+ if (!TRIVIAL_NOUN_PATTERN.test(text)) return false;
113
+ // Don't trip on verified findings — they share some verbs (Completed) but
114
+ // have concrete identifiers. detectVerifiedFinding has priority.
115
+ if (detectVerifiedFinding(text)) return false;
116
+ return true;
117
+ }
118
+
79
119
  export interface SalienceInput {
80
120
  content: string;
81
121
  eventType?: SalienceEventType;
@@ -124,6 +164,7 @@ export function evaluateSalience(
124
164
  let resolvedMemoryClass: MemoryClass = input.memoryClass ?? 'working';
125
165
  let autoPromoted = false;
126
166
  let verifiedFindingFloor = false;
167
+ let trivialOperationCap = false;
127
168
  if (detectUserFeedback(input.content)) {
128
169
  resolvedEventType = 'user_feedback';
129
170
  resolvedMemoryClass = 'canonical';
@@ -136,6 +177,10 @@ export function evaluateSalience(
136
177
  resolvedEventType = 'decision';
137
178
  }
138
179
  verifiedFindingFloor = true;
180
+ } else if (detectTrivialOperation(input.content)) {
181
+ // Trivial routine operation — cap salience below stagingThreshold so it
182
+ // routes to 'discard'. Caller can still force-keep via canonical/structural.
183
+ trivialOperationCap = true;
139
184
  }
140
185
 
141
186
  const features: SalienceFeatures = {
@@ -149,6 +194,7 @@ export function evaluateSalience(
149
194
  const reasonCodes: string[] = [];
150
195
  if (autoPromoted) reasonCodes.push('auto:user_feedback');
151
196
  if (verifiedFindingFloor) reasonCodes.push('auto:verified_finding');
197
+ if (trivialOperationCap) reasonCodes.push('auto:trivial_operation');
152
198
 
153
199
  // Novelty: 1.0 = completely new info, 0 = exact duplicate exists
154
200
  // Default to 0.8 (assume mostly novel) when caller doesn't check
@@ -168,18 +214,62 @@ export function evaluateSalience(
168
214
  if (novelty > 0.7) reasonCodes.push('novel_information');
169
215
  if (novelty < 0.3) reasonCodes.push('redundant_information');
170
216
 
171
- // Event type bonus
217
+ // Event type bonus — gated by signal strength. The bonus represents the
218
+ // confidence that an event of this type warrants the type-specific boost.
219
+ // If the caller labels something `friction` but every signal is near zero,
220
+ // they're telling the system the friction was minor — the typeBonus is
221
+ // attenuated to reflect that. Without this gate, any labeled friction
222
+ // event clears the active threshold on novelty alone (self-test 1.4).
172
223
  let typeBonus = 0;
224
+ let typeReason = '';
173
225
  switch (features.eventType) {
174
- case 'decision': typeBonus = 0.15; reasonCodes.push('event:decision'); break;
175
- case 'friction': typeBonus = 0.2; reasonCodes.push('event:friction'); break;
176
- case 'surprise': typeBonus = 0.25; reasonCodes.push('event:surprise'); break;
177
- case 'causal': typeBonus = 0.2; reasonCodes.push('event:causal'); break;
178
- case 'user_feedback': typeBonus = 0.3; reasonCodes.push('event:user_feedback'); break;
226
+ case 'decision': typeBonus = 0.15; typeReason = 'event:decision'; break;
227
+ case 'friction': typeBonus = 0.2; typeReason = 'event:friction'; break;
228
+ case 'surprise': typeBonus = 0.25; typeReason = 'event:surprise'; break;
229
+ case 'causal': typeBonus = 0.2; typeReason = 'event:causal'; break;
230
+ case 'user_feedback': typeBonus = 0.3; typeReason = 'event:user_feedback'; break;
179
231
  case 'observation': break;
180
232
  }
233
+ // Signal-weakness gate. The novelty score alone (~0.45 for fresh content)
234
+ // would clear the active threshold (0.4), so any labelled event with no
235
+ // backing numerical signals lands as 'active' regardless of the label's
236
+ // semantics. That's wrong for friction/causal: those types describe events
237
+ // that *happened to the agent* and benefit from explicit intensity signals.
238
+ // surprise / user_feedback / decision-with-decisionMade are exempt: their
239
+ // label alone is the signal.
240
+ const exemptFromAttenuation =
241
+ features.eventType === 'user_feedback'
242
+ || features.eventType === 'surprise'
243
+ || (features.eventType === 'decision' && features.decisionMade);
244
+ const signalStrength = features.surprise + features.causalDepth + features.resolutionEffort + (features.decisionMade ? 0.5 : 0);
245
+ const signalsAreWeak = !exemptFromAttenuation && signalStrength < 0.5;
246
+
247
+ if (typeBonus > 0 && signalsAreWeak) {
248
+ typeBonus *= 0.25; // weak-signal event: keep a hint, not the full bonus
249
+ typeReason += ':attenuated';
250
+ }
251
+ if (typeReason) reasonCodes.push(typeReason);
252
+
253
+ // Cap the novelty contribution when signals are weak AND the eventType
254
+ // claims a typeBonus (friction/causal). Without this, novelty=1.0 alone
255
+ // (0.45 noveltyScore) clears the active threshold (0.4), making any
256
+ // weakly-signalled non-exempt write 'active' regardless of intent.
257
+ // Plain observations (typeBonus=0) are NOT capped — a novel observation
258
+ // is still default-active even without explicit signals.
259
+ let cappedNoveltyScore = noveltyScore;
260
+ if (signalsAreWeak && typeBonus > 0) {
261
+ cappedNoveltyScore = Math.min(noveltyScore, 0.30);
262
+ if (cappedNoveltyScore < noveltyScore) reasonCodes.push('novelty:capped');
263
+ }
264
+
265
+ let score = Math.min(surpriseScore + decisionScore + causalScore + effortScore + cappedNoveltyScore + typeBonus, 1.0);
181
266
 
182
- let score = Math.min(surpriseScore + decisionScore + causalScore + effortScore + noveltyScore + typeBonus, 1.0);
267
+ // Apply triviality cap BEFORE memoryClass floor the floor still wins for
268
+ // canonical/structural writes (covered below). Trivial cap forces routine
269
+ // operational chatter below stagingThreshold.
270
+ if (trivialOperationCap) {
271
+ score = Math.min(score, 0.1);
272
+ }
183
273
 
184
274
  // Memory class overrides
185
275
  const memoryClass = resolvedMemoryClass;
@@ -188,6 +278,15 @@ export function evaluateSalience(
188
278
  // Canonical memories: salience floor of 0.7, never go to staging
189
279
  score = Math.max(score, 0.7);
190
280
  reasonCodes.push('class:canonical');
281
+ } else if (memoryClass === 'structural') {
282
+ // Structural memories (0.8): system-written event-log records — chapter
283
+ // analyses, promise advancements, materialized-view feeds. Floor 0.7 like
284
+ // canonical (always preserved by construction) but distinct reasonCode
285
+ // so retrieval paths can filter them out of cognitive `/activate` by
286
+ // default. Caller controls embedding + temporal-edge skipping in the
287
+ // write pipeline.
288
+ score = Math.max(score, 0.7);
289
+ reasonCodes.push('class:structural');
191
290
  } else if (memoryClass === 'ephemeral') {
192
291
  reasonCodes.push('class:ephemeral');
193
292
  } else if (verifiedFindingFloor) {
@@ -196,8 +295,9 @@ export function evaluateSalience(
196
295
  }
197
296
 
198
297
  let disposition: 'active' | 'staging' | 'discard';
199
- if (memoryClass === 'canonical') {
200
- // Canonical always goes active they represent current truth
298
+ if (memoryClass === 'canonical' || memoryClass === 'structural') {
299
+ // Canonical = source-of-truth; structural = system-written record.
300
+ // Both always go active — they represent intentional permanent state.
201
301
  disposition = 'active';
202
302
  reasonCodes.push('disposition:active');
203
303
  } else if (score >= activeThreshold) {
@@ -224,14 +324,14 @@ export function evaluateSalience(
224
324
  *
225
325
  * The check is cheap (~1ms) because BM25 is synchronous SQLite FTS5.
226
326
  */
227
- export function computeNovelty(store: EngramStore, agentId: string, concept: string, content: string): number {
327
+ export async function computeNovelty(store: EngramStore, agentId: string, concept: string, content: string): Promise<number> {
228
328
  try {
229
329
  // Search using concept + first 100 chars of content (enough to detect duplicates, fast)
230
330
  const contentStr = typeof content === 'string' ? content : '';
231
331
  const conceptStr = typeof concept === 'string' ? concept : '';
232
332
  const searchText = `${conceptStr} ${contentStr.slice(0, 100)}`;
233
333
 
234
- const results = store.searchBM25WithRank(agentId, searchText, 5);
334
+ const results = await store.searchBM25WithRank(agentId, searchText, 5);
235
335
  if (results.length === 0) return 1.0; // Nothing similar — fully novel
236
336
 
237
337
  // searchBM25WithRank normalizes scores to 0..1 via |rank|/(1+|rank|).
@@ -283,43 +383,108 @@ export interface NoveltyResult {
283
383
  }
284
384
 
285
385
  /**
286
- * Compute novelty score AND return the best matching engram (for reinforcement-on-duplicate).
287
- * Uses BM25 (synchronous, fast) to find the closest existing memory.
386
+ * Compute novelty score AND return the best matching engram (for
387
+ * reinforcement-on-duplicate).
388
+ *
389
+ * **v0.8.5+: dual-signal novelty (BM25 ∨ cosine, max).**
390
+ *
391
+ * When `embedding` is provided, computes both:
392
+ * - BM25 lexical match (existing path) — catches verbatim duplicates,
393
+ * identifier-driven matches, recall-output reingestion attempts.
394
+ * - Cosine semantic match (new) — catches paraphrased duplicates,
395
+ * vocabulary-drifted restatements of the same fact, cross-role
396
+ * rephrasings (user question → assistant answer about same fact).
397
+ *
398
+ * Takes `max(bm25Score, cosineSimilarity)` and returns the engram from
399
+ * whichever signal won. Why both?
400
+ * - BM25 is *backend-dependent* — Postgres ts_rank_cd and SQLite FTS5
401
+ * BM25 are different algorithms producing different rankings for
402
+ * short-text matches (verified empirically 2026-05-26). Cosine is
403
+ * *backend-agnostic* — same embedding model produces identical
404
+ * similarity scores on either backend.
405
+ * - Cosine alone misses the exact-text cases BM25 catches (recall
406
+ * output leakage, identifier matching). BM25 alone misses the
407
+ * semantic cases cosine catches (paraphrase, vocabulary drift —
408
+ * the LoCoMo pattern of "user said X" across conversations).
409
+ *
410
+ * When `embedding` is null/omitted, falls back to BM25-only (preserves
411
+ * backward compat with v0.8.4 and earlier callers).
412
+ *
288
413
  * Optionally checks workspace-scoped memories too (cross-agent dedup).
289
414
  */
290
- export function computeNoveltyWithMatch(
415
+ export async function computeNoveltyWithMatch(
291
416
  store: EngramStore, agentId: string, concept: string, content: string,
292
- workspace?: string | null
293
- ): NoveltyResult {
417
+ workspace?: string | null,
418
+ embedding?: number[] | null,
419
+ ): Promise<NoveltyResult> {
294
420
  try {
295
421
  const contentStr = typeof content === 'string' ? content : '';
296
422
  const conceptStr = typeof concept === 'string' ? concept : '';
297
423
  const searchText = `${conceptStr} ${contentStr.slice(0, 100)}`;
298
424
 
299
- // Agent-scoped search (limit:3 to avoid single shallow match suppressing novelty)
300
- const results = store.searchBM25WithRank(agentId, searchText, 3);
301
-
302
- // Workspace search — only if the store supports it (v0.5.4+)
303
- let wsResults: { engram: { id: string }; bm25Score: number }[] = [];
425
+ // BM25 channel (existing) agent-scoped + optional workspace.
426
+ const bm25Results = await store.searchBM25WithRank(agentId, searchText, 3);
427
+ let wsResults: { engram: { id: string; concept?: string; createdAt?: Date | string | number }; bm25Score: number }[] = [];
304
428
  if (workspace && typeof (store as any).searchBM25WithRankWorkspace === 'function') {
305
- wsResults = (store as any).searchBM25WithRankWorkspace(agentId, searchText, 3, workspace);
429
+ wsResults = await (store as any).searchBM25WithRankWorkspace(agentId, searchText, 3, workspace);
430
+ }
431
+ const allBm25 = [...bm25Results, ...wsResults];
432
+ allBm25.sort((a, b) => b.bm25Score - a.bm25Score);
433
+ const topBm25 = allBm25[0]
434
+ ? { engramId: allBm25[0].engram.id, score: allBm25[0].bm25Score, engram: allBm25[0].engram }
435
+ : null;
436
+
437
+ // Cosine channel (v0.8.5) — only when caller supplies an embedding.
438
+ // The embed cost is paid once in the write-pipeline pre-novelty and
439
+ // re-used for the engram's stored vector, so we don't double-embed.
440
+ let topCosine: { engramId: string; score: number; engram: any } | null = null;
441
+ if (embedding && embedding.length > 0) {
442
+ try {
443
+ const hits = await store.searchByVector(agentId, embedding, 3);
444
+ if (hits.length > 0) {
445
+ const h = hits[0];
446
+ // pgvector distance ≈ 1 - cosineSimilarity for unit-norm BGE vectors.
447
+ // SQLite searchByVector returns distance = 1 - sim in the same form.
448
+ // Clamp into [0, 1] to be safe with floating-point drift.
449
+ const sim = Math.max(0, Math.min(1, 1 - h.distance));
450
+ topCosine = { engramId: h.engram.id, score: sim, engram: h.engram };
451
+ }
452
+ } catch { /* cosine channel optional — fall back to BM25 alone */ }
306
453
  }
307
454
 
308
- const allResults = [...results, ...wsResults];
309
- if (allResults.length === 0) return { novelty: 1.0, matchedEngramId: null, matchScore: 0 };
455
+ // Combine: take the higher-confidence signal. If both fired and they
456
+ // identify the same engram, scores reinforce each other (we still take
457
+ // max, but the matched engram is the same). If they identify *different*
458
+ // engrams (one semantic match, one lexical), the higher score wins —
459
+ // typically the more discriminating signal for that particular content.
460
+ //
461
+ // Tested MIN and cosine-primary on 2026-05-26 to address PGlite token
462
+ // bloat; both dropped accuracy 7–20pp across backends. The bloat is a
463
+ // recall-output problem (returning full merged engram content when only
464
+ // a slice matches the query), not a novelty problem. Keeping MAX
465
+ // preserves the 100% / 97.5% accuracy we had on PGlite / SQLite.
466
+ let combinedTop: { engramId: string; score: number; engram: any } | null;
467
+ if (topCosine && topBm25) {
468
+ combinedTop = topCosine.score >= topBm25.score ? topCosine : topBm25;
469
+ } else if (topCosine) {
470
+ combinedTop = topCosine;
471
+ } else if (topBm25) {
472
+ combinedTop = topBm25;
473
+ } else {
474
+ return { novelty: 1.0, matchedEngramId: null, matchScore: 0 };
475
+ }
310
476
 
311
- allResults.sort((a, b) => b.bm25Score - a.bm25Score);
312
- const top = allResults[0];
313
- const topScore = top.bm25Score;
477
+ const topScore = combinedTop.score;
314
478
 
315
479
  // Quadratic dampening — see computeNovelty for curve rationale
316
480
  const baseNovelty = 1.0 - topScore * topScore;
317
481
 
318
- // Recent-only concept penalty (30d window)
482
+ // Recent-only concept penalty (30d window). Check across all matches we
483
+ // saw on EITHER channel — exact-concept repeat counts as a near-duplicate
484
+ // regardless of which signal noticed it.
319
485
  const conceptLower = conceptStr.toLowerCase().trim();
320
486
  const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
321
- const exactConceptRecent = allResults.some(r => {
322
- const eng = r.engram as { concept?: string; createdAt?: Date | string | number };
487
+ const checkExactConcept = (eng: { concept?: string; createdAt?: Date | string | number }): boolean => {
323
488
  if (eng?.concept?.toLowerCase().trim() !== conceptLower) return false;
324
489
  const created = eng?.createdAt;
325
490
  if (!created) return true;
@@ -327,11 +492,13 @@ export function computeNoveltyWithMatch(
327
492
  ? created.getTime()
328
493
  : typeof created === 'number' ? created : Date.parse(created);
329
494
  return Number.isFinite(createdMs) && createdMs >= cutoffMs;
330
- });
495
+ };
496
+ const exactConceptRecent = allBm25.some(r => checkExactConcept(r.engram))
497
+ || (topCosine ? checkExactConcept(topCosine.engram) : false);
331
498
  const conceptPenalty = exactConceptRecent ? 0.3 : 0;
332
499
 
333
500
  const novelty = Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
334
- return { novelty, matchedEngramId: top.engram.id, matchScore: topScore };
501
+ return { novelty, matchedEngramId: combinedTop.engramId, matchScore: topScore };
335
502
  } catch {
336
503
  return { novelty: 0.8, matchedEngramId: null, matchScore: 0 };
337
504
  }