agent-working-memory 0.7.17 → 0.8.6
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 +156 -15
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +289 -202
- package/dist/adapters/common.js.map +1 -1
- package/dist/adapters/http.js +22 -22
- package/dist/api/routes.d.ts +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +258 -79
- package/dist/api/routes.js.map +1 -1
- package/dist/cli/migrate.d.ts +42 -0
- package/dist/cli/migrate.d.ts.map +1 -0
- package/dist/cli/migrate.js +268 -0
- package/dist/cli/migrate.js.map +1 -0
- package/dist/cli.js +147 -103
- package/dist/cli.js.map +1 -1
- package/dist/coordination/circuit-breaker.d.ts +24 -0
- package/dist/coordination/circuit-breaker.d.ts.map +1 -0
- package/dist/coordination/circuit-breaker.js +70 -0
- package/dist/coordination/circuit-breaker.js.map +1 -0
- package/dist/coordination/failure-modes.d.ts +21 -0
- package/dist/coordination/failure-modes.d.ts.map +1 -0
- package/dist/coordination/failure-modes.js +49 -0
- package/dist/coordination/failure-modes.js.map +1 -0
- package/dist/coordination/peer-decisions.js +7 -7
- package/dist/coordination/routes.d.ts.map +1 -1
- package/dist/coordination/routes.js +44 -1
- package/dist/coordination/routes.js.map +1 -1
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +18 -0
- package/dist/coordination/schema.js.map +1 -1
- package/dist/coordination/schemas.d.ts +9 -9
- package/dist/coordination/stale.d.ts +9 -1
- package/dist/coordination/stale.d.ts.map +1 -1
- package/dist/coordination/stale.js +53 -9
- package/dist/coordination/stale.js.map +1 -1
- package/dist/core/embeddings.d.ts +16 -10
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +54 -38
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/ml-worker-entry.d.ts +17 -0
- package/dist/core/ml-worker-entry.d.ts.map +1 -0
- package/dist/core/ml-worker-entry.js +193 -0
- package/dist/core/ml-worker-entry.js.map +1 -0
- package/dist/core/ml-worker.d.ts +59 -0
- package/dist/core/ml-worker.d.ts.map +1 -0
- package/dist/core/ml-worker.js +253 -0
- package/dist/core/ml-worker.js.map +1 -0
- package/dist/core/query-expander.d.ts +13 -15
- package/dist/core/query-expander.d.ts.map +1 -1
- package/dist/core/query-expander.js +48 -54
- package/dist/core/query-expander.js.map +1 -1
- package/dist/core/reranker.d.ts +9 -19
- package/dist/core/reranker.d.ts.map +1 -1
- package/dist/core/reranker.js +33 -35
- package/dist/core/reranker.js.map +1 -1
- package/dist/core/salience.d.ts +32 -5
- package/dist/core/salience.d.ts.map +1 -1
- package/dist/core/salience.js +201 -28
- package/dist/core/salience.js.map +1 -1
- package/dist/core/write-pipeline.d.ts +10 -2
- package/dist/core/write-pipeline.d.ts.map +1 -1
- package/dist/core/write-pipeline.js +218 -49
- package/dist/core/write-pipeline.js.map +1 -1
- package/dist/engine/activation.d.ts +2 -2
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +238 -115
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/confidence.d.ts +62 -0
- package/dist/engine/confidence.d.ts.map +1 -0
- package/dist/engine/confidence.js +100 -0
- package/dist/engine/confidence.js.map +1 -0
- package/dist/engine/connections.d.ts +47 -8
- package/dist/engine/connections.d.ts.map +1 -1
- package/dist/engine/connections.js +83 -22
- package/dist/engine/connections.js.map +1 -1
- package/dist/engine/consolidation-scheduler.d.ts +46 -10
- package/dist/engine/consolidation-scheduler.d.ts.map +1 -1
- package/dist/engine/consolidation-scheduler.js +170 -50
- package/dist/engine/consolidation-scheduler.js.map +1 -1
- package/dist/engine/consolidation.d.ts +16 -2
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +143 -44
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/engine/eval.d.ts +2 -2
- package/dist/engine/eval.d.ts.map +1 -1
- package/dist/engine/eval.js +9 -9
- package/dist/engine/eval.js.map +1 -1
- package/dist/engine/eviction.d.ts +4 -4
- package/dist/engine/eviction.d.ts.map +1 -1
- package/dist/engine/eviction.js +15 -15
- package/dist/engine/eviction.js.map +1 -1
- package/dist/engine/retraction.d.ts +93 -5
- package/dist/engine/retraction.d.ts.map +1 -1
- package/dist/engine/retraction.js +238 -19
- package/dist/engine/retraction.js.map +1 -1
- package/dist/engine/staging.d.ts +1 -1
- package/dist/engine/staging.d.ts.map +1 -1
- package/dist/engine/staging.js +3 -3
- package/dist/engine/staging.js.map +1 -1
- package/dist/hooks/sidecar.d.ts +1 -1
- package/dist/hooks/sidecar.d.ts.map +1 -1
- package/dist/hooks/sidecar.js +4 -4
- package/dist/hooks/sidecar.js.map +1 -1
- package/dist/index.js +48 -31
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +167 -141
- package/dist/mcp.js.map +1 -1
- package/dist/storage/factory.d.ts +55 -0
- package/dist/storage/factory.d.ts.map +1 -0
- package/dist/storage/factory.js +135 -0
- package/dist/storage/factory.js.map +1 -0
- package/dist/storage/pglite-schema.d.ts +20 -0
- package/dist/storage/pglite-schema.d.ts.map +1 -0
- package/dist/storage/pglite-schema.js +165 -0
- package/dist/storage/pglite-schema.js.map +1 -0
- package/dist/storage/pglite.d.ts +187 -0
- package/dist/storage/pglite.d.ts.map +1 -0
- package/dist/storage/pglite.js +1114 -0
- package/dist/storage/pglite.js.map +1 -0
- package/dist/storage/sqlite.d.ts +117 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +357 -8
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/storage/store.d.ts +63 -0
- package/dist/storage/store.d.ts.map +1 -0
- package/dist/storage/store.js +27 -0
- package/dist/storage/store.js.map +1 -0
- package/dist/types/engram.d.ts +92 -2
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +3 -1
- package/src/adapters/claude-code.ts +218 -218
- package/src/adapters/codex.ts +252 -252
- package/src/adapters/common.ts +498 -411
- package/src/adapters/cursor.ts +135 -135
- package/src/adapters/http.ts +93 -93
- package/src/adapters/index.ts +31 -31
- package/src/adapters/types.ts +75 -75
- package/src/api/index.ts +3 -3
- package/src/api/routes.ts +970 -714
- package/src/cli/migrate.ts +307 -0
- package/src/cli.ts +755 -719
- package/src/coordination/circuit-breaker.ts +83 -0
- package/src/coordination/events.ts +90 -90
- package/src/coordination/failure-modes.ts +50 -0
- package/src/coordination/peer-decisions.ts +105 -105
- package/src/coordination/plugin-loader.ts +60 -60
- package/src/coordination/plugin.ts +44 -44
- package/src/coordination/routes.ts +56 -1
- package/src/coordination/schema.ts +13 -0
- package/src/coordination/stale.ts +80 -11
- package/src/coordination/types.ts +311 -311
- package/src/coordination/write-mutex.ts +69 -69
- package/src/core/auto-tagger.ts +168 -168
- package/src/core/decay.ts +63 -63
- package/src/core/embeddings.ts +110 -93
- package/src/core/index.ts +5 -5
- package/src/core/logger.ts +36 -36
- package/src/core/ml-worker-entry.ts +194 -0
- package/src/core/ml-worker.ts +281 -0
- package/src/core/query-expander.ts +122 -128
- package/src/core/reranker.ts +119 -125
- package/src/core/salience.ts +200 -33
- package/src/core/write-pipeline.ts +521 -343
- package/src/engine/activation.ts +235 -109
- package/src/engine/confidence.ts +120 -0
- package/src/engine/connections.ts +162 -103
- package/src/engine/consolidation-scheduler.ts +242 -125
- package/src/engine/consolidation.ts +138 -45
- package/src/engine/eval.ts +102 -102
- package/src/engine/eviction.ts +101 -101
- package/src/engine/index.ts +8 -8
- package/src/engine/retraction.ts +366 -116
- package/src/engine/staging.ts +74 -74
- package/src/hooks/sidecar.ts +5 -5
- package/src/index.ts +226 -212
- package/src/mcp.ts +1145 -1121
- package/src/storage/factory.ts +147 -0
- package/src/storage/index.ts +3 -3
- package/src/storage/pglite-schema.ts +166 -0
- package/src/storage/pglite.ts +1363 -0
- package/src/storage/sqlite.ts +402 -7
- package/src/storage/store.ts +80 -0
- package/src/types/agent.ts +67 -67
- package/src/types/checkpoint.ts +46 -46
- package/src/types/engram.ts +101 -3
- package/src/types/eval.ts +100 -100
- package/src/types/index.ts +6 -6
package/src/storage/sqlite.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import Database from 'better-sqlite3';
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { cosineSimilarity } from '../core/embeddings.js';
|
|
12
13
|
import type {
|
|
13
14
|
Engram, EngramCreate, EngramStage, Association, AssociationType,
|
|
14
15
|
SearchQuery, SalienceFeatures, ActivationEvent, StagingEvent,
|
|
@@ -369,6 +370,26 @@ export class EngramStore {
|
|
|
369
370
|
} catch {
|
|
370
371
|
this.db.exec(`ALTER TABLE engrams ADD COLUMN memory_type TEXT NOT NULL DEFAULT 'unclassified'`);
|
|
371
372
|
}
|
|
373
|
+
|
|
374
|
+
// Migration (0.8 Cluster A): sequence column for story-time / chronology.
|
|
375
|
+
// Nullable — existing engrams stay NULL. Partial index keeps the cost low.
|
|
376
|
+
try {
|
|
377
|
+
this.db.prepare('SELECT sequence FROM engrams LIMIT 0').get();
|
|
378
|
+
} catch {
|
|
379
|
+
this.db.exec(`ALTER TABLE engrams ADD COLUMN sequence INTEGER`);
|
|
380
|
+
this.db.exec(
|
|
381
|
+
`CREATE INDEX IF NOT EXISTS idx_engrams_agent_sequence
|
|
382
|
+
ON engrams(agent_id, sequence) WHERE sequence IS NOT NULL`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Migration (0.8 Cluster A): references_json column for typed cross-record
|
|
387
|
+
// links. Wired through HTTP in Cluster D — schema slot only here.
|
|
388
|
+
try {
|
|
389
|
+
this.db.prepare('SELECT references_json FROM engrams LIMIT 0').get();
|
|
390
|
+
} catch {
|
|
391
|
+
this.db.exec(`ALTER TABLE engrams ADD COLUMN references_json TEXT`);
|
|
392
|
+
}
|
|
372
393
|
}
|
|
373
394
|
|
|
374
395
|
// --- Engram CRUD ---
|
|
@@ -383,8 +404,9 @@ export class EngramStore {
|
|
|
383
404
|
this.db.prepare(`
|
|
384
405
|
INSERT INTO engrams (id, agent_id, concept, content, embedding, confidence, salience,
|
|
385
406
|
access_count, last_accessed, created_at, salience_features, reason_codes, stage, tags, episode_id,
|
|
386
|
-
ttl, memory_class, supersedes, task_status, task_priority, blocked_by, memory_type
|
|
387
|
-
|
|
407
|
+
ttl, memory_class, supersedes, task_status, task_priority, blocked_by, memory_type,
|
|
408
|
+
sequence, references_json)
|
|
409
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
388
410
|
`).run(
|
|
389
411
|
id, input.agentId, input.concept, input.content, embeddingBlob,
|
|
390
412
|
input.confidence ?? 0.5,
|
|
@@ -401,6 +423,9 @@ export class EngramStore {
|
|
|
401
423
|
input.taskPriority ?? null,
|
|
402
424
|
input.blockedBy ?? null,
|
|
403
425
|
input.memoryType ?? 'unclassified',
|
|
426
|
+
input.sequence ?? null,
|
|
427
|
+
input.references && input.references.length > 0
|
|
428
|
+
? JSON.stringify(input.references) : null,
|
|
404
429
|
);
|
|
405
430
|
|
|
406
431
|
// Add to slim cache (skip if not yet populated — first slim fetch will load it)
|
|
@@ -575,7 +600,7 @@ export class EngramStore {
|
|
|
575
600
|
if (agentIds.length === 1) return this.searchBM25WithRank(agentIds[0], query, limit);
|
|
576
601
|
|
|
577
602
|
const sanitized = query
|
|
578
|
-
.replace(/[^\w\s]/g, '')
|
|
603
|
+
.replace(/[^\w\s]/g, ' ') // Split on punctuation so hyphenated IDs (PROJ-1000) match FTS5's separator-split tokens.
|
|
579
604
|
.split(/\s+/)
|
|
580
605
|
.filter(w => w.length > 1)
|
|
581
606
|
.map(w => `"${w}"`)
|
|
@@ -650,6 +675,16 @@ export class EngramStore {
|
|
|
650
675
|
this.cacheUpdateStage(id, stage);
|
|
651
676
|
}
|
|
652
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Replace an engram's content. Used by the fade phase of consolidation
|
|
680
|
+
* (Paper 1: storage degradation) to coarsen un-recalled memories without
|
|
681
|
+
* deleting them — concept + tags stay intact for tag-based retrieval, but
|
|
682
|
+
* BM25 content surface area shrinks.
|
|
683
|
+
*/
|
|
684
|
+
updateContent(id: string, content: string): void {
|
|
685
|
+
this.db.prepare('UPDATE engrams SET content = ? WHERE id = ?').run(content, id);
|
|
686
|
+
}
|
|
687
|
+
|
|
653
688
|
updateConfidence(id: string, confidence: number): void {
|
|
654
689
|
this.db.prepare('UPDATE engrams SET confidence = ? WHERE id = ?').run(
|
|
655
690
|
Math.max(0, Math.min(1, confidence)), id
|
|
@@ -712,6 +747,46 @@ export class EngramStore {
|
|
|
712
747
|
return this.searchBM25WithRank(agentId, query, limit).map(r => r.engram);
|
|
713
748
|
}
|
|
714
749
|
|
|
750
|
+
/**
|
|
751
|
+
* Vector similarity top-K search. SQLite has no native vector index, so we
|
|
752
|
+
* iterate over the slim cache, compute cosine similarity in-process, and
|
|
753
|
+
* return the top-K nearest neighbors. PGlite uses pgvector + ivfflat for
|
|
754
|
+
* the same operation in O(log N).
|
|
755
|
+
*
|
|
756
|
+
* Returns engrams with their cosine distance (0 = identical, 2 = opposite)
|
|
757
|
+
* to match the PGlite contract. Activation engine consumes `1 - distance`
|
|
758
|
+
* as the cosine similarity score.
|
|
759
|
+
*/
|
|
760
|
+
searchByVector(
|
|
761
|
+
agentId: string,
|
|
762
|
+
vec: number[],
|
|
763
|
+
limit: number = 10,
|
|
764
|
+
): Array<{ engram: Engram; distance: number }> {
|
|
765
|
+
if (!vec || vec.length === 0) return [];
|
|
766
|
+
// Include both 'active' and 'fading' — faded engrams retain their embedding
|
|
767
|
+
// (Paper 1: storage degradation; only content is trimmed) so they should still
|
|
768
|
+
// participate in vector recall. Concept + tags + truncated content remain in BM25.
|
|
769
|
+
const activeSlim = this.getEngramsByAgentSlim(agentId, 'active', false);
|
|
770
|
+
const fadingSlim = this.getEngramsByAgentSlim(agentId, 'fading', false);
|
|
771
|
+
const slim = activeSlim.concat(fadingSlim);
|
|
772
|
+
const scored: Array<{ id: string; sim: number }> = [];
|
|
773
|
+
for (const e of slim) {
|
|
774
|
+
if (!e.embedding) continue;
|
|
775
|
+
const sim = cosineSimilarity(vec, e.embedding);
|
|
776
|
+
scored.push({ id: e.id, sim });
|
|
777
|
+
}
|
|
778
|
+
scored.sort((a, b) => b.sim - a.sim);
|
|
779
|
+
const topIds = scored.slice(0, limit).map(s => s.id);
|
|
780
|
+
if (topIds.length === 0) return [];
|
|
781
|
+
const engrams = this.getEngramsByIds(topIds);
|
|
782
|
+
const engramMap = new Map(engrams.map(e => [e.id, e]));
|
|
783
|
+
return scored
|
|
784
|
+
.slice(0, limit)
|
|
785
|
+
.map(s => ({ engram: engramMap.get(s.id), sim: s.sim }))
|
|
786
|
+
.filter((x): x is { engram: Engram; sim: number } => x.engram !== undefined)
|
|
787
|
+
.map(x => ({ engram: x.engram, distance: 1 - x.sim }));
|
|
788
|
+
}
|
|
789
|
+
|
|
715
790
|
/**
|
|
716
791
|
* BM25 search returning rank scores alongside engrams.
|
|
717
792
|
* FTS5 rank is negative (lower = better match).
|
|
@@ -720,7 +795,7 @@ export class EngramStore {
|
|
|
720
795
|
searchBM25WithRank(agentId: string, query: string, limit: number = 10): { engram: Engram; bm25Score: number }[] {
|
|
721
796
|
// Sanitize query for FTS5: quote each word to prevent column name interpretation
|
|
722
797
|
const sanitized = query
|
|
723
|
-
.replace(/[^\w\s]/g, '')
|
|
798
|
+
.replace(/[^\w\s]/g, ' ') // Split on punctuation so hyphenated IDs (PROJ-1000) match FTS5's separator-split tokens.
|
|
724
799
|
.split(/\s+/)
|
|
725
800
|
.filter(w => w.length > 1)
|
|
726
801
|
.map(w => `"${w}"`)
|
|
@@ -789,14 +864,48 @@ export class EngramStore {
|
|
|
789
864
|
sql += ' AND retracted = ?';
|
|
790
865
|
params.push(query.retracted ? 1 : 0);
|
|
791
866
|
}
|
|
792
|
-
|
|
793
|
-
|
|
867
|
+
|
|
868
|
+
// ── Tag filters (0.8 Cluster B) ──
|
|
869
|
+
// AND-set: merge legacy `tags` + new `tagsAll`. Both apply (intersection).
|
|
870
|
+
const allTags = [...(query.tags ?? []), ...(query.tagsAll ?? [])];
|
|
871
|
+
if (allTags.length > 0) {
|
|
872
|
+
for (const tag of allTags) {
|
|
794
873
|
sql += ' AND tags LIKE ?';
|
|
795
874
|
params.push(`%"${tag}"%`);
|
|
796
875
|
}
|
|
797
876
|
}
|
|
877
|
+
// OR-set: at least one must match.
|
|
878
|
+
if (query.tagsAny && query.tagsAny.length > 0) {
|
|
879
|
+
const ors = query.tagsAny.map(() => 'tags LIKE ?').join(' OR ');
|
|
880
|
+
sql += ` AND (${ors})`;
|
|
881
|
+
for (const tag of query.tagsAny) params.push(`%"${tag}"%`);
|
|
882
|
+
}
|
|
883
|
+
// NOT-set: none may match.
|
|
884
|
+
if (query.tagsNone && query.tagsNone.length > 0) {
|
|
885
|
+
const ors = query.tagsNone.map(() => 'tags LIKE ?').join(' OR ');
|
|
886
|
+
sql += ` AND NOT (${ors})`;
|
|
887
|
+
for (const tag of query.tagsNone) params.push(`%"${tag}"%`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ── Sort (0.8 Cluster B) ──
|
|
891
|
+
// Default preserves legacy behavior (last_accessed DESC). Map TS camelCase
|
|
892
|
+
// to SQL snake_case. `sequence` puts NULLs last via (col IS NULL) trick.
|
|
893
|
+
const sortCol = ({
|
|
894
|
+
createdAt: 'created_at',
|
|
895
|
+
sequence: 'sequence',
|
|
896
|
+
salience: 'salience',
|
|
897
|
+
confidence: 'confidence',
|
|
898
|
+
lastAccessed: 'last_accessed',
|
|
899
|
+
} as const)[query.sortBy ?? 'lastAccessed'];
|
|
900
|
+
const dir = query.sortOrder === 'asc' ? 'ASC' : 'DESC';
|
|
901
|
+
if (query.sortBy === 'sequence') {
|
|
902
|
+
// NULLs last regardless of direction so engrams without a story-time
|
|
903
|
+
// value don't shuffle into the middle of sequenced results.
|
|
904
|
+
sql += ` ORDER BY (sequence IS NULL), sequence ${dir}`;
|
|
905
|
+
} else {
|
|
906
|
+
sql += ` ORDER BY ${sortCol} ${dir}`;
|
|
907
|
+
}
|
|
798
908
|
|
|
799
|
-
sql += ' ORDER BY last_accessed DESC';
|
|
800
909
|
sql += ` LIMIT ? OFFSET ?`;
|
|
801
910
|
params.push(query.limit ?? 50, query.offset ?? 0);
|
|
802
911
|
|
|
@@ -889,6 +998,37 @@ export class EngramStore {
|
|
|
889
998
|
this.db.prepare('UPDATE engrams SET supersedes = ? WHERE id = ?').run(oldId, newId);
|
|
890
999
|
}
|
|
891
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* Find the most recent active engram matching `concept` (case-insensitive,
|
|
1003
|
+
* trimmed) and all of `requiredTags`. Excludes superseded and retracted
|
|
1004
|
+
* engrams. Used by /memory/supersede Form B (0.8 Cluster D) and by
|
|
1005
|
+
* /memory/write's references[] resolution.
|
|
1006
|
+
*
|
|
1007
|
+
* Returns null if no match.
|
|
1008
|
+
*/
|
|
1009
|
+
findActiveMatchByConcept(
|
|
1010
|
+
agentId: string,
|
|
1011
|
+
concept: string,
|
|
1012
|
+
requiredTags?: string[],
|
|
1013
|
+
): Engram | null {
|
|
1014
|
+
let sql = `SELECT * FROM engrams
|
|
1015
|
+
WHERE agent_id = ?
|
|
1016
|
+
AND LOWER(TRIM(concept)) = LOWER(TRIM(?))
|
|
1017
|
+
AND stage = 'active'
|
|
1018
|
+
AND retracted = 0
|
|
1019
|
+
AND superseded_by IS NULL`;
|
|
1020
|
+
const params: any[] = [agentId, concept];
|
|
1021
|
+
if (requiredTags && requiredTags.length > 0) {
|
|
1022
|
+
for (const tag of requiredTags) {
|
|
1023
|
+
sql += ' AND tags LIKE ?';
|
|
1024
|
+
params.push(`%"${tag}"%`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
sql += ' ORDER BY created_at DESC LIMIT 1';
|
|
1028
|
+
const row = this.db.prepare(sql).get(...params) as any;
|
|
1029
|
+
return row ? this.rowToEngram(row) : null;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
892
1032
|
/**
|
|
893
1033
|
* Check if an engram has been superseded.
|
|
894
1034
|
*/
|
|
@@ -1223,6 +1363,8 @@ export class EngramStore {
|
|
|
1223
1363
|
taskStatus: row.task_status ?? null,
|
|
1224
1364
|
taskPriority: row.task_priority ?? null,
|
|
1225
1365
|
blockedBy: row.blocked_by ?? null,
|
|
1366
|
+
sequence: row.sequence == null ? null : Number(row.sequence),
|
|
1367
|
+
references: row.references_json ? JSON.parse(row.references_json) : null,
|
|
1226
1368
|
};
|
|
1227
1369
|
}
|
|
1228
1370
|
|
|
@@ -1463,4 +1605,257 @@ export class EngramStore {
|
|
|
1463
1605
|
this.walCheckpoint();
|
|
1464
1606
|
this.db.close();
|
|
1465
1607
|
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* Run `fn` in a SQL transaction. All writes succeed atomically or roll back
|
|
1611
|
+
* on throw. Synchronous (matches better-sqlite3 semantics).
|
|
1612
|
+
*
|
|
1613
|
+
* Used by 0.8 Cluster D's `/memory/supersede` Form B to wrap find-match +
|
|
1614
|
+
* performWrite + supersede + causal-edge + confidence-decay so callers
|
|
1615
|
+
* never observe an intermediate state where new engram exists but old
|
|
1616
|
+
* isn't yet superseded.
|
|
1617
|
+
*/
|
|
1618
|
+
transaction<T>(fn: () => T): T {
|
|
1619
|
+
return this.db.transaction(fn)();
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* Async-aware transaction wrapper. `fn` may await — we hold the SQLite
|
|
1624
|
+
* lock across awaits by issuing raw BEGIN/COMMIT/ROLLBACK statements rather
|
|
1625
|
+
* than using better-sqlite3's sync `db.transaction()`.
|
|
1626
|
+
*
|
|
1627
|
+
* Used by Form B (atomic write + supersede) where the body calls
|
|
1628
|
+
* async store methods through the IEngramStore contract.
|
|
1629
|
+
*/
|
|
1630
|
+
async withTransaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
1631
|
+
this.db.exec('BEGIN');
|
|
1632
|
+
try {
|
|
1633
|
+
const result = await fn();
|
|
1634
|
+
this.db.exec('COMMIT');
|
|
1635
|
+
return result;
|
|
1636
|
+
} catch (err) {
|
|
1637
|
+
try { this.db.exec('ROLLBACK'); } catch { /* best-effort */ }
|
|
1638
|
+
throw err;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// ============================================================
|
|
1643
|
+
// 0.8 Cluster C — materialized-view + atomic-counter primitives
|
|
1644
|
+
// ============================================================
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* For each distinct value of the tag whose key prefix is `tagKeyPrefix`
|
|
1648
|
+
* (e.g. `"character="`), return the most-recent active engram. Used by
|
|
1649
|
+
* NovelForge's "latest emotional state per character", "latest motif
|
|
1650
|
+
* phase per motif", "recent chapter summaries".
|
|
1651
|
+
*
|
|
1652
|
+
* `sortBy` defaults to `createdAt`. With `"sequence"`, engrams without a
|
|
1653
|
+
* sequence are excluded (they have no story-time anchor).
|
|
1654
|
+
*/
|
|
1655
|
+
getLatestByTag(opts: {
|
|
1656
|
+
agentId: string;
|
|
1657
|
+
tagKeyPrefix: string; // e.g. "character=", "motif=", "chapter="
|
|
1658
|
+
scopeTagsAll?: string[]; // optional narrowing (e.g. ["topic=motif-use"])
|
|
1659
|
+
retracted?: boolean;
|
|
1660
|
+
sortBy?: 'createdAt' | 'sequence';
|
|
1661
|
+
limit?: number;
|
|
1662
|
+
}): Engram[] {
|
|
1663
|
+
let sql = `SELECT * FROM engrams
|
|
1664
|
+
WHERE agent_id = ?
|
|
1665
|
+
AND retracted = ?
|
|
1666
|
+
AND stage = 'active'
|
|
1667
|
+
AND tags LIKE ?`;
|
|
1668
|
+
const params: any[] = [
|
|
1669
|
+
opts.agentId,
|
|
1670
|
+
opts.retracted ? 1 : 0,
|
|
1671
|
+
`%"${opts.tagKeyPrefix}%`,
|
|
1672
|
+
];
|
|
1673
|
+
if (opts.scopeTagsAll && opts.scopeTagsAll.length > 0) {
|
|
1674
|
+
for (const t of opts.scopeTagsAll) {
|
|
1675
|
+
sql += ' AND tags LIKE ?';
|
|
1676
|
+
params.push(`%"${t}"%`);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
if (opts.sortBy === 'sequence') {
|
|
1680
|
+
sql += ' AND sequence IS NOT NULL';
|
|
1681
|
+
}
|
|
1682
|
+
sql += ' ORDER BY ' + (opts.sortBy === 'sequence' ? 'sequence DESC, created_at DESC' : 'created_at DESC');
|
|
1683
|
+
|
|
1684
|
+
const rows = this.db.prepare(sql).all(...params) as any[];
|
|
1685
|
+
const engrams = rows.map(r => this.rowToEngram(r));
|
|
1686
|
+
|
|
1687
|
+
// Group by extracted tag value, take first (already sorted newest first).
|
|
1688
|
+
const seen = new Map<string, Engram>();
|
|
1689
|
+
for (const e of engrams) {
|
|
1690
|
+
const value = this.extractTagValue(e.tags, opts.tagKeyPrefix);
|
|
1691
|
+
if (value == null) continue;
|
|
1692
|
+
if (!seen.has(value)) seen.set(value, e);
|
|
1693
|
+
}
|
|
1694
|
+
const out = Array.from(seen.values());
|
|
1695
|
+
return opts.limit ? out.slice(0, opts.limit) : out;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Filter engrams by tag-set operators and sort by numeric value extracted
|
|
1700
|
+
* from a tag prefix (e.g. `"weight="` → numeric value of `weight=8`).
|
|
1701
|
+
* Used by NovelForge's "top 40 active promises by weight".
|
|
1702
|
+
*
|
|
1703
|
+
* Missing or unparseable numeric values sort last.
|
|
1704
|
+
*/
|
|
1705
|
+
getTopBy(opts: {
|
|
1706
|
+
agentId: string;
|
|
1707
|
+
sortField: string; // tag prefix, e.g. "weight="
|
|
1708
|
+
order: 'asc' | 'desc';
|
|
1709
|
+
filterTagsAll?: string[];
|
|
1710
|
+
filterTagsAny?: string[];
|
|
1711
|
+
filterTagsNone?: string[];
|
|
1712
|
+
limit?: number;
|
|
1713
|
+
retracted?: boolean;
|
|
1714
|
+
}): Engram[] {
|
|
1715
|
+
// Build the base query inline (mirrors search() but adds the topBy sort).
|
|
1716
|
+
let sql = `SELECT * FROM engrams
|
|
1717
|
+
WHERE agent_id = ?
|
|
1718
|
+
AND retracted = ?
|
|
1719
|
+
AND stage = 'active'
|
|
1720
|
+
AND tags LIKE ?`;
|
|
1721
|
+
const params: any[] = [
|
|
1722
|
+
opts.agentId,
|
|
1723
|
+
opts.retracted ? 1 : 0,
|
|
1724
|
+
`%"${opts.sortField}%`, // must have a tag with this prefix
|
|
1725
|
+
];
|
|
1726
|
+
|
|
1727
|
+
if (opts.filterTagsAll && opts.filterTagsAll.length > 0) {
|
|
1728
|
+
for (const tag of opts.filterTagsAll) {
|
|
1729
|
+
sql += ' AND tags LIKE ?';
|
|
1730
|
+
params.push(`%"${tag}"%`);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
if (opts.filterTagsAny && opts.filterTagsAny.length > 0) {
|
|
1734
|
+
const ors = opts.filterTagsAny.map(() => 'tags LIKE ?').join(' OR ');
|
|
1735
|
+
sql += ` AND (${ors})`;
|
|
1736
|
+
for (const tag of opts.filterTagsAny) params.push(`%"${tag}"%`);
|
|
1737
|
+
}
|
|
1738
|
+
if (opts.filterTagsNone && opts.filterTagsNone.length > 0) {
|
|
1739
|
+
const ors = opts.filterTagsNone.map(() => 'tags LIKE ?').join(' OR ');
|
|
1740
|
+
sql += ` AND NOT (${ors})`;
|
|
1741
|
+
for (const tag of opts.filterTagsNone) params.push(`%"${tag}"%`);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
const rows = this.db.prepare(sql).all(...params) as any[];
|
|
1745
|
+
const engrams = rows.map(r => this.rowToEngram(r));
|
|
1746
|
+
|
|
1747
|
+
// Extract numeric value from the sortField tag; missing or NaN sorts last.
|
|
1748
|
+
const valued = engrams.map(e => {
|
|
1749
|
+
const raw = this.extractTagValue(e.tags, opts.sortField);
|
|
1750
|
+
const n = raw == null ? NaN : Number(raw);
|
|
1751
|
+
return { e, n };
|
|
1752
|
+
});
|
|
1753
|
+
valued.sort((a, b) => {
|
|
1754
|
+
const aNaN = Number.isNaN(a.n);
|
|
1755
|
+
const bNaN = Number.isNaN(b.n);
|
|
1756
|
+
if (aNaN && bNaN) return 0;
|
|
1757
|
+
if (aNaN) return 1; // NaN last
|
|
1758
|
+
if (bNaN) return -1;
|
|
1759
|
+
return opts.order === 'asc' ? a.n - b.n : b.n - a.n;
|
|
1760
|
+
});
|
|
1761
|
+
const sorted = valued.map(v => v.e);
|
|
1762
|
+
return opts.limit ? sorted.slice(0, opts.limit) : sorted;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* Compute the effective state of an engram from referenced events.
|
|
1767
|
+
*
|
|
1768
|
+
* - 'superseded' if engram.supersededBy is set
|
|
1769
|
+
* - else: scan engrams whose references_json names this engram with a
|
|
1770
|
+
* terminal relation type (resolves / subverts / abandons). Take the
|
|
1771
|
+
* latest by createdAt. effectiveState = that type.
|
|
1772
|
+
* - else: 'active'
|
|
1773
|
+
*
|
|
1774
|
+
* Returns `null` if target engram not found.
|
|
1775
|
+
*/
|
|
1776
|
+
resolveEffectiveState(targetEngramId: string): {
|
|
1777
|
+
engram: Engram;
|
|
1778
|
+
effectiveState: 'active' | 'resolved' | 'subverted' | 'abandoned' | 'superseded';
|
|
1779
|
+
resolvingEvents: Array<{ id: string; type: string; createdAt: string }>;
|
|
1780
|
+
} | null {
|
|
1781
|
+
const target = this.getEngram(targetEngramId);
|
|
1782
|
+
if (!target) return null;
|
|
1783
|
+
|
|
1784
|
+
if (target.supersededBy) {
|
|
1785
|
+
return { engram: target, effectiveState: 'superseded', resolvingEvents: [] };
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// Find candidate referencing engrams. LIKE-filter first to narrow,
|
|
1789
|
+
// then JSON.parse each candidate's references_json to verify the
|
|
1790
|
+
// matchEngramId and type. Cheap enough at NovelForge's scale.
|
|
1791
|
+
const candidates = this.db.prepare(
|
|
1792
|
+
`SELECT id, references_json, created_at FROM engrams
|
|
1793
|
+
WHERE agent_id = ?
|
|
1794
|
+
AND retracted = 0
|
|
1795
|
+
AND stage = 'active'
|
|
1796
|
+
AND references_json IS NOT NULL
|
|
1797
|
+
AND references_json LIKE ?`
|
|
1798
|
+
).all(target.agentId, `%"${targetEngramId}"%`) as Array<{
|
|
1799
|
+
id: string;
|
|
1800
|
+
references_json: string;
|
|
1801
|
+
created_at: string;
|
|
1802
|
+
}>;
|
|
1803
|
+
|
|
1804
|
+
const terminalTypes = new Set(['resolves', 'subverts', 'abandons']);
|
|
1805
|
+
const resolvingEvents: Array<{ id: string; type: string; createdAt: string }> = [];
|
|
1806
|
+
for (const c of candidates) {
|
|
1807
|
+
try {
|
|
1808
|
+
const refs = JSON.parse(c.references_json) as Array<{ type: string; matchEngramId?: string }>;
|
|
1809
|
+
for (const ref of refs) {
|
|
1810
|
+
if (ref.matchEngramId === targetEngramId && terminalTypes.has(ref.type)) {
|
|
1811
|
+
resolvingEvents.push({ id: c.id, type: ref.type, createdAt: c.created_at });
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
} catch { /* malformed references_json — skip */ }
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
if (resolvingEvents.length === 0) {
|
|
1818
|
+
return { engram: target, effectiveState: 'active', resolvingEvents: [] };
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Latest event wins.
|
|
1822
|
+
resolvingEvents.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1823
|
+
const latestType = resolvingEvents[0]!.type as 'resolves' | 'subverts' | 'abandons';
|
|
1824
|
+
const effectiveState = (
|
|
1825
|
+
latestType === 'resolves' ? 'resolved'
|
|
1826
|
+
: latestType === 'subverts' ? 'subverted'
|
|
1827
|
+
: 'abandoned'
|
|
1828
|
+
);
|
|
1829
|
+
return { engram: target, effectiveState, resolvingEvents };
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* Atomically allocate the next sequence number for an agent.
|
|
1834
|
+
*
|
|
1835
|
+
* Uses BEGIN IMMEDIATE to serialize concurrent callers — better-sqlite3's
|
|
1836
|
+
* transaction() acquires a RESERVED lock which prevents other writers
|
|
1837
|
+
* from racing. Returns MAX(sequence)+1 for the agent, or 1 if no engrams
|
|
1838
|
+
* have a sequence yet.
|
|
1839
|
+
*/
|
|
1840
|
+
allocateNextSequence(agentId: string): number {
|
|
1841
|
+
return this.transaction(() => {
|
|
1842
|
+
const row = this.db.prepare(
|
|
1843
|
+
'SELECT MAX(sequence) AS max_seq FROM engrams WHERE agent_id = ?'
|
|
1844
|
+
).get(agentId) as { max_seq: number | null };
|
|
1845
|
+
return (row?.max_seq ?? 0) + 1;
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* Extract the value following a tag prefix, e.g. `["weight=8"]` + `"weight="`
|
|
1851
|
+
* → `"8"`. Returns null if no tag with that prefix is present.
|
|
1852
|
+
*/
|
|
1853
|
+
private extractTagValue(tags: string[], tagKeyPrefix: string): string | null {
|
|
1854
|
+
for (const t of tags) {
|
|
1855
|
+
if (t.startsWith(tagKeyPrefix)) {
|
|
1856
|
+
return t.slice(tagKeyPrefix.length);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
return null;
|
|
1860
|
+
}
|
|
1466
1861
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Backend-agnostic storage contract for AWM.
|
|
5
|
+
*
|
|
6
|
+
* AWM 0.8.x introduces a pluggable storage layer:
|
|
7
|
+
* - SQLiteEngramStore: better-sqlite3 + FTS5 + BLOB embeddings (current default)
|
|
8
|
+
* - PGliteEngramStore: @electric-sql/pglite + pgvector + tsvector (opt-in via
|
|
9
|
+
* `AWM_STORE_BACKEND=pglite`, planned default in 0.9.x)
|
|
10
|
+
* - PostgresEngramStore: real Postgres backend for scale (planned, post-1.0)
|
|
11
|
+
*
|
|
12
|
+
* All backends provide the same public surface — defined here as `IEngramStore`.
|
|
13
|
+
* The cognitive engines (activation, consolidation, Hebbian, eviction, etc.)
|
|
14
|
+
* accept `IEngramStore` and work against any conforming backend.
|
|
15
|
+
*
|
|
16
|
+
* The interface is derived from the SQLite implementation via TypeScript's
|
|
17
|
+
* `Omit<>` so it stays in sync automatically. SQLite-specific methods
|
|
18
|
+
* (DB handle access, WAL checkpointing, slim-cache management, integrity
|
|
19
|
+
* checks) are excluded — these are implementation-internal and don't belong
|
|
20
|
+
* in a backend-agnostic contract.
|
|
21
|
+
*
|
|
22
|
+
* Future backends MUST implement every method on `IEngramStore`. They MAY
|
|
23
|
+
* additionally expose backend-specific methods (e.g., PGlite-specific tooling,
|
|
24
|
+
* Postgres pool management) — those are not part of the contract.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { EngramStore as SqliteEngramStore } from './sqlite.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Backend-specific methods on SqliteEngramStore that are NOT part of the
|
|
31
|
+
* shared contract. Other backends may provide functionally-similar methods
|
|
32
|
+
* under different names or not at all.
|
|
33
|
+
*/
|
|
34
|
+
type SqliteSpecificMethods =
|
|
35
|
+
| 'getDb' // Returns better-sqlite3 Database — SQLite-only API
|
|
36
|
+
| 'integrityCheck' // SQLite PRAGMA integrity_check
|
|
37
|
+
| 'walCheckpoint' // SQLite WAL checkpoint
|
|
38
|
+
| 'stopWalCheckpointTimer'
|
|
39
|
+
| 'backup' // SQLite backup API; PGlite/Postgres use pg_dump
|
|
40
|
+
| 'warmSlimCache' // In-memory cache pre-population (SQLite-specific perf opt)
|
|
41
|
+
| 'resetSlimCache'
|
|
42
|
+
| 'getSlimCacheStats'
|
|
43
|
+
| 'transaction'; // SQLite sync transaction helper — PGlite uses withTransaction
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* MaybePromise — covariant union that lets sync and async backends share one contract.
|
|
47
|
+
*
|
|
48
|
+
* Engines `await` every store call. `await T` resolves to T immediately when
|
|
49
|
+
* the backend is sync (SQLite, returning bare values) and resolves the Promise
|
|
50
|
+
* when the backend is async (PGlite). Both shapes satisfy the same interface.
|
|
51
|
+
*/
|
|
52
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Turn every method return type R into `MaybePromise<Awaited<R>>` so the
|
|
56
|
+
* contract accepts both sync and async backends.
|
|
57
|
+
*/
|
|
58
|
+
type AsyncifyMethods<T> = {
|
|
59
|
+
[K in keyof T]: T[K] extends (...args: infer A) => infer R
|
|
60
|
+
? (...args: A) => MaybePromise<Awaited<R>>
|
|
61
|
+
: T[K];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The backend-agnostic storage contract.
|
|
66
|
+
*
|
|
67
|
+
* Any class with this shape can be used as the EngramStore for the AWM
|
|
68
|
+
* cognitive engines. New backends should `implements IEngramStore` to get
|
|
69
|
+
* compile-time enforcement of the full surface.
|
|
70
|
+
*/
|
|
71
|
+
export type IEngramStore = AsyncifyMethods<Omit<SqliteEngramStore, SqliteSpecificMethods>>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convenience type-only re-export so consumers can `import type { EngramStore }`
|
|
75
|
+
* from this module and get the backend-agnostic contract instead of the
|
|
76
|
+
* SQLite-specific class. Existing imports from `'../storage/sqlite.js'`
|
|
77
|
+
* continue to work and resolve to the SQLite class (which is a structural
|
|
78
|
+
* supertype of IEngramStore).
|
|
79
|
+
*/
|
|
80
|
+
export type EngramStore = IEngramStore;
|