agent-working-memory 0.11.0 → 0.12.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 (99) hide show
  1. package/README.md +29 -0
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +63 -3
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adapters/common.d.ts.map +1 -1
  6. package/dist/adapters/common.js +329 -306
  7. package/dist/adapters/common.js.map +1 -1
  8. package/dist/api/routes.d.ts.map +1 -1
  9. package/dist/api/routes.js +29 -7
  10. package/dist/api/routes.js.map +1 -1
  11. package/dist/coordination/routes.d.ts.map +1 -1
  12. package/dist/coordination/routes.js +174 -170
  13. package/dist/coordination/routes.js.map +1 -1
  14. package/dist/core/embeddings.d.ts.map +1 -1
  15. package/dist/core/embeddings.js +3 -0
  16. package/dist/core/embeddings.js.map +1 -1
  17. package/dist/core/entity-extract.d.ts +3 -0
  18. package/dist/core/entity-extract.d.ts.map +1 -0
  19. package/dist/core/entity-extract.js +47 -0
  20. package/dist/core/entity-extract.js.map +1 -0
  21. package/dist/core/salience.d.ts.map +1 -1
  22. package/dist/core/salience.js +14 -2
  23. package/dist/core/salience.js.map +1 -1
  24. package/dist/core/whoami.d.ts +24 -0
  25. package/dist/core/whoami.d.ts.map +1 -0
  26. package/dist/core/whoami.js +66 -0
  27. package/dist/core/whoami.js.map +1 -0
  28. package/dist/core/write-pipeline.d.ts +9 -0
  29. package/dist/core/write-pipeline.d.ts.map +1 -1
  30. package/dist/core/write-pipeline.js +109 -68
  31. package/dist/core/write-pipeline.js.map +1 -1
  32. package/dist/core/write-telemetry.d.ts +33 -0
  33. package/dist/core/write-telemetry.d.ts.map +1 -0
  34. package/dist/core/write-telemetry.js +110 -0
  35. package/dist/core/write-telemetry.js.map +1 -0
  36. package/dist/engine/activation.d.ts +22 -12
  37. package/dist/engine/activation.d.ts.map +1 -1
  38. package/dist/engine/activation.js +133 -17
  39. package/dist/engine/activation.js.map +1 -1
  40. package/dist/engine/consolidation-scheduler.d.ts +1 -1
  41. package/dist/engine/consolidation-scheduler.js +1 -1
  42. package/dist/engine/consolidation.d.ts +1 -0
  43. package/dist/engine/consolidation.d.ts.map +1 -1
  44. package/dist/engine/consolidation.js +18 -0
  45. package/dist/engine/consolidation.js.map +1 -1
  46. package/dist/engine/eval.d.ts.map +1 -1
  47. package/dist/engine/eval.js +5 -1
  48. package/dist/engine/eval.js.map +1 -1
  49. package/dist/index.js +20 -2
  50. package/dist/index.js.map +1 -1
  51. package/dist/mcp.d.ts +2 -1
  52. package/dist/mcp.d.ts.map +1 -1
  53. package/dist/mcp.js +168 -100
  54. package/dist/mcp.js.map +1 -1
  55. package/dist/recipes/index.d.ts +57 -0
  56. package/dist/recipes/index.d.ts.map +1 -0
  57. package/dist/recipes/index.js +81 -0
  58. package/dist/recipes/index.js.map +1 -0
  59. package/dist/storage/pglite-schema.d.ts.map +1 -1
  60. package/dist/storage/pglite-schema.js +27 -0
  61. package/dist/storage/pglite-schema.js.map +1 -1
  62. package/dist/storage/pglite.d.ts +5 -0
  63. package/dist/storage/pglite.d.ts.map +1 -1
  64. package/dist/storage/pglite.js +180 -138
  65. package/dist/storage/pglite.js.map +1 -1
  66. package/dist/storage/postgres.d.ts +5 -0
  67. package/dist/storage/postgres.d.ts.map +1 -1
  68. package/dist/storage/postgres.js +180 -138
  69. package/dist/storage/postgres.js.map +1 -1
  70. package/dist/storage/sqlite.d.ts +9 -0
  71. package/dist/storage/sqlite.d.ts.map +1 -1
  72. package/dist/storage/sqlite.js +394 -326
  73. package/dist/storage/sqlite.js.map +1 -1
  74. package/dist/types/engram.d.ts +14 -0
  75. package/dist/types/engram.d.ts.map +1 -1
  76. package/dist/types/engram.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/adapters/claude-code.ts +66 -3
  79. package/src/adapters/common.ts +538 -515
  80. package/src/api/routes.ts +999 -971
  81. package/src/coordination/routes.ts +2155 -2150
  82. package/src/core/embeddings.ts +3 -0
  83. package/src/core/entity-extract.ts +47 -0
  84. package/src/core/salience.ts +529 -514
  85. package/src/core/whoami.ts +92 -0
  86. package/src/core/write-pipeline.ts +60 -8
  87. package/src/core/write-telemetry.ts +131 -0
  88. package/src/engine/activation.ts +1468 -1369
  89. package/src/engine/consolidation-scheduler.ts +1 -1
  90. package/src/engine/consolidation.ts +887 -869
  91. package/src/engine/eval.ts +6 -1
  92. package/src/index.ts +248 -227
  93. package/src/mcp.ts +1341 -1270
  94. package/src/recipes/index.ts +125 -0
  95. package/src/storage/pglite-schema.ts +27 -0
  96. package/src/storage/pglite.ts +1420 -1372
  97. package/src/storage/postgres.ts +1523 -1475
  98. package/src/storage/sqlite.ts +1936 -1861
  99. package/src/types/engram.ts +22 -0
@@ -1,514 +1,529 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Salience Filter — decides what's worth remembering.
5
- *
6
- * Codex feedback incorporated:
7
- * - Persists raw feature scores for auditability
8
- * - Returns reason codes for explainability
9
- * - Thresholds are tunable per agent
10
- * - Deterministic heuristics first, LLM augmentation optional
11
- */
12
-
13
- import type { SalienceFeatures, MemoryClass } from '../types/index.js';
14
- import type { IEngramStore as EngramStore } from '../storage/store.js';
15
-
16
- export type SalienceEventType = 'decision' | 'friction' | 'surprise' | 'causal' | 'observation' | 'user_feedback';
17
-
18
- /**
19
- * Auto-detect user-feedback memories: content that begins with a known user's
20
- * name + a feedback verb. These memories represent direct human decisions and
21
- * must never be discarded. Examples:
22
- * "Robert verbatim: 'LMS programs-first like CRM'"
23
- * "Katherine said the CEC cycle resets on promotion"
24
- * "Nancy directed Tier 1 has no grace period"
25
- *
26
- * Why this exists: the BM25 novelty check collapses near-duplicates regardless
27
- * of whether the content is a NEW decision or a repeat observation. User
28
- * feedback often shares terminology with prior memories ("LMS", "ECP",
29
- * "officials") and gets discarded at salience 0.14 (verified in activity log
30
- * 2026-05-06T19:08:47). Detecting "Robert said X" → canonical class bypasses
31
- * the salience filter entirely.
32
- *
33
- * Tune the name list as new staff join. Pattern requires word boundary at
34
- * start so "Roberta" or "Hannahs" don't match.
35
- */
36
- const USER_FEEDBACK_PATTERN = /^(Robert|Katherine|Catherine|Nancy|Brandy|Brandi|Hannah|Marilyn|Kaylee|Pete|Abby|Tom|Wendy|Sita|Nick|Rob|Joan|Jennifer|Cindy|Jason|Alex|Molly)\s+(said|verbatim|feedback|asked|wants|prefers|requested|requested|directed|decided|confirmed|clarified|chose|specified|explained)\b/i;
37
-
38
- /** Returns true if the content looks like direct user feedback that should auto-promote to canonical. */
39
- export function detectUserFeedback(content: string): boolean {
40
- if (typeof content !== 'string' || content.length === 0) return false;
41
- return USER_FEEDBACK_PATTERN.test(content.trim());
42
- }
43
-
44
- /**
45
- * Auto-detect verified operational findings: batch records, completion summaries,
46
- * incident reconciliations. These have low BM25 novelty (terminology repeats across
47
- * runs "USEF results submission", "Freshdesk triage batch") but the SPECIFIC
48
- * event/ticket IDs, dates, and counts make each one uniquely valuable for future
49
- * recall.
50
- *
51
- * Why this exists: the salience filter discarded a 6-event USEF batch summary at
52
- * 0.14 (verified in activity log 2026-05-07T18:44:14) because the topic words
53
- * collided with the long-running USEF history. The procedural memory beside it
54
- * scored 0.70 — same topic, different content shape. The novelty signal alone
55
- * can't distinguish a useful operational record from a duplicate observation.
56
- *
57
- * Pattern requires BOTH:
58
- * 1. An action-verb header (Submitted/Finalized/Completed/Reconciled/Triaged/Posted/Resolved/Stamped)
59
- * 2. At least 2 concrete identifiers — absolute dates (YYYY-MM-DD) OR numeric IDs
60
- * with context (event \d+, ticket #\d+, USEF \d+, USEA \d+).
61
- *
62
- * Matched memories get a salience floor of 0.45 (active, but below canonical
63
- * 0.7) preserves the record without claiming source-of-truth status.
64
- */
65
- const OPERATIONAL_VERB_PATTERN = /\b(Submitted|Finalized|Completed|Reconciled|Triaged|Posted|Resolved|Stamped|Pushed|Deployed|Migrated|Imported|Exported|Backfilled)\b/i;
66
- const ISO_DATE_PATTERN = /\b\d{4}-\d{2}-\d{2}\b/g;
67
- const CONCRETE_ID_PATTERN = /\b(?:events?|tickets?|comps?|comp_id|usef|usea|classes|class|cases?|orders?|payments?|member_id|horse_id|user_id|orgs?|#)\s*[#:]?\s*\d{3,}/gi;
68
-
69
- /** Returns true if the content looks like a verified operational/batch record that should auto-bump salience. */
70
- export function detectVerifiedFinding(content: string): boolean {
71
- if (typeof content !== 'string' || content.length === 0) return false;
72
- const text = content.trim();
73
- if (!OPERATIONAL_VERB_PATTERN.test(text)) return false;
74
- const dateCount = (text.match(ISO_DATE_PATTERN) || []).length;
75
- const idCount = (text.match(CONCRETE_ID_PATTERN) || []).length;
76
- return dateCount + idCount >= 2;
77
- }
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
-
119
- export interface SalienceInput {
120
- content: string;
121
- eventType?: SalienceEventType;
122
- surprise?: number;
123
- decisionMade?: boolean;
124
- causalDepth?: number;
125
- resolutionEffort?: number;
126
- /** 0 = exact duplicate exists, 1 = completely novel. Computed by caller via BM25 similarity check. */
127
- novelty?: number;
128
- /** Memory class canonical memories get salience floor of 0.7 and never stage. */
129
- memoryClass?: MemoryClass;
130
- }
131
-
132
- export interface SalienceResult {
133
- score: number;
134
- disposition: 'active' | 'staging' | 'discard';
135
- features: SalienceFeatures;
136
- reasonCodes: string[];
137
- }
138
-
139
- /**
140
- * Weights for the salience scoring formula.
141
- * Novelty is the strongest signal new information should always be stored.
142
- * Duplicates get filtered aggressively.
143
- */
144
- const WEIGHTS = {
145
- surprise: 0.15,
146
- decision: 0.15,
147
- causalDepth: 0.15,
148
- resolutionEffort: 0.1,
149
- novelty: 0.45,
150
- };
151
-
152
- /**
153
- * Rule-based salience scorer with full audit trail.
154
- */
155
- export function evaluateSalience(
156
- input: SalienceInput,
157
- activeThreshold: number = 0.4,
158
- stagingThreshold: number = 0.2
159
- ): SalienceResult {
160
- // Auto-detect user feedback before scoring. If content matches the pattern,
161
- // force eventType='user_feedback' and memoryClass='canonical'. This bypasses
162
- // the BM25 novelty floor that was discarding pivotal user decisions at 0.14.
163
- let resolvedEventType: SalienceEventType = input.eventType ?? 'observation';
164
- let resolvedMemoryClass: MemoryClass = input.memoryClass ?? 'working';
165
- let autoPromoted = false;
166
- let verifiedFindingFloor = false;
167
- let trivialOperationCap = false;
168
- if (detectUserFeedback(input.content)) {
169
- resolvedEventType = 'user_feedback';
170
- resolvedMemoryClass = 'canonical';
171
- autoPromoted = true;
172
- } else if (detectVerifiedFinding(input.content)) {
173
- // Operational record: bump eventType to 'decision' (typeBonus +0.15) and
174
- // remember to apply a 0.45 salience floor below. Do NOT promote to canonical
175
- // these records are verified, not source-of-truth.
176
- if (resolvedEventType === 'observation') {
177
- resolvedEventType = 'decision';
178
- }
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;
184
- }
185
-
186
- const features: SalienceFeatures = {
187
- surprise: input.surprise ?? 0,
188
- decisionMade: input.decisionMade ?? false,
189
- causalDepth: input.causalDepth ?? 0,
190
- resolutionEffort: input.resolutionEffort ?? 0,
191
- eventType: resolvedEventType,
192
- };
193
-
194
- const reasonCodes: string[] = [];
195
- if (autoPromoted) reasonCodes.push('auto:user_feedback');
196
- if (verifiedFindingFloor) reasonCodes.push('auto:verified_finding');
197
- if (trivialOperationCap) reasonCodes.push('auto:trivial_operation');
198
-
199
- // Novelty: 1.0 = completely new info, 0 = exact duplicate exists
200
- // Default to 0.8 (assume mostly novel) when caller doesn't check
201
- const novelty = input.novelty ?? 0.8;
202
-
203
- // Score components
204
- const surpriseScore = WEIGHTS.surprise * features.surprise;
205
- const decisionScore = WEIGHTS.decision * (features.decisionMade ? 1.0 : 0);
206
- const causalScore = WEIGHTS.causalDepth * features.causalDepth;
207
- const effortScore = WEIGHTS.resolutionEffort * features.resolutionEffort;
208
- const noveltyScore = WEIGHTS.novelty * novelty;
209
-
210
- if (features.surprise > 0.5) reasonCodes.push('high_surprise');
211
- if (features.decisionMade) reasonCodes.push('decision_point');
212
- if (features.causalDepth > 0.5) reasonCodes.push('causal_insight');
213
- if (features.resolutionEffort > 0.5) reasonCodes.push('high_effort_resolution');
214
- if (novelty > 0.7) reasonCodes.push('novel_information');
215
- if (novelty < 0.3) reasonCodes.push('redundant_information');
216
-
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).
223
- let typeBonus = 0;
224
- let typeReason = '';
225
- switch (features.eventType) {
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;
231
- case 'observation': break;
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);
266
-
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
- }
273
-
274
- // Memory class overrides
275
- const memoryClass = resolvedMemoryClass;
276
-
277
- if (memoryClass === 'canonical') {
278
- // Canonical memories: salience floor of 0.7, never go to staging
279
- score = Math.max(score, 0.7);
280
- reasonCodes.push('class:canonical');
281
- } else if (memoryClass === 'structural') {
282
- // Structural memories (0.8): system-written event-log recordschapter
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');
290
- } else if (memoryClass === 'ephemeral') {
291
- reasonCodes.push('class:ephemeral');
292
- } else if (verifiedFindingFloor) {
293
- // Verified operational record: 0.45 floor keeps it active without canonical promotion
294
- score = Math.max(score, 0.45);
295
- }
296
-
297
- let disposition: 'active' | 'staging' | 'discard';
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.
301
- disposition = 'active';
302
- reasonCodes.push('disposition:active');
303
- } else if (score >= activeThreshold) {
304
- disposition = 'active';
305
- reasonCodes.push('disposition:active');
306
- } else if (score >= stagingThreshold) {
307
- disposition = 'staging';
308
- reasonCodes.push('disposition:staging');
309
- } else {
310
- disposition = 'discard';
311
- reasonCodes.push('disposition:discard');
312
- }
313
-
314
- return { score, disposition, features, reasonCodes };
315
- }
316
-
317
- /**
318
- * Compute novelty score by checking how similar the content is to existing memories.
319
- * Uses BM25 (synchronous, fast) to find the closest existing memory.
320
- *
321
- * Returns 0..1 where:
322
- * 1.0 = nothing similar exists (completely novel)
323
- * 0.0 = near-exact duplicate exists
324
- *
325
- * The check is cheap (~1ms) because BM25 is synchronous SQLite FTS5.
326
- */
327
- export async function computeNovelty(store: EngramStore, agentId: string, concept: string, content: string): Promise<number> {
328
- try {
329
- // Search using concept + first 100 chars of content (enough to detect duplicates, fast)
330
- const contentStr = typeof content === 'string' ? content : '';
331
- const conceptStr = typeof concept === 'string' ? concept : '';
332
- const searchText = `${conceptStr} ${contentStr.slice(0, 100)}`;
333
-
334
- const results = await store.searchBM25WithRank(agentId, searchText, 5);
335
- if (results.length === 0) return 1.0; // Nothing similar — fully novel
336
-
337
- // searchBM25WithRank normalizes scores to 0..1 via |rank|/(1+|rank|).
338
- // Higher score = stronger match = less novel.
339
- const topScore = results[0].bm25Score;
340
-
341
- // Quadratic dampening (1 - topScore²) so mid-range matches don't kill novelty.
342
- // Old curve was linear (1 - topScore) which floored at 0.1 for almost any match
343
- // in a populated DB, killing the salience signal.
344
- // Curve comparison (topScore novelty):
345
- // 0.30 0.91 (different topic strong novelty)
346
- // 0.60 0.64 (loosely related partial credit)
347
- // 0.80 0.36 (related but distinct — meaningful signal)
348
- // 0.95 → 0.10 (near-dupe — still suppress)
349
- const baseNovelty = 1.0 - topScore * topScore;
350
-
351
- // Concept penalty scoped to recent matches only — re-using the same concept
352
- // string for a NEW topic months later shouldn't be punished. Penalty was 0.4
353
- // (too harsh); now 0.3 and only applies if any matched result is < 30 days old.
354
- const conceptLower = conceptStr.toLowerCase().trim();
355
- const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
356
- const exactConceptRecent = results.some(r => {
357
- if (r.engram?.concept?.toLowerCase().trim() !== conceptLower) return false;
358
- const created = r.engram?.createdAt as Date | string | number | undefined;
359
- if (!created) return true; // No timestamp treat as recent (conservative)
360
- const createdMs = created instanceof Date
361
- ? created.getTime()
362
- : typeof created === 'number' ? created : Date.parse(created);
363
- return Number.isFinite(createdMs) && createdMs >= cutoffMs;
364
- });
365
- const conceptPenalty = exactConceptRecent ? 0.3 : 0;
366
-
367
- // Floor lowered to 0.05 (was 0.10) so true duplicates can score near-zero
368
- // and clearly stay below stagingThreshold (0.2). Ceiling unchanged.
369
- return Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
370
- } catch {
371
- // If BM25 search fails (e.g., FTS not ready), assume novel
372
- return 0.8;
373
- }
374
- }
375
-
376
- /**
377
- * Result from novelty computation with match info for reinforcement.
378
- */
379
- export interface NoveltyResult {
380
- novelty: number;
381
- matchedEngramId: string | null;
382
- matchScore: number;
383
- }
384
-
385
- /**
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
- *
413
- * Optionally checks workspace-scoped memories too (cross-agent dedup).
414
- */
415
- export async function computeNoveltyWithMatch(
416
- store: EngramStore, agentId: string, concept: string, content: string,
417
- workspace?: string | null,
418
- embedding?: number[] | null,
419
- ): Promise<NoveltyResult> {
420
- try {
421
- const contentStr = typeof content === 'string' ? content : '';
422
- const conceptStr = typeof concept === 'string' ? concept : '';
423
- const searchText = `${conceptStr} ${contentStr.slice(0, 100)}`;
424
-
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 }[] = [];
428
- if (workspace && typeof (store as any).searchBM25WithRankWorkspace === 'function') {
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 optionalfall back to BM25 alone */ }
453
- }
454
-
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
- }
476
-
477
- const topScore = combinedTop.score;
478
-
479
- // Quadratic dampening see computeNovelty for curve rationale
480
- const baseNovelty = 1.0 - topScore * topScore;
481
-
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.
485
- const conceptLower = conceptStr.toLowerCase().trim();
486
- const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
487
- const checkExactConcept = (eng: { concept?: string; createdAt?: Date | string | number }): boolean => {
488
- if (eng?.concept?.toLowerCase().trim() !== conceptLower) return false;
489
- const created = eng?.createdAt;
490
- if (!created) return true;
491
- const createdMs = created instanceof Date
492
- ? created.getTime()
493
- : typeof created === 'number' ? created : Date.parse(created);
494
- return Number.isFinite(createdMs) && createdMs >= cutoffMs;
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.
498
- const exactConceptRecent = allBm25.some(r => checkExactConcept(r.engram))
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);
507
- const conceptPenalty = exactConceptRecent ? 0.3 : 0;
508
-
509
- const novelty = Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
510
- return { novelty, matchedEngramId: exactMatch?.id ?? combinedTop.engramId, matchScore: topScore };
511
- } catch {
512
- return { novelty: 0.8, matchedEngramId: null, matchScore: 0 };
513
- }
514
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Salience Filter — decides what's worth remembering.
5
+ *
6
+ * Codex feedback incorporated:
7
+ * - Persists raw feature scores for auditability
8
+ * - Returns reason codes for explainability
9
+ * - Thresholds are tunable per agent
10
+ * - Deterministic heuristics first, LLM augmentation optional
11
+ */
12
+
13
+ import type { SalienceFeatures, MemoryClass } from '../types/index.js';
14
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
15
+
16
+ export type SalienceEventType = 'decision' | 'friction' | 'surprise' | 'causal' | 'observation' | 'user_feedback';
17
+
18
+ /**
19
+ * Auto-detect user-feedback memories: content that begins with a known user's
20
+ * name + a feedback verb. These memories represent direct human decisions and
21
+ * must never be discarded. Examples:
22
+ * "Robert verbatim: 'LMS programs-first like CRM'"
23
+ * "Katherine said the CEC cycle resets on promotion"
24
+ * "Nancy directed Tier 1 has no grace period"
25
+ *
26
+ * Why this exists: the BM25 novelty check collapses near-duplicates regardless
27
+ * of whether the content is a NEW decision or a repeat observation. User
28
+ * feedback often shares terminology with prior memories ("LMS", "ECP",
29
+ * "officials") and gets discarded at salience 0.14 (verified in activity log
30
+ * 2026-05-06T19:08:47). Detecting "Robert said X" → canonical class bypasses
31
+ * the salience filter entirely.
32
+ *
33
+ * Tune via AWM_FEEDBACK_NAMES (comma list). Pattern requires word boundary at
34
+ * start so "Roberta" or "Hannahs" don't match.
35
+ */
36
+ // D4 (2026-07-30): names/verbs are configurable so the library is not coupled
37
+ // to one organization's staff list. AWM_FEEDBACK_NAMES / AWM_FEEDBACK_VERBS
38
+ // take comma-separated lists. The built-in default preserves existing installs;
39
+ // a future 1.0 flips the default to empty with the installer seeding values.
40
+ const DEFAULT_FEEDBACK_NAMES = 'Robert,Katherine,Catherine,Nancy,Brandy,Brandi,Hannah,Marilyn,Kaylee,Pete,Abby,Tom,Wendy,Sita,Nick,Rob,Joan,Jennifer,Cindy,Jason,Alex,Molly';
41
+ const DEFAULT_FEEDBACK_VERBS = 'said,verbatim,feedback,asked,wants,prefers,requested,directed,decided,confirmed,clarified,chose,specified,explained';
42
+ function csvToAlternation(env: string | undefined, fallback: string): string {
43
+ return (env ?? fallback).split(',').map(x => x.trim()).filter(Boolean)
44
+ .map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
45
+ .join('|');
46
+ }
47
+ const USER_FEEDBACK_PATTERN = new RegExp(
48
+ '^(' + csvToAlternation(process.env.AWM_FEEDBACK_NAMES, DEFAULT_FEEDBACK_NAMES) + ')\\s+(' +
49
+ csvToAlternation(process.env.AWM_FEEDBACK_VERBS, DEFAULT_FEEDBACK_VERBS) + ')\\b',
50
+ 'i',
51
+ );
52
+
53
+ /** Returns true if the content looks like direct user feedback that should auto-promote to canonical. */
54
+ export function detectUserFeedback(content: string): boolean {
55
+ if (typeof content !== 'string' || content.length === 0) return false;
56
+ return USER_FEEDBACK_PATTERN.test(content.trim());
57
+ }
58
+
59
+ /**
60
+ * Auto-detect verified operational findings: batch records, completion summaries,
61
+ * incident reconciliations. These have low BM25 novelty (terminology repeats across
62
+ * runs "USEF results submission", "Freshdesk triage batch") but the SPECIFIC
63
+ * event/ticket IDs, dates, and counts make each one uniquely valuable for future
64
+ * recall.
65
+ *
66
+ * Why this exists: the salience filter discarded a 6-event USEF batch summary at
67
+ * 0.14 (verified in activity log 2026-05-07T18:44:14) because the topic words
68
+ * collided with the long-running USEF history. The procedural memory beside it
69
+ * scored 0.70 same topic, different content shape. The novelty signal alone
70
+ * can't distinguish a useful operational record from a duplicate observation.
71
+ *
72
+ * Pattern requires BOTH:
73
+ * 1. An action-verb header (Submitted/Finalized/Completed/Reconciled/Triaged/Posted/Resolved/Stamped)
74
+ * 2. At least 2 concrete identifiers — absolute dates (YYYY-MM-DD) OR numeric IDs
75
+ * with context (event \d+, ticket #\d+, USEF \d+, USEA \d+).
76
+ *
77
+ * Matched memories get a salience floor of 0.45 (active, but below canonical
78
+ * 0.7) — preserves the record without claiming source-of-truth status.
79
+ */
80
+ const OPERATIONAL_VERB_PATTERN = /\b(Submitted|Finalized|Completed|Reconciled|Triaged|Posted|Resolved|Stamped|Pushed|Deployed|Migrated|Imported|Exported|Backfilled)\b/i;
81
+ const ISO_DATE_PATTERN = /\b\d{4}-\d{2}-\d{2}\b/g;
82
+ const CONCRETE_ID_PATTERN = /\b(?:events?|tickets?|comps?|comp_id|usef|usea|classes|class|cases?|orders?|payments?|member_id|horse_id|user_id|orgs?|#)\s*[#:]?\s*\d{3,}/gi;
83
+
84
+ /** Returns true if the content looks like a verified operational/batch record that should auto-bump salience. */
85
+ export function detectVerifiedFinding(content: string): boolean {
86
+ if (typeof content !== 'string' || content.length === 0) return false;
87
+ const text = content.trim();
88
+ if (!OPERATIONAL_VERB_PATTERN.test(text)) return false;
89
+ const dateCount = (text.match(ISO_DATE_PATTERN) || []).length;
90
+ const idCount = (text.match(CONCRETE_ID_PATTERN) || []).length;
91
+ return dateCount + idCount >= 2;
92
+ }
93
+
94
+ /**
95
+ * Auto-detect trivial routine operations: file reads, status pings, log-line
96
+ * completions. These have high BM25 novelty (each one has different filenames,
97
+ * timestamps, attempt counts) but represent NO learning value — they're the
98
+ * sort of background chatter a working agent generates by the thousand.
99
+ *
100
+ * Why this exists: the novelty weight (0.45) puts a floor at ~0.45 for every
101
+ * write on a fresh agent, which prevents trivial observations from ever
102
+ * routing to 'discard'. self-test 1.2 ("File read completed successfully for
103
+ * file 0") explicitly asks for trivial → discard. We can't detect triviality
104
+ * from features alone — the caller passes surprise=0, effort=0 but novelty
105
+ * computes to 1.0 — so we need a content shape check.
106
+ *
107
+ * Pattern requires:
108
+ * - A routine verb phrase: "completed", "succeeded", "finished", "returned",
109
+ * "loaded", "saved", "read", "wrote", "synced", "pinged", "checked",
110
+ * "started", "stopped", "rotated", "flushed"
111
+ * - Generic operational noun: file/log/request/response/status/job/connection
112
+ * - Total length under ~150 chars (trivial events are short)
113
+ *
114
+ * Matched memories get a salience CAP at 0.10 (below the 0.2 stagingThreshold,
115
+ * so they route to 'discard'). Caller can still force-store via
116
+ * memory_class=canonical or memory_class=structural.
117
+ */
118
+ const TRIVIAL_VERB_PATTERN = /\b(completed|succeeded|finished|returned|loaded|saved|read|wrote|synced|pinged|checked|started|stopped|rotated|flushed)\b/i;
119
+ const TRIVIAL_NOUN_PATTERN = /\b(file|log|request|response|status|job|connection|task|cron|sync|tick|batch)\b/i;
120
+
121
+ /** Returns true if the content looks like a routine operational ping that adds no learning value. */
122
+ export function detectTrivialOperation(content: string): boolean {
123
+ if (typeof content !== 'string' || content.length === 0) return false;
124
+ const text = content.trim();
125
+ if (text.length > 150) return false;
126
+ if (!TRIVIAL_VERB_PATTERN.test(text)) return false;
127
+ if (!TRIVIAL_NOUN_PATTERN.test(text)) return false;
128
+ // Don't trip on verified findings they share some verbs (Completed) but
129
+ // have concrete identifiers. detectVerifiedFinding has priority.
130
+ if (detectVerifiedFinding(text)) return false;
131
+ return true;
132
+ }
133
+
134
+ export interface SalienceInput {
135
+ content: string;
136
+ eventType?: SalienceEventType;
137
+ surprise?: number;
138
+ decisionMade?: boolean;
139
+ causalDepth?: number;
140
+ resolutionEffort?: number;
141
+ /** 0 = exact duplicate exists, 1 = completely novel. Computed by caller via BM25 similarity check. */
142
+ novelty?: number;
143
+ /** Memory class — canonical memories get salience floor of 0.7 and never stage. */
144
+ memoryClass?: MemoryClass;
145
+ }
146
+
147
+ export interface SalienceResult {
148
+ score: number;
149
+ disposition: 'active' | 'staging' | 'discard';
150
+ features: SalienceFeatures;
151
+ reasonCodes: string[];
152
+ }
153
+
154
+ /**
155
+ * Weights for the salience scoring formula.
156
+ * Novelty is the strongest signal — new information should always be stored.
157
+ * Duplicates get filtered aggressively.
158
+ */
159
+ const WEIGHTS = {
160
+ surprise: 0.15,
161
+ decision: 0.15,
162
+ causalDepth: 0.15,
163
+ resolutionEffort: 0.1,
164
+ novelty: 0.45,
165
+ };
166
+
167
+ /**
168
+ * Rule-based salience scorer with full audit trail.
169
+ */
170
+ export function evaluateSalience(
171
+ input: SalienceInput,
172
+ activeThreshold: number = 0.4,
173
+ stagingThreshold: number = 0.2
174
+ ): SalienceResult {
175
+ // Auto-detect user feedback before scoring. If content matches the pattern,
176
+ // force eventType='user_feedback' and memoryClass='canonical'. This bypasses
177
+ // the BM25 novelty floor that was discarding pivotal user decisions at 0.14.
178
+ let resolvedEventType: SalienceEventType = input.eventType ?? 'observation';
179
+ let resolvedMemoryClass: MemoryClass = input.memoryClass ?? 'working';
180
+ let autoPromoted = false;
181
+ let verifiedFindingFloor = false;
182
+ let trivialOperationCap = false;
183
+ if (detectUserFeedback(input.content)) {
184
+ resolvedEventType = 'user_feedback';
185
+ resolvedMemoryClass = 'canonical';
186
+ autoPromoted = true;
187
+ } else if (detectVerifiedFinding(input.content)) {
188
+ // Operational record: bump eventType to 'decision' (typeBonus +0.15) and
189
+ // remember to apply a 0.45 salience floor below. Do NOT promote to canonical
190
+ // these records are verified, not source-of-truth.
191
+ if (resolvedEventType === 'observation') {
192
+ resolvedEventType = 'decision';
193
+ }
194
+ verifiedFindingFloor = true;
195
+ } else if (detectTrivialOperation(input.content)) {
196
+ // Trivial routine operation — cap salience below stagingThreshold so it
197
+ // routes to 'discard'. Caller can still force-keep via canonical/structural.
198
+ trivialOperationCap = true;
199
+ }
200
+
201
+ const features: SalienceFeatures = {
202
+ surprise: input.surprise ?? 0,
203
+ decisionMade: input.decisionMade ?? false,
204
+ causalDepth: input.causalDepth ?? 0,
205
+ resolutionEffort: input.resolutionEffort ?? 0,
206
+ eventType: resolvedEventType,
207
+ };
208
+
209
+ const reasonCodes: string[] = [];
210
+ if (autoPromoted) reasonCodes.push('auto:user_feedback');
211
+ if (verifiedFindingFloor) reasonCodes.push('auto:verified_finding');
212
+ if (trivialOperationCap) reasonCodes.push('auto:trivial_operation');
213
+
214
+ // Novelty: 1.0 = completely new info, 0 = exact duplicate exists
215
+ // Default to 0.8 (assume mostly novel) when caller doesn't check
216
+ const novelty = input.novelty ?? 0.8;
217
+
218
+ // Score components
219
+ const surpriseScore = WEIGHTS.surprise * features.surprise;
220
+ const decisionScore = WEIGHTS.decision * (features.decisionMade ? 1.0 : 0);
221
+ const causalScore = WEIGHTS.causalDepth * features.causalDepth;
222
+ const effortScore = WEIGHTS.resolutionEffort * features.resolutionEffort;
223
+ const noveltyScore = WEIGHTS.novelty * novelty;
224
+
225
+ if (features.surprise > 0.5) reasonCodes.push('high_surprise');
226
+ if (features.decisionMade) reasonCodes.push('decision_point');
227
+ if (features.causalDepth > 0.5) reasonCodes.push('causal_insight');
228
+ if (features.resolutionEffort > 0.5) reasonCodes.push('high_effort_resolution');
229
+ if (novelty > 0.7) reasonCodes.push('novel_information');
230
+ if (novelty < 0.3) reasonCodes.push('redundant_information');
231
+
232
+ // Event type bonus — gated by signal strength. The bonus represents the
233
+ // confidence that an event of this type warrants the type-specific boost.
234
+ // If the caller labels something `friction` but every signal is near zero,
235
+ // they're telling the system the friction was minor the typeBonus is
236
+ // attenuated to reflect that. Without this gate, any labeled friction
237
+ // event clears the active threshold on novelty alone (self-test 1.4).
238
+ let typeBonus = 0;
239
+ let typeReason = '';
240
+ switch (features.eventType) {
241
+ case 'decision': typeBonus = 0.15; typeReason = 'event:decision'; break;
242
+ case 'friction': typeBonus = 0.2; typeReason = 'event:friction'; break;
243
+ case 'surprise': typeBonus = 0.25; typeReason = 'event:surprise'; break;
244
+ case 'causal': typeBonus = 0.2; typeReason = 'event:causal'; break;
245
+ case 'user_feedback': typeBonus = 0.3; typeReason = 'event:user_feedback'; break;
246
+ case 'observation': break;
247
+ }
248
+ // Signal-weakness gate. The novelty score alone (~0.45 for fresh content)
249
+ // would clear the active threshold (0.4), so any labelled event with no
250
+ // backing numerical signals lands as 'active' regardless of the label's
251
+ // semantics. That's wrong for friction/causal: those types describe events
252
+ // that *happened to the agent* and benefit from explicit intensity signals.
253
+ // surprise / user_feedback / decision-with-decisionMade are exempt: their
254
+ // label alone is the signal.
255
+ const exemptFromAttenuation =
256
+ features.eventType === 'user_feedback'
257
+ || features.eventType === 'surprise'
258
+ || (features.eventType === 'decision' && features.decisionMade);
259
+ const signalStrength = features.surprise + features.causalDepth + features.resolutionEffort + (features.decisionMade ? 0.5 : 0);
260
+ const signalsAreWeak = !exemptFromAttenuation && signalStrength < 0.5;
261
+
262
+ if (typeBonus > 0 && signalsAreWeak) {
263
+ typeBonus *= 0.25; // weak-signal event: keep a hint, not the full bonus
264
+ typeReason += ':attenuated';
265
+ }
266
+ if (typeReason) reasonCodes.push(typeReason);
267
+
268
+ // Cap the novelty contribution when signals are weak AND the eventType
269
+ // claims a typeBonus (friction/causal). Without this, novelty=1.0 alone
270
+ // (0.45 noveltyScore) clears the active threshold (0.4), making any
271
+ // weakly-signalled non-exempt write 'active' regardless of intent.
272
+ // Plain observations (typeBonus=0) are NOT capped — a novel observation
273
+ // is still default-active even without explicit signals.
274
+ let cappedNoveltyScore = noveltyScore;
275
+ if (signalsAreWeak && typeBonus > 0) {
276
+ cappedNoveltyScore = Math.min(noveltyScore, 0.30);
277
+ if (cappedNoveltyScore < noveltyScore) reasonCodes.push('novelty:capped');
278
+ }
279
+
280
+ let score = Math.min(surpriseScore + decisionScore + causalScore + effortScore + cappedNoveltyScore + typeBonus, 1.0);
281
+
282
+ // Apply triviality cap BEFORE memoryClass floorthe floor still wins for
283
+ // canonical/structural writes (covered below). Trivial cap forces routine
284
+ // operational chatter below stagingThreshold.
285
+ if (trivialOperationCap) {
286
+ score = Math.min(score, 0.1);
287
+ }
288
+
289
+ // Memory class overrides
290
+ const memoryClass = resolvedMemoryClass;
291
+
292
+ if (memoryClass === 'canonical') {
293
+ // Canonical memories: salience floor of 0.7, never go to staging
294
+ score = Math.max(score, 0.7);
295
+ reasonCodes.push('class:canonical');
296
+ } else if (memoryClass === 'structural') {
297
+ // Structural memories (0.8): system-written event-log records chapter
298
+ // analyses, promise advancements, materialized-view feeds. Floor 0.7 like
299
+ // canonical (always preserved by construction) but distinct reasonCode
300
+ // so retrieval paths can filter them out of cognitive `/activate` by
301
+ // default. Caller controls embedding + temporal-edge skipping in the
302
+ // write pipeline.
303
+ score = Math.max(score, 0.7);
304
+ reasonCodes.push('class:structural');
305
+ } else if (memoryClass === 'ephemeral') {
306
+ reasonCodes.push('class:ephemeral');
307
+ } else if (verifiedFindingFloor) {
308
+ // Verified operational record: 0.45 floor — keeps it active without canonical promotion
309
+ score = Math.max(score, 0.45);
310
+ }
311
+
312
+ let disposition: 'active' | 'staging' | 'discard';
313
+ if (memoryClass === 'canonical' || memoryClass === 'structural') {
314
+ // Canonical = source-of-truth; structural = system-written record.
315
+ // Both always go active — they represent intentional permanent state.
316
+ disposition = 'active';
317
+ reasonCodes.push('disposition:active');
318
+ } else if (score >= activeThreshold) {
319
+ disposition = 'active';
320
+ reasonCodes.push('disposition:active');
321
+ } else if (score >= stagingThreshold) {
322
+ disposition = 'staging';
323
+ reasonCodes.push('disposition:staging');
324
+ } else {
325
+ disposition = 'discard';
326
+ reasonCodes.push('disposition:discard');
327
+ }
328
+
329
+ return { score, disposition, features, reasonCodes };
330
+ }
331
+
332
+ /**
333
+ * Compute novelty score by checking how similar the content is to existing memories.
334
+ * Uses BM25 (synchronous, fast) to find the closest existing memory.
335
+ *
336
+ * Returns 0..1 where:
337
+ * 1.0 = nothing similar exists (completely novel)
338
+ * 0.0 = near-exact duplicate exists
339
+ *
340
+ * The check is cheap (~1ms) because BM25 is synchronous SQLite FTS5.
341
+ */
342
+ export async function computeNovelty(store: EngramStore, agentId: string, concept: string, content: string): Promise<number> {
343
+ try {
344
+ // Search using concept + first 100 chars of content (enough to detect duplicates, fast)
345
+ const contentStr = typeof content === 'string' ? content : '';
346
+ const conceptStr = typeof concept === 'string' ? concept : '';
347
+ const searchText = `${conceptStr} ${contentStr.slice(0, 100)}`;
348
+
349
+ const results = await store.searchBM25WithRank(agentId, searchText, 5);
350
+ if (results.length === 0) return 1.0; // Nothing similar — fully novel
351
+
352
+ // searchBM25WithRank normalizes scores to 0..1 via |rank|/(1+|rank|).
353
+ // Higher score = stronger match = less novel.
354
+ const topScore = results[0].bm25Score;
355
+
356
+ // Quadratic dampening (1 - topScore²) so mid-range matches don't kill novelty.
357
+ // Old curve was linear (1 - topScore) which floored at 0.1 for almost any match
358
+ // in a populated DB, killing the salience signal.
359
+ // Curve comparison (topScore novelty):
360
+ // 0.30 0.91 (different topic — strong novelty)
361
+ // 0.60 → 0.64 (loosely related — partial credit)
362
+ // 0.80 0.36 (related but distinct meaningful signal)
363
+ // 0.95 → 0.10 (near-dupe still suppress)
364
+ const baseNovelty = 1.0 - topScore * topScore;
365
+
366
+ // Concept penalty scoped to recent matches only — re-using the same concept
367
+ // string for a NEW topic months later shouldn't be punished. Penalty was 0.4
368
+ // (too harsh); now 0.3 and only applies if any matched result is < 30 days old.
369
+ const conceptLower = conceptStr.toLowerCase().trim();
370
+ const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
371
+ const exactConceptRecent = results.some(r => {
372
+ if (r.engram?.concept?.toLowerCase().trim() !== conceptLower) return false;
373
+ const created = r.engram?.createdAt as Date | string | number | undefined;
374
+ if (!created) return true; // No timestamp — treat as recent (conservative)
375
+ const createdMs = created instanceof Date
376
+ ? created.getTime()
377
+ : typeof created === 'number' ? created : Date.parse(created);
378
+ return Number.isFinite(createdMs) && createdMs >= cutoffMs;
379
+ });
380
+ const conceptPenalty = exactConceptRecent ? 0.3 : 0;
381
+
382
+ // Floor lowered to 0.05 (was 0.10) so true duplicates can score near-zero
383
+ // and clearly stay below stagingThreshold (0.2). Ceiling unchanged.
384
+ return Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
385
+ } catch {
386
+ // If BM25 search fails (e.g., FTS not ready), assume novel
387
+ return 0.8;
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Result from novelty computation with match info for reinforcement.
393
+ */
394
+ export interface NoveltyResult {
395
+ novelty: number;
396
+ matchedEngramId: string | null;
397
+ matchScore: number;
398
+ }
399
+
400
+ /**
401
+ * Compute novelty score AND return the best matching engram (for
402
+ * reinforcement-on-duplicate).
403
+ *
404
+ * **v0.8.5+: dual-signal novelty (BM25 ∨ cosine, max).**
405
+ *
406
+ * When `embedding` is provided, computes both:
407
+ * - BM25 lexical match (existing path) catches verbatim duplicates,
408
+ * identifier-driven matches, recall-output reingestion attempts.
409
+ * - Cosine semantic match (new) — catches paraphrased duplicates,
410
+ * vocabulary-drifted restatements of the same fact, cross-role
411
+ * rephrasings (user question assistant answer about same fact).
412
+ *
413
+ * Takes `max(bm25Score, cosineSimilarity)` and returns the engram from
414
+ * whichever signal won. Why both?
415
+ * - BM25 is *backend-dependent* — Postgres ts_rank_cd and SQLite FTS5
416
+ * BM25 are different algorithms producing different rankings for
417
+ * short-text matches (verified empirically 2026-05-26). Cosine is
418
+ * *backend-agnostic* — same embedding model produces identical
419
+ * similarity scores on either backend.
420
+ * - Cosine alone misses the exact-text cases BM25 catches (recall
421
+ * output leakage, identifier matching). BM25 alone misses the
422
+ * semantic cases cosine catches (paraphrase, vocabulary drift
423
+ * the LoCoMo pattern of "user said X" across conversations).
424
+ *
425
+ * When `embedding` is null/omitted, falls back to BM25-only (preserves
426
+ * backward compat with v0.8.4 and earlier callers).
427
+ *
428
+ * Optionally checks workspace-scoped memories too (cross-agent dedup).
429
+ */
430
+ export async function computeNoveltyWithMatch(
431
+ store: EngramStore, agentId: string, concept: string, content: string,
432
+ workspace?: string | null,
433
+ embedding?: number[] | null,
434
+ ): Promise<NoveltyResult> {
435
+ try {
436
+ const contentStr = typeof content === 'string' ? content : '';
437
+ const conceptStr = typeof concept === 'string' ? concept : '';
438
+ const searchText = `${conceptStr} ${contentStr.slice(0, 100)}`;
439
+
440
+ // BM25 channel (existing) agent-scoped + optional workspace.
441
+ const bm25Results = await store.searchBM25WithRank(agentId, searchText, 3);
442
+ let wsResults: { engram: { id: string; concept?: string; createdAt?: Date | string | number }; bm25Score: number }[] = [];
443
+ if (workspace && typeof (store as any).searchBM25WithRankWorkspace === 'function') {
444
+ wsResults = await (store as any).searchBM25WithRankWorkspace(agentId, searchText, 3, workspace);
445
+ }
446
+ const allBm25 = [...bm25Results, ...wsResults];
447
+ allBm25.sort((a, b) => b.bm25Score - a.bm25Score);
448
+ const topBm25 = allBm25[0]
449
+ ? { engramId: allBm25[0].engram.id, score: allBm25[0].bm25Score, engram: allBm25[0].engram }
450
+ : null;
451
+
452
+ // Cosine channel (v0.8.5)only when caller supplies an embedding.
453
+ // The embed cost is paid once in the write-pipeline pre-novelty and
454
+ // re-used for the engram's stored vector, so we don't double-embed.
455
+ let topCosine: { engramId: string; score: number; engram: any } | null = null;
456
+ if (embedding && embedding.length > 0) {
457
+ try {
458
+ const hits = await store.searchByVector(agentId, embedding, 3);
459
+ if (hits.length > 0) {
460
+ const h = hits[0];
461
+ // pgvector distance 1 - cosineSimilarity for unit-norm BGE vectors.
462
+ // SQLite searchByVector returns distance = 1 - sim in the same form.
463
+ // Clamp into [0, 1] to be safe with floating-point drift.
464
+ const sim = Math.max(0, Math.min(1, 1 - h.distance));
465
+ topCosine = { engramId: h.engram.id, score: sim, engram: h.engram };
466
+ }
467
+ } catch { /* cosine channel optional — fall back to BM25 alone */ }
468
+ }
469
+
470
+ // Combine: take the higher-confidence signal. If both fired and they
471
+ // identify the same engram, scores reinforce each other (we still take
472
+ // max, but the matched engram is the same). If they identify *different*
473
+ // engrams (one semantic match, one lexical), the higher score wins —
474
+ // typically the more discriminating signal for that particular content.
475
+ //
476
+ // Tested MIN and cosine-primary on 2026-05-26 to address PGlite token
477
+ // bloat; both dropped accuracy 7–20pp across backends. The bloat is a
478
+ // recall-output problem (returning full merged engram content when only
479
+ // a slice matches the query), not a novelty problem. Keeping MAX
480
+ // preserves the 100% / 97.5% accuracy we had on PGlite / SQLite.
481
+ let combinedTop: { engramId: string; score: number; engram: any } | null;
482
+ if (topCosine && topBm25) {
483
+ combinedTop = topCosine.score >= topBm25.score ? topCosine : topBm25;
484
+ } else if (topCosine) {
485
+ combinedTop = topCosine;
486
+ } else if (topBm25) {
487
+ combinedTop = topBm25;
488
+ } else {
489
+ return { novelty: 1.0, matchedEngramId: null, matchScore: 0 };
490
+ }
491
+
492
+ const topScore = combinedTop.score;
493
+
494
+ // Quadratic dampening see computeNovelty for curve rationale
495
+ const baseNovelty = 1.0 - topScore * topScore;
496
+
497
+ // Recent-only concept penalty (30d window). Check across all matches we
498
+ // saw on EITHER channel — exact-concept repeat counts as a near-duplicate
499
+ // regardless of which signal noticed it.
500
+ const conceptLower = conceptStr.toLowerCase().trim();
501
+ const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
502
+ const checkExactConcept = (eng: { concept?: string; createdAt?: Date | string | number }): boolean => {
503
+ if (eng?.concept?.toLowerCase().trim() !== conceptLower) return false;
504
+ const created = eng?.createdAt;
505
+ if (!created) return true;
506
+ const createdMs = created instanceof Date
507
+ ? created.getTime()
508
+ : typeof created === 'number' ? created : Date.parse(created);
509
+ return Number.isFinite(createdMs) && createdMs >= cutoffMs;
510
+ };
511
+ // Novelty PENALTY: an exact-concept recent match on EITHER channel (including cross-agent workspace
512
+ // results) is a near-duplicate for novelty-scoring purposes.
513
+ const exactConceptRecent = allBm25.some(r => checkExactConcept(r.engram))
514
+ || (topCosine ? checkExactConcept(topCosine.engram) : false);
515
+ // REINFORCE redirect: prefer an exact same-concept match as the matched engram so a true duplicate
516
+ // REINFORCES it (R1) instead of creating a new one even when a different-concept engram out-scores it.
517
+ // CRUCIALLY, only consider AGENT-SCOPED candidates — bm25Results and the cosine channel are scoped to
518
+ // this agent, but `wsResults` are OTHER agents' engrams; redirecting to one would make the write
519
+ // pipeline reinforce/supersede a foreign agent's memory (cross-agent contamination).
520
+ const exactMatch = bm25Results.find(r => checkExactConcept(r.engram))?.engram
521
+ ?? (topCosine && checkExactConcept(topCosine.engram) ? topCosine.engram : undefined);
522
+ const conceptPenalty = exactConceptRecent ? 0.3 : 0;
523
+
524
+ const novelty = Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
525
+ return { novelty, matchedEngramId: exactMatch?.id ?? combinedTop.engramId, matchScore: topScore };
526
+ } catch {
527
+ return { novelty: 0.8, matchedEngramId: null, matchScore: 0 };
528
+ }
529
+ }