agent-working-memory 0.8.6 → 0.8.7

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 (53) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/common.d.ts.map +1 -1
  3. package/dist/adapters/common.js +13 -0
  4. package/dist/adapters/common.js.map +1 -1
  5. package/dist/api/routes.js +1 -1
  6. package/dist/cli/migrate.js +29 -29
  7. package/dist/cli.js +1 -1
  8. package/dist/coordination/circuit-breaker.js +23 -23
  9. package/dist/core/lite-compress.d.ts +26 -0
  10. package/dist/core/lite-compress.d.ts.map +1 -0
  11. package/dist/core/lite-compress.js +105 -0
  12. package/dist/core/lite-compress.js.map +1 -0
  13. package/dist/mcp.d.ts +5 -1
  14. package/dist/mcp.d.ts.map +1 -1
  15. package/dist/mcp.js +58 -4
  16. package/dist/mcp.js.map +1 -1
  17. package/dist/storage/pglite-schema.js +143 -143
  18. package/dist/storage/pglite.js +138 -138
  19. package/package.json +4 -3
  20. package/src/adapters/common.ts +13 -0
  21. package/src/api/index.ts +3 -3
  22. package/src/api/routes.ts +1 -1
  23. package/src/cli/migrate.ts +307 -307
  24. package/src/cli.ts +1 -1
  25. package/src/coordination/circuit-breaker.ts +83 -83
  26. package/src/coordination/failure-modes.ts +50 -50
  27. package/src/core/decay.ts +63 -63
  28. package/src/core/embeddings.ts +110 -110
  29. package/src/core/index.ts +5 -5
  30. package/src/core/lite-compress.ts +129 -0
  31. package/src/core/logger.ts +36 -36
  32. package/src/core/ml-worker-entry.ts +194 -194
  33. package/src/core/ml-worker.ts +281 -281
  34. package/src/core/query-expander.ts +122 -122
  35. package/src/core/reranker.ts +119 -119
  36. package/src/engine/confidence.ts +120 -120
  37. package/src/engine/connections.ts +162 -162
  38. package/src/engine/consolidation-scheduler.ts +242 -242
  39. package/src/engine/eval.ts +102 -102
  40. package/src/engine/eviction.ts +101 -101
  41. package/src/engine/index.ts +8 -8
  42. package/src/engine/retraction.ts +366 -366
  43. package/src/engine/staging.ts +74 -74
  44. package/src/mcp.ts +70 -4
  45. package/src/storage/factory.ts +147 -147
  46. package/src/storage/index.ts +3 -3
  47. package/src/storage/pglite-schema.ts +166 -166
  48. package/src/storage/pglite.ts +1363 -1363
  49. package/src/storage/store.ts +80 -80
  50. package/src/types/agent.ts +67 -67
  51. package/src/types/checkpoint.ts +46 -46
  52. package/src/types/eval.ts +100 -100
  53. package/src/types/index.ts +6 -6
@@ -1,120 +1,120 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Retrieval confidence — score-distribution-aware signal that complements
5
- * the per-result `score`. The shape of the result set carries information
6
- * the raw scores do not:
7
- *
8
- * - Confident recall: top-1 dominates, sharp cliff, non-trivial floor.
9
- * - Noisy recall: many similar scores, flat distribution, weak floor.
10
- * - "Best of bad bunch": sharp cliff but the cliff sits below a usable
11
- * floor — the system found a winner among uninteresting candidates.
12
- *
13
- * Research grounding:
14
- * - Geifman & El-Yaniv, "Selective Classification for Deep Neural
15
- * Networks" (NeurIPS 2017): abstaining improves precision on confused
16
- * inputs more than recalibrating thresholds.
17
- * - Roitero et al, "Predictive Confidence in Retrieval" (SIGIR 2022):
18
- * score-distribution shape predicts retrieval quality better than
19
- * top-1 score in isolation.
20
- * - Carmel & Yom-Tov, "Estimating Query Difficulty for IR" (Synthesis
21
- * Lectures, 2010): post-retrieval predictors — sharpness, depth of
22
- * score drop — correlate with TREC topic difficulty.
23
- *
24
- * AWM 0.8.5 integration: confidence is computed once per recall after
25
- * final scoring and attached to every `ActivationResult`. Consumers may
26
- * use it however they like (display, abstention, paired retrieval).
27
- * Default behavior of recall is unchanged — confidence is data, not a
28
- * gate, in PR-1.
29
- *
30
- * Configurable via env vars (initial weights tuned to favour sharpness):
31
- * AWM_CONF_SHARPNESS_W (default 0.4) — weight of top1/mean(top5) signal
32
- * AWM_CONF_CLIFF_W (default 0.3) — weight of (top1 - top10) / top1
33
- * AWM_CONF_FLOOR_W (default 0.3) — weight of top1 absolute score
34
- */
35
-
36
- export interface RecallConfidence {
37
- /** Composite confidence in [0, 1]. Higher = recall result is more trustworthy. */
38
- confidence: number;
39
- /** top1 / mean(top5), mapped to [0, 1] via (s-1)/(s+1). High = clear winner. */
40
- sharpness: number;
41
- /** (top1 - top10) / top1 in [0, 1]. High = sharp dropoff after winner. */
42
- cliff: number;
43
- /** top1 raw score, clamped to [0, 1]. Low = "best of bad bunch" risk. */
44
- floor: number;
45
- }
46
-
47
- const SHARPNESS_W = parseFloat(process.env.AWM_CONF_SHARPNESS_W ?? '0.4');
48
- const CLIFF_W = parseFloat(process.env.AWM_CONF_CLIFF_W ?? '0.3');
49
- const FLOOR_W = parseFloat(process.env.AWM_CONF_FLOOR_W ?? '0.3');
50
-
51
- /**
52
- * Compute recall confidence from an ordered (descending) array of result scores.
53
- *
54
- * Returns a confidence near 0 when:
55
- * - Empty result set (no winner)
56
- * - Flat distribution (sharpness ~1, cliff ~0)
57
- * - Low absolute scores (floor low — "best of bad bunch")
58
- *
59
- * Returns a confidence near 1 when:
60
- * - top-1 dominates (sharpness >> 1)
61
- * - Sharp cliff after top-1 (cliff close to 1)
62
- * - top-1 is itself a strong absolute match (floor close to 1)
63
- *
64
- * Edge cases:
65
- * - 1 result: cliff is 0 (no runner-up). Sharpness defaults to 1 (no peers
66
- * to dominate). Confidence anchored entirely by floor.
67
- * - 0 results: all zero, confidence = 0.
68
- */
69
- export function computeRecallConfidence(scoresDesc: number[]): RecallConfidence {
70
- if (scoresDesc.length === 0) {
71
- return { confidence: 0, sharpness: 0, cliff: 0, floor: 0 };
72
- }
73
-
74
- const top1 = scoresDesc[0];
75
-
76
- // Floor: clamp top1 into [0, 1]. AWM composite scores already lie in this
77
- // range under normal use, but be defensive.
78
- const floor = Math.max(0, Math.min(1, top1));
79
-
80
- // Sharpness: top1 / mean(top-5). Skip if only 1 result (no peers).
81
- let sharpness = 0;
82
- if (scoresDesc.length >= 2) {
83
- const window = scoresDesc.slice(0, Math.min(5, scoresDesc.length));
84
- const mean = window.reduce((s, v) => s + v, 0) / window.length;
85
- if (mean > 0) {
86
- const ratio = top1 / mean; // typically in [1, K]
87
- sharpness = (ratio - 1) / (ratio + 1); // maps [1, ∞) → [0, 1)
88
- }
89
- }
90
-
91
- // Cliff: how steep is the drop from top-1 to the K-th candidate?
92
- // Use top-10 (or last available). If only 1 result, no cliff to measure.
93
- let cliff = 0;
94
- if (scoresDesc.length >= 2 && top1 > 0) {
95
- const tail = scoresDesc[Math.min(9, scoresDesc.length - 1)];
96
- cliff = Math.max(0, Math.min(1, (top1 - tail) / top1));
97
- }
98
-
99
- // Geometric blend — any near-zero component pulls confidence down.
100
- // Add a tiny epsilon so log/zero doesn't collapse the whole signal when
101
- // a result is genuinely sharp but the cliff is computed off only 2-3
102
- // candidates (cliff small even for confident recalls).
103
- const EPS = 0.05;
104
- const s = sharpness + EPS;
105
- const c = cliff + EPS;
106
- const f = floor + EPS;
107
-
108
- // Weighted geometric mean: prod(x_i ^ w_i)
109
- const logConf =
110
- SHARPNESS_W * Math.log(s)
111
- + CLIFF_W * Math.log(c)
112
- + FLOOR_W * Math.log(f);
113
- const totalW = SHARPNESS_W + CLIFF_W + FLOOR_W;
114
- // Subtract epsilon contribution so the floor of confidence is ~0 when all
115
- // signals are zero (rather than the value of EPS).
116
- const rawConf = Math.exp(logConf / totalW) - EPS;
117
- const confidence = Math.max(0, Math.min(1, rawConf));
118
-
119
- return { confidence, sharpness, cliff, floor };
120
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Retrieval confidence — score-distribution-aware signal that complements
5
+ * the per-result `score`. The shape of the result set carries information
6
+ * the raw scores do not:
7
+ *
8
+ * - Confident recall: top-1 dominates, sharp cliff, non-trivial floor.
9
+ * - Noisy recall: many similar scores, flat distribution, weak floor.
10
+ * - "Best of bad bunch": sharp cliff but the cliff sits below a usable
11
+ * floor — the system found a winner among uninteresting candidates.
12
+ *
13
+ * Research grounding:
14
+ * - Geifman & El-Yaniv, "Selective Classification for Deep Neural
15
+ * Networks" (NeurIPS 2017): abstaining improves precision on confused
16
+ * inputs more than recalibrating thresholds.
17
+ * - Roitero et al, "Predictive Confidence in Retrieval" (SIGIR 2022):
18
+ * score-distribution shape predicts retrieval quality better than
19
+ * top-1 score in isolation.
20
+ * - Carmel & Yom-Tov, "Estimating Query Difficulty for IR" (Synthesis
21
+ * Lectures, 2010): post-retrieval predictors — sharpness, depth of
22
+ * score drop — correlate with TREC topic difficulty.
23
+ *
24
+ * AWM 0.8.5 integration: confidence is computed once per recall after
25
+ * final scoring and attached to every `ActivationResult`. Consumers may
26
+ * use it however they like (display, abstention, paired retrieval).
27
+ * Default behavior of recall is unchanged — confidence is data, not a
28
+ * gate, in PR-1.
29
+ *
30
+ * Configurable via env vars (initial weights tuned to favour sharpness):
31
+ * AWM_CONF_SHARPNESS_W (default 0.4) — weight of top1/mean(top5) signal
32
+ * AWM_CONF_CLIFF_W (default 0.3) — weight of (top1 - top10) / top1
33
+ * AWM_CONF_FLOOR_W (default 0.3) — weight of top1 absolute score
34
+ */
35
+
36
+ export interface RecallConfidence {
37
+ /** Composite confidence in [0, 1]. Higher = recall result is more trustworthy. */
38
+ confidence: number;
39
+ /** top1 / mean(top5), mapped to [0, 1] via (s-1)/(s+1). High = clear winner. */
40
+ sharpness: number;
41
+ /** (top1 - top10) / top1 in [0, 1]. High = sharp dropoff after winner. */
42
+ cliff: number;
43
+ /** top1 raw score, clamped to [0, 1]. Low = "best of bad bunch" risk. */
44
+ floor: number;
45
+ }
46
+
47
+ const SHARPNESS_W = parseFloat(process.env.AWM_CONF_SHARPNESS_W ?? '0.4');
48
+ const CLIFF_W = parseFloat(process.env.AWM_CONF_CLIFF_W ?? '0.3');
49
+ const FLOOR_W = parseFloat(process.env.AWM_CONF_FLOOR_W ?? '0.3');
50
+
51
+ /**
52
+ * Compute recall confidence from an ordered (descending) array of result scores.
53
+ *
54
+ * Returns a confidence near 0 when:
55
+ * - Empty result set (no winner)
56
+ * - Flat distribution (sharpness ~1, cliff ~0)
57
+ * - Low absolute scores (floor low — "best of bad bunch")
58
+ *
59
+ * Returns a confidence near 1 when:
60
+ * - top-1 dominates (sharpness >> 1)
61
+ * - Sharp cliff after top-1 (cliff close to 1)
62
+ * - top-1 is itself a strong absolute match (floor close to 1)
63
+ *
64
+ * Edge cases:
65
+ * - 1 result: cliff is 0 (no runner-up). Sharpness defaults to 1 (no peers
66
+ * to dominate). Confidence anchored entirely by floor.
67
+ * - 0 results: all zero, confidence = 0.
68
+ */
69
+ export function computeRecallConfidence(scoresDesc: number[]): RecallConfidence {
70
+ if (scoresDesc.length === 0) {
71
+ return { confidence: 0, sharpness: 0, cliff: 0, floor: 0 };
72
+ }
73
+
74
+ const top1 = scoresDesc[0];
75
+
76
+ // Floor: clamp top1 into [0, 1]. AWM composite scores already lie in this
77
+ // range under normal use, but be defensive.
78
+ const floor = Math.max(0, Math.min(1, top1));
79
+
80
+ // Sharpness: top1 / mean(top-5). Skip if only 1 result (no peers).
81
+ let sharpness = 0;
82
+ if (scoresDesc.length >= 2) {
83
+ const window = scoresDesc.slice(0, Math.min(5, scoresDesc.length));
84
+ const mean = window.reduce((s, v) => s + v, 0) / window.length;
85
+ if (mean > 0) {
86
+ const ratio = top1 / mean; // typically in [1, K]
87
+ sharpness = (ratio - 1) / (ratio + 1); // maps [1, ∞) → [0, 1)
88
+ }
89
+ }
90
+
91
+ // Cliff: how steep is the drop from top-1 to the K-th candidate?
92
+ // Use top-10 (or last available). If only 1 result, no cliff to measure.
93
+ let cliff = 0;
94
+ if (scoresDesc.length >= 2 && top1 > 0) {
95
+ const tail = scoresDesc[Math.min(9, scoresDesc.length - 1)];
96
+ cliff = Math.max(0, Math.min(1, (top1 - tail) / top1));
97
+ }
98
+
99
+ // Geometric blend — any near-zero component pulls confidence down.
100
+ // Add a tiny epsilon so log/zero doesn't collapse the whole signal when
101
+ // a result is genuinely sharp but the cliff is computed off only 2-3
102
+ // candidates (cliff small even for confident recalls).
103
+ const EPS = 0.05;
104
+ const s = sharpness + EPS;
105
+ const c = cliff + EPS;
106
+ const f = floor + EPS;
107
+
108
+ // Weighted geometric mean: prod(x_i ^ w_i)
109
+ const logConf =
110
+ SHARPNESS_W * Math.log(s)
111
+ + CLIFF_W * Math.log(c)
112
+ + FLOOR_W * Math.log(f);
113
+ const totalW = SHARPNESS_W + CLIFF_W + FLOOR_W;
114
+ // Subtract epsilon contribution so the floor of confidence is ~0 when all
115
+ // signals are zero (rather than the value of EPS).
116
+ const rawConf = Math.exp(logConf / totalW) - EPS;
117
+ const confidence = Math.max(0, Math.min(1, rawConf));
118
+
119
+ return { confidence, sharpness, cliff, floor };
120
+ }
@@ -1,162 +1,162 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Connection Engine — discovers links between memories.
5
- *
6
- * **Lifecycle (v0.8.2):** `enqueue()` just appends to an in-memory queue and
7
- * returns. The queue is drained by `processQueue()`, which is called from
8
- * the consolidation cycle. Per-write inline drain was removed because each
9
- * `findConnections` call runs a full activation cycle (embed + BM25 + vector
10
- * + rerank) — ~200-500 ms of event-loop blocking per write, queued ahead
11
- * of subsequent requests under load.
12
- *
13
- * Cold-start exception: when the agent has fewer than
14
- * `AWM_CONNECTION_COLD_START_THRESHOLD` (default 10) active engrams, callers
15
- * can opt into inline drain via `enqueueAndMaybeFlush()` so the first few
16
- * writes still produce a useful association graph before the next
17
- * consolidation cycle fires. Once the pool grows past the threshold, all
18
- * discovery defers to consolidation regardless.
19
- *
20
- * Footprint: when AWM is idle and no consolidation is running, the queue
21
- * is a plain `string[]` — no timers, no background work, ~24 bytes per
22
- * queued ID. AWM remains cheap to NOT use.
23
- */
24
-
25
- import type { IEngramStore as EngramStore } from '../storage/store.js';
26
- import type { ActivationEngine } from './activation.js';
27
- import type { Engram } from '../types/index.js';
28
-
29
- const COLD_START_THRESHOLD = Number(process.env.AWM_CONNECTION_COLD_START_THRESHOLD ?? 10);
30
-
31
- export class ConnectionEngine {
32
- private store: EngramStore;
33
- private engine: ActivationEngine;
34
- private threshold: number;
35
- private queue: string[] = [];
36
- private processing = false;
37
-
38
- constructor(
39
- store: EngramStore,
40
- engine: ActivationEngine,
41
- threshold: number = 0.7
42
- ) {
43
- this.store = store;
44
- this.engine = engine;
45
- this.threshold = threshold;
46
- }
47
-
48
- /**
49
- * Queue a newly written engram for connection discovery.
50
- *
51
- * Synchronous and non-triggering. The queue is drained later by:
52
- * - `processQueue()` called from the consolidation cycle, or
53
- * - `enqueueAndMaybeFlush()` for cold-start inline drain.
54
- */
55
- enqueue(engramId: string): void {
56
- this.queue.push(engramId);
57
- }
58
-
59
- /**
60
- * Queue + opportunistic inline drain for cold-start agents.
61
- *
62
- * If the agent has fewer than `AWM_CONNECTION_COLD_START_THRESHOLD`
63
- * active engrams (default 10), drain the queue inline so the first few
64
- * writes produce a useful association graph before consolidation runs.
65
- * Once the pool grows past the threshold, this falls back to deferred
66
- * (consolidation-driven) drain.
67
- *
68
- * Returns immediately — the inline drain runs as a fire-and-forget
69
- * background task so the calling write doesn't block on it.
70
- */
71
- enqueueAndMaybeFlush(engramId: string, agentId: string): void {
72
- this.queue.push(engramId);
73
- if (this.processing) return;
74
- void this.maybeDrainColdStart(agentId);
75
- }
76
-
77
- private async maybeDrainColdStart(agentId: string): Promise<void> {
78
- try {
79
- const count = await this.store.getActiveCount(agentId);
80
- if (count < COLD_START_THRESHOLD) {
81
- await this.processQueue();
82
- }
83
- } catch {
84
- // Cold-start drain is best-effort. The next consolidation cycle
85
- // will drain whatever stayed queued.
86
- }
87
- }
88
-
89
- /**
90
- * Drain the queue: run connection discovery for every queued engram.
91
- *
92
- * Called from the consolidation cycle (`ConsolidationEngine.consolidate`)
93
- * at the start of each run, and from `enqueueAndMaybeFlush()` for
94
- * cold-start agents. Reentrant-safe via the `processing` flag.
95
- *
96
- * Exposed publicly so callers (consolidation, tests) can explicitly drain.
97
- */
98
- async processQueue(): Promise<void> {
99
- if (this.processing) return; // Reentrancy guard
100
- this.processing = true;
101
- try {
102
- while (this.queue.length > 0) {
103
- const engramId = this.queue.shift()!;
104
- const engram = await this.store.getEngram(engramId);
105
- if (!engram || engram.stage !== 'active') continue;
106
-
107
- try {
108
- await this.findConnections(engram);
109
- } catch {
110
- // Connection discovery is best-effort — don't crash the server
111
- }
112
- }
113
- } finally {
114
- this.processing = false;
115
- }
116
- }
117
-
118
- /** Number of engrams currently queued for connection discovery. */
119
- queueSize(): number {
120
- return this.queue.length;
121
- }
122
-
123
- /**
124
- * Find and create connections for a given engram.
125
- */
126
- private async findConnections(engram: Engram): Promise<void> {
127
- const results = await this.engine.activate({
128
- agentId: engram.agentId,
129
- context: `${engram.concept} ${engram.content}`,
130
- limit: 5,
131
- minScore: this.threshold,
132
- internal: true,
133
- });
134
-
135
- // Filter out self and already-connected engrams
136
- const existing = await this.store.getAssociationsFor(engram.id);
137
- const existingIds = new Set(existing.map(a =>
138
- a.fromEngramId === engram.id ? a.toEngramId : a.fromEngramId
139
- ));
140
-
141
- for (const result of results) {
142
- if (result.engram.id === engram.id) continue;
143
- if (existingIds.has(result.engram.id)) continue;
144
-
145
- // Create a connection association
146
- await this.store.upsertAssociation(
147
- engram.id,
148
- result.engram.id,
149
- result.score,
150
- 'connection'
151
- );
152
-
153
- // Bidirectional
154
- await this.store.upsertAssociation(
155
- result.engram.id,
156
- engram.id,
157
- result.score,
158
- 'connection'
159
- );
160
- }
161
- }
162
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Connection Engine — discovers links between memories.
5
+ *
6
+ * **Lifecycle (v0.8.2):** `enqueue()` just appends to an in-memory queue and
7
+ * returns. The queue is drained by `processQueue()`, which is called from
8
+ * the consolidation cycle. Per-write inline drain was removed because each
9
+ * `findConnections` call runs a full activation cycle (embed + BM25 + vector
10
+ * + rerank) — ~200-500 ms of event-loop blocking per write, queued ahead
11
+ * of subsequent requests under load.
12
+ *
13
+ * Cold-start exception: when the agent has fewer than
14
+ * `AWM_CONNECTION_COLD_START_THRESHOLD` (default 10) active engrams, callers
15
+ * can opt into inline drain via `enqueueAndMaybeFlush()` so the first few
16
+ * writes still produce a useful association graph before the next
17
+ * consolidation cycle fires. Once the pool grows past the threshold, all
18
+ * discovery defers to consolidation regardless.
19
+ *
20
+ * Footprint: when AWM is idle and no consolidation is running, the queue
21
+ * is a plain `string[]` — no timers, no background work, ~24 bytes per
22
+ * queued ID. AWM remains cheap to NOT use.
23
+ */
24
+
25
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
26
+ import type { ActivationEngine } from './activation.js';
27
+ import type { Engram } from '../types/index.js';
28
+
29
+ const COLD_START_THRESHOLD = Number(process.env.AWM_CONNECTION_COLD_START_THRESHOLD ?? 10);
30
+
31
+ export class ConnectionEngine {
32
+ private store: EngramStore;
33
+ private engine: ActivationEngine;
34
+ private threshold: number;
35
+ private queue: string[] = [];
36
+ private processing = false;
37
+
38
+ constructor(
39
+ store: EngramStore,
40
+ engine: ActivationEngine,
41
+ threshold: number = 0.7
42
+ ) {
43
+ this.store = store;
44
+ this.engine = engine;
45
+ this.threshold = threshold;
46
+ }
47
+
48
+ /**
49
+ * Queue a newly written engram for connection discovery.
50
+ *
51
+ * Synchronous and non-triggering. The queue is drained later by:
52
+ * - `processQueue()` called from the consolidation cycle, or
53
+ * - `enqueueAndMaybeFlush()` for cold-start inline drain.
54
+ */
55
+ enqueue(engramId: string): void {
56
+ this.queue.push(engramId);
57
+ }
58
+
59
+ /**
60
+ * Queue + opportunistic inline drain for cold-start agents.
61
+ *
62
+ * If the agent has fewer than `AWM_CONNECTION_COLD_START_THRESHOLD`
63
+ * active engrams (default 10), drain the queue inline so the first few
64
+ * writes produce a useful association graph before consolidation runs.
65
+ * Once the pool grows past the threshold, this falls back to deferred
66
+ * (consolidation-driven) drain.
67
+ *
68
+ * Returns immediately — the inline drain runs as a fire-and-forget
69
+ * background task so the calling write doesn't block on it.
70
+ */
71
+ enqueueAndMaybeFlush(engramId: string, agentId: string): void {
72
+ this.queue.push(engramId);
73
+ if (this.processing) return;
74
+ void this.maybeDrainColdStart(agentId);
75
+ }
76
+
77
+ private async maybeDrainColdStart(agentId: string): Promise<void> {
78
+ try {
79
+ const count = await this.store.getActiveCount(agentId);
80
+ if (count < COLD_START_THRESHOLD) {
81
+ await this.processQueue();
82
+ }
83
+ } catch {
84
+ // Cold-start drain is best-effort. The next consolidation cycle
85
+ // will drain whatever stayed queued.
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Drain the queue: run connection discovery for every queued engram.
91
+ *
92
+ * Called from the consolidation cycle (`ConsolidationEngine.consolidate`)
93
+ * at the start of each run, and from `enqueueAndMaybeFlush()` for
94
+ * cold-start agents. Reentrant-safe via the `processing` flag.
95
+ *
96
+ * Exposed publicly so callers (consolidation, tests) can explicitly drain.
97
+ */
98
+ async processQueue(): Promise<void> {
99
+ if (this.processing) return; // Reentrancy guard
100
+ this.processing = true;
101
+ try {
102
+ while (this.queue.length > 0) {
103
+ const engramId = this.queue.shift()!;
104
+ const engram = await this.store.getEngram(engramId);
105
+ if (!engram || engram.stage !== 'active') continue;
106
+
107
+ try {
108
+ await this.findConnections(engram);
109
+ } catch {
110
+ // Connection discovery is best-effort — don't crash the server
111
+ }
112
+ }
113
+ } finally {
114
+ this.processing = false;
115
+ }
116
+ }
117
+
118
+ /** Number of engrams currently queued for connection discovery. */
119
+ queueSize(): number {
120
+ return this.queue.length;
121
+ }
122
+
123
+ /**
124
+ * Find and create connections for a given engram.
125
+ */
126
+ private async findConnections(engram: Engram): Promise<void> {
127
+ const results = await this.engine.activate({
128
+ agentId: engram.agentId,
129
+ context: `${engram.concept} ${engram.content}`,
130
+ limit: 5,
131
+ minScore: this.threshold,
132
+ internal: true,
133
+ });
134
+
135
+ // Filter out self and already-connected engrams
136
+ const existing = await this.store.getAssociationsFor(engram.id);
137
+ const existingIds = new Set(existing.map(a =>
138
+ a.fromEngramId === engram.id ? a.toEngramId : a.fromEngramId
139
+ ));
140
+
141
+ for (const result of results) {
142
+ if (result.engram.id === engram.id) continue;
143
+ if (existingIds.has(result.engram.id)) continue;
144
+
145
+ // Create a connection association
146
+ await this.store.upsertAssociation(
147
+ engram.id,
148
+ result.engram.id,
149
+ result.score,
150
+ 'connection'
151
+ );
152
+
153
+ // Bidirectional
154
+ await this.store.upsertAssociation(
155
+ result.engram.id,
156
+ engram.id,
157
+ result.score,
158
+ 'connection'
159
+ );
160
+ }
161
+ }
162
+ }