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.
- package/README.md +29 -0
- package/dist/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +63 -3
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +329 -306
- package/dist/adapters/common.js.map +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +29 -7
- package/dist/api/routes.js.map +1 -1
- package/dist/coordination/routes.d.ts.map +1 -1
- package/dist/coordination/routes.js +174 -170
- package/dist/coordination/routes.js.map +1 -1
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +3 -0
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/entity-extract.d.ts +3 -0
- package/dist/core/entity-extract.d.ts.map +1 -0
- package/dist/core/entity-extract.js +47 -0
- package/dist/core/entity-extract.js.map +1 -0
- package/dist/core/salience.d.ts.map +1 -1
- package/dist/core/salience.js +14 -2
- package/dist/core/salience.js.map +1 -1
- package/dist/core/whoami.d.ts +24 -0
- package/dist/core/whoami.d.ts.map +1 -0
- package/dist/core/whoami.js +66 -0
- package/dist/core/whoami.js.map +1 -0
- package/dist/core/write-pipeline.d.ts +9 -0
- package/dist/core/write-pipeline.d.ts.map +1 -1
- package/dist/core/write-pipeline.js +109 -68
- package/dist/core/write-pipeline.js.map +1 -1
- package/dist/core/write-telemetry.d.ts +33 -0
- package/dist/core/write-telemetry.d.ts.map +1 -0
- package/dist/core/write-telemetry.js +110 -0
- package/dist/core/write-telemetry.js.map +1 -0
- package/dist/engine/activation.d.ts +22 -12
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +133 -17
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation-scheduler.d.ts +1 -1
- package/dist/engine/consolidation-scheduler.js +1 -1
- package/dist/engine/consolidation.d.ts +1 -0
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +18 -0
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/engine/eval.d.ts.map +1 -1
- package/dist/engine/eval.js +5 -1
- package/dist/engine/eval.js.map +1 -1
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +2 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +168 -100
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/index.d.ts +57 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +81 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/storage/pglite-schema.d.ts.map +1 -1
- package/dist/storage/pglite-schema.js +27 -0
- package/dist/storage/pglite-schema.js.map +1 -1
- package/dist/storage/pglite.d.ts +5 -0
- package/dist/storage/pglite.d.ts.map +1 -1
- package/dist/storage/pglite.js +180 -138
- package/dist/storage/pglite.js.map +1 -1
- package/dist/storage/postgres.d.ts +5 -0
- package/dist/storage/postgres.d.ts.map +1 -1
- package/dist/storage/postgres.js +180 -138
- package/dist/storage/postgres.js.map +1 -1
- package/dist/storage/sqlite.d.ts +9 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +394 -326
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +14 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/claude-code.ts +66 -3
- package/src/adapters/common.ts +538 -515
- package/src/api/routes.ts +999 -971
- package/src/coordination/routes.ts +2155 -2150
- package/src/core/embeddings.ts +3 -0
- package/src/core/entity-extract.ts +47 -0
- package/src/core/salience.ts +529 -514
- package/src/core/whoami.ts +92 -0
- package/src/core/write-pipeline.ts +60 -8
- package/src/core/write-telemetry.ts +131 -0
- package/src/engine/activation.ts +1468 -1369
- package/src/engine/consolidation-scheduler.ts +1 -1
- package/src/engine/consolidation.ts +887 -869
- package/src/engine/eval.ts +6 -1
- package/src/index.ts +248 -227
- package/src/mcp.ts +1341 -1270
- package/src/recipes/index.ts +125 -0
- package/src/storage/pglite-schema.ts +27 -0
- package/src/storage/pglite.ts +1420 -1372
- package/src/storage/postgres.ts +1523 -1475
- package/src/storage/sqlite.ts +1936 -1861
- package/src/types/engram.ts +22 -0
package/src/core/salience.ts
CHANGED
|
@@ -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
|
|
34
|
-
* start so "Roberta" or "Hannahs" don't match.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
*/
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
if (
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
//
|
|
294
|
-
score = Math.max(score, 0.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
// and
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
*
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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 floor — the 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
|
+
}
|