@cleocode/core 2026.4.50 → 2026.4.52
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/dist/index.js +511 -41
- package/dist/index.js.map +4 -4
- package/dist/internal.d.ts +4 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +669 -45
- package/dist/internal.js.map +4 -4
- package/dist/memory/brain-export.d.ts +70 -0
- package/dist/memory/brain-export.d.ts.map +1 -0
- package/dist/memory/brain-lifecycle.d.ts +7 -0
- package/dist/memory/brain-lifecycle.d.ts.map +1 -1
- package/dist/memory/brain-retrieval.d.ts.map +1 -1
- package/dist/memory/brain-stdp.d.ts +122 -0
- package/dist/memory/brain-stdp.d.ts.map +1 -0
- package/dist/memory/decision-cross-link.d.ts +70 -0
- package/dist/memory/decision-cross-link.d.ts.map +1 -0
- package/dist/memory/decisions.d.ts.map +1 -1
- package/dist/memory/edge-types.d.ts +24 -0
- package/dist/memory/edge-types.d.ts.map +1 -0
- package/dist/memory/index.d.ts +1 -0
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/store/brain-schema.d.ts +150 -3
- package/dist/store/brain-schema.d.ts.map +1 -1
- package/dist/store/brain-sqlite.d.ts.map +1 -1
- package/dist/store/validation-schemas.d.ts +1 -0
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/dist/validation/verification.d.ts.map +1 -1
- package/migrations/drizzle-brain/20260415000001_t626-normalize-co-retrieved-edge-type/migration.sql +14 -0
- package/package.json +8 -8
- package/src/internal.ts +14 -0
- package/src/memory/__tests__/brain-stdp.test.ts +452 -0
- package/src/memory/__tests__/decision-cross-link.test.ts +240 -0
- package/src/memory/brain-embedding.ts +1 -1
- package/src/memory/brain-export.ts +286 -0
- package/src/memory/brain-lifecycle.ts +23 -4
- package/src/memory/brain-retrieval.ts +80 -14
- package/src/memory/brain-similarity.ts +1 -1
- package/src/memory/brain-stdp.ts +448 -0
- package/src/memory/claude-mem-migration.ts +1 -1
- package/src/memory/decision-cross-link.ts +276 -0
- package/src/memory/decisions.ts +7 -0
- package/src/memory/edge-types.ts +31 -0
- package/src/memory/index.ts +2 -0
- package/src/sessions/briefing.ts +1 -1
- package/src/skills/dispatch.ts +1 -1
- package/src/skills/injection/subagent.ts +1 -1
- package/src/skills/orchestrator/spawn.ts +1 -1
- package/src/store/brain-schema.ts +54 -0
- package/src/store/brain-sqlite.ts +17 -0
- package/src/store/json.ts +2 -2
- package/src/system/archive-analytics.ts +1 -1
- package/src/tasks/task-ops.ts +2 -2
- package/src/validation/verification.ts +2 -6
|
@@ -259,9 +259,18 @@ export async function searchBrainCompact(
|
|
|
259
259
|
const returnedIds = results.map((r) => r.id);
|
|
260
260
|
setImmediate(() => {
|
|
261
261
|
incrementCitationCounts(projectRoot, returnedIds).catch(() => {});
|
|
262
|
-
|
|
263
|
-
() => {
|
|
264
|
-
|
|
262
|
+
getCurrentSessionId(projectRoot)
|
|
263
|
+
.then((sessionId) => {
|
|
264
|
+
return logRetrieval(
|
|
265
|
+
projectRoot,
|
|
266
|
+
query,
|
|
267
|
+
returnedIds,
|
|
268
|
+
'find-rrf',
|
|
269
|
+
results.length * 50,
|
|
270
|
+
sessionId,
|
|
271
|
+
);
|
|
272
|
+
})
|
|
273
|
+
.catch(() => {});
|
|
265
274
|
});
|
|
266
275
|
}
|
|
267
276
|
|
|
@@ -345,7 +354,18 @@ export async function searchBrainCompact(
|
|
|
345
354
|
const returnedIds = results.map((r) => r.id);
|
|
346
355
|
setImmediate(() => {
|
|
347
356
|
incrementCitationCounts(projectRoot, returnedIds).catch(() => {});
|
|
348
|
-
|
|
357
|
+
getCurrentSessionId(projectRoot)
|
|
358
|
+
.then((sessionId) => {
|
|
359
|
+
return logRetrieval(
|
|
360
|
+
projectRoot,
|
|
361
|
+
query,
|
|
362
|
+
returnedIds,
|
|
363
|
+
'find',
|
|
364
|
+
results.length * 50,
|
|
365
|
+
sessionId,
|
|
366
|
+
);
|
|
367
|
+
})
|
|
368
|
+
.catch(() => {});
|
|
349
369
|
});
|
|
350
370
|
}
|
|
351
371
|
|
|
@@ -604,13 +624,18 @@ export async function fetchBrainEntries(
|
|
|
604
624
|
const fetchedIds = results.map((r) => r.id);
|
|
605
625
|
setImmediate(() => {
|
|
606
626
|
incrementCitationCounts(projectRoot, fetchedIds).catch(() => {});
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
627
|
+
getCurrentSessionId(projectRoot)
|
|
628
|
+
.then((sessionId) => {
|
|
629
|
+
return logRetrieval(
|
|
630
|
+
projectRoot,
|
|
631
|
+
fetchedIds.join(','),
|
|
632
|
+
fetchedIds,
|
|
633
|
+
'fetch',
|
|
634
|
+
results.length * 500,
|
|
635
|
+
sessionId,
|
|
636
|
+
);
|
|
637
|
+
})
|
|
638
|
+
.catch(() => {});
|
|
614
639
|
});
|
|
615
640
|
}
|
|
616
641
|
|
|
@@ -1364,6 +1389,31 @@ export async function retrieveWithBudget(
|
|
|
1364
1389
|
};
|
|
1365
1390
|
}
|
|
1366
1391
|
|
|
1392
|
+
// ============================================================================
|
|
1393
|
+
// Session ID Retrieval (for logRetrieval)
|
|
1394
|
+
// ============================================================================
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Get the current session ID from the session manager.
|
|
1398
|
+
*
|
|
1399
|
+
* This is a best-effort operation — if no session is active or session
|
|
1400
|
+
* manager is unavailable, returns null. Used by logRetrieval to group
|
|
1401
|
+
* retrievals by session for STDP analysis.
|
|
1402
|
+
*
|
|
1403
|
+
* @param projectRoot - Project root directory
|
|
1404
|
+
* @returns Current session ID or null if unavailable
|
|
1405
|
+
*/
|
|
1406
|
+
async function getCurrentSessionId(projectRoot: string): Promise<string | undefined> {
|
|
1407
|
+
try {
|
|
1408
|
+
const { sessionStatus } = await import('../sessions/index.js');
|
|
1409
|
+
const session = await sessionStatus(projectRoot);
|
|
1410
|
+
return session?.id;
|
|
1411
|
+
} catch {
|
|
1412
|
+
// Session manager unavailable or other error — log retrievals without session
|
|
1413
|
+
return undefined;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1367
1417
|
// ============================================================================
|
|
1368
1418
|
// Citation Count Increment (non-blocking helper)
|
|
1369
1419
|
// ============================================================================
|
|
@@ -1416,6 +1466,13 @@ async function incrementCitationCounts(projectRoot: string, ids: string[]): Prom
|
|
|
1416
1466
|
*
|
|
1417
1467
|
* Creates the table on first use if it doesn't exist (self-healing).
|
|
1418
1468
|
* Best-effort: errors are silently swallowed.
|
|
1469
|
+
*
|
|
1470
|
+
* @param projectRoot - Project root directory
|
|
1471
|
+
* @param query - The search query or fetch IDs
|
|
1472
|
+
* @param entryIds - Array of entry IDs returned in this retrieval
|
|
1473
|
+
* @param source - Retrieval source ('find', 'fetch', 'hybrid', 'timeline', 'budget')
|
|
1474
|
+
* @param tokensUsed - Estimated tokens consumed (optional)
|
|
1475
|
+
* @param sessionId - Session ID for grouping retrievals by session (optional, soft FK to tasks.db)
|
|
1419
1476
|
*/
|
|
1420
1477
|
async function logRetrieval(
|
|
1421
1478
|
projectRoot: string,
|
|
@@ -1423,6 +1480,7 @@ async function logRetrieval(
|
|
|
1423
1480
|
entryIds: string[],
|
|
1424
1481
|
source: string,
|
|
1425
1482
|
tokensUsed?: number,
|
|
1483
|
+
sessionId?: string,
|
|
1426
1484
|
): Promise<void> {
|
|
1427
1485
|
if (entryIds.length === 0) return;
|
|
1428
1486
|
|
|
@@ -1431,7 +1489,7 @@ async function logRetrieval(
|
|
|
1431
1489
|
const nativeDb = getBrainNativeDb();
|
|
1432
1490
|
if (!nativeDb) return;
|
|
1433
1491
|
|
|
1434
|
-
// Self-healing: create table if not exists
|
|
1492
|
+
// Self-healing: create table if not exists (includes session_id column)
|
|
1435
1493
|
const createSql =
|
|
1436
1494
|
'CREATE TABLE IF NOT EXISTS brain_retrieval_log (' +
|
|
1437
1495
|
'id INTEGER PRIMARY KEY AUTOINCREMENT,' +
|
|
@@ -1440,6 +1498,7 @@ async function logRetrieval(
|
|
|
1440
1498
|
'entry_count INTEGER NOT NULL,' +
|
|
1441
1499
|
'source TEXT NOT NULL,' +
|
|
1442
1500
|
'tokens_used INTEGER,' +
|
|
1501
|
+
'session_id TEXT,' +
|
|
1443
1502
|
"created_at TEXT NOT NULL DEFAULT (datetime('now'))" +
|
|
1444
1503
|
')';
|
|
1445
1504
|
try {
|
|
@@ -1451,9 +1510,16 @@ async function logRetrieval(
|
|
|
1451
1510
|
try {
|
|
1452
1511
|
nativeDb
|
|
1453
1512
|
.prepare(
|
|
1454
|
-
'INSERT INTO brain_retrieval_log (query, entry_ids, entry_count, source, tokens_used) VALUES (?, ?, ?, ?, ?)',
|
|
1513
|
+
'INSERT INTO brain_retrieval_log (query, entry_ids, entry_count, source, tokens_used, session_id) VALUES (?, ?, ?, ?, ?, ?)',
|
|
1455
1514
|
)
|
|
1456
|
-
.run(
|
|
1515
|
+
.run(
|
|
1516
|
+
query,
|
|
1517
|
+
entryIds.join(','),
|
|
1518
|
+
entryIds.length,
|
|
1519
|
+
source,
|
|
1520
|
+
tokensUsed ?? null,
|
|
1521
|
+
sessionId ?? null,
|
|
1522
|
+
);
|
|
1457
1523
|
} catch {
|
|
1458
1524
|
/* best-effort */
|
|
1459
1525
|
}
|
|
@@ -61,7 +61,7 @@ export async function searchSimilar(
|
|
|
61
61
|
projectRoot: string,
|
|
62
62
|
limit?: number,
|
|
63
63
|
): Promise<SimilarityResult[]> {
|
|
64
|
-
if (!query
|
|
64
|
+
if (!query?.trim()) return [];
|
|
65
65
|
if (!isEmbeddingAvailable()) return [];
|
|
66
66
|
|
|
67
67
|
const maxResults = limit ?? 10;
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STDP (Spike-Timing-Dependent Plasticity) for CLEO BRAIN.
|
|
3
|
+
*
|
|
4
|
+
* Implements a biologically-inspired Hebbian learning rule that modulates edge
|
|
5
|
+
* weights based on the *temporal order* of memory retrievals within a session
|
|
6
|
+
* window, not just co-occurrence frequency.
|
|
7
|
+
*
|
|
8
|
+
* ## Neuroscience basis
|
|
9
|
+
*
|
|
10
|
+
* Classical STDP: if neuron A fires before neuron B by Δt milliseconds, the
|
|
11
|
+
* synapse A→B is potentiated (LTP); if A fires after B, it is depressed (LTD).
|
|
12
|
+
* The magnitude of the change decays exponentially with |Δt|.
|
|
13
|
+
*
|
|
14
|
+
* ## Mapping to CLEO memory
|
|
15
|
+
*
|
|
16
|
+
* - Each entry retrieved from brain.db is a "spike".
|
|
17
|
+
* - Retrievals within the same session and within `sessionWindowMs` of each
|
|
18
|
+
* other are treated as temporally related spikes.
|
|
19
|
+
* - If entry A was retrieved BEFORE entry B by Δt ms:
|
|
20
|
+
* Δw = A_pre × exp(−Δt / τ_pre) → potentiation (LTP, positive Δw)
|
|
21
|
+
* - If entry A was retrieved AFTER entry B by Δt ms:
|
|
22
|
+
* Δw = −A_post × exp(−Δt / τ_post) → depression (LTD, negative Δw)
|
|
23
|
+
* - Weights are clamped to [0.0, 1.0].
|
|
24
|
+
* - All events are logged to `brain_plasticity_events` for observability.
|
|
25
|
+
*
|
|
26
|
+
* ## Parameters (biologically reasonable defaults)
|
|
27
|
+
*
|
|
28
|
+
* | Symbol | Value | Meaning |
|
|
29
|
+
* |---------|--------|--------------------------------------------------|
|
|
30
|
+
* | τ_pre | 20 s | Time constant for pre→post potentiation |
|
|
31
|
+
* | τ_post | 20 s | Time constant for post→pre depression |
|
|
32
|
+
* | A_pre | 0.05 | Peak potentiation amplitude |
|
|
33
|
+
* | A_post | 0.06 | Peak depression amplitude (asymmetric, per STDP) |
|
|
34
|
+
*
|
|
35
|
+
* The asymmetry A_post > A_pre models the biological fact that LTD is slightly
|
|
36
|
+
* stronger than LTP, preventing runaway weight growth.
|
|
37
|
+
*
|
|
38
|
+
* ## Relation to Hebbian co-retrieval
|
|
39
|
+
*
|
|
40
|
+
* Hebbian (`strengthenCoRetrievedEdges` in brain-lifecycle.ts) uses the
|
|
41
|
+
* `co_retrieved` edge type and does NOT track order; it fires on pairs that
|
|
42
|
+
* co-occur ≥ 3× regardless of timing. STDP is a *second plasticity pass* that
|
|
43
|
+
* runs after Hebbian and refines existing `co_retrieved` edges using order data.
|
|
44
|
+
*
|
|
45
|
+
* If no `co_retrieved` edge exists yet between a pair, STDP inserts one with the
|
|
46
|
+
* initial STDP-derived weight (potentiation pairs only — LTD does not create
|
|
47
|
+
* new edges, only weakens existing ones).
|
|
48
|
+
*
|
|
49
|
+
* @task T626
|
|
50
|
+
* @epic T626
|
|
51
|
+
* @see packages/core/src/memory/brain-lifecycle.ts#strengthenCoRetrievedEdges
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { typedAll } from '../store/typed-query.js';
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// STDP constants
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/** Time constant (ms) for pre→post potentiation window. */
|
|
61
|
+
const TAU_PRE_MS = 20_000; // 20 s
|
|
62
|
+
|
|
63
|
+
/** Time constant (ms) for post→pre depression window. */
|
|
64
|
+
const TAU_POST_MS = 20_000; // 20 s
|
|
65
|
+
|
|
66
|
+
/** Peak potentiation amplitude (dimensionless weight delta). */
|
|
67
|
+
const A_PRE = 0.05;
|
|
68
|
+
|
|
69
|
+
/** Peak depression amplitude (slightly larger than A_pre — asymmetric STDP). */
|
|
70
|
+
const A_POST = 0.06;
|
|
71
|
+
|
|
72
|
+
/** Minimum edge weight (floor). */
|
|
73
|
+
const WEIGHT_MIN = 0.0;
|
|
74
|
+
|
|
75
|
+
/** Maximum edge weight (ceiling). */
|
|
76
|
+
const WEIGHT_MAX = 1.0;
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// Public types
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
/** Result returned by `applyStdpPlasticity`. */
|
|
83
|
+
export interface StdpPlasticityResult {
|
|
84
|
+
/** Number of LTP (potentiation) events applied. */
|
|
85
|
+
ltpEvents: number;
|
|
86
|
+
/** Number of LTD (depression) events applied. */
|
|
87
|
+
ltdEvents: number;
|
|
88
|
+
/** Number of new edges inserted (LTP on pairs without an existing edge). */
|
|
89
|
+
edgesCreated: number;
|
|
90
|
+
/** Number of retrieval pairs examined. */
|
|
91
|
+
pairsExamined: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Summary row from `getPlasticityStats`. */
|
|
95
|
+
export interface PlasticityStatsSummary {
|
|
96
|
+
/** Total number of plasticity events ever recorded. */
|
|
97
|
+
totalEvents: number;
|
|
98
|
+
/** Count of LTP events. */
|
|
99
|
+
ltpCount: number;
|
|
100
|
+
/** Count of LTD events. */
|
|
101
|
+
ltdCount: number;
|
|
102
|
+
/** Net weight delta summed across all events (positive = net strengthening). */
|
|
103
|
+
netDeltaW: number;
|
|
104
|
+
/** Most recent event timestamp (ISO 8601), or null if no events. */
|
|
105
|
+
lastEventAt: string | null;
|
|
106
|
+
/** Recent events (up to `limit`, newest first). */
|
|
107
|
+
recentEvents: RecentPlasticityEvent[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A single recent plasticity event for display. */
|
|
111
|
+
export interface RecentPlasticityEvent {
|
|
112
|
+
/** Auto-increment event ID. */
|
|
113
|
+
id: number;
|
|
114
|
+
/** Source node identifier. */
|
|
115
|
+
sourceNode: string;
|
|
116
|
+
/** Target node identifier. */
|
|
117
|
+
targetNode: string;
|
|
118
|
+
/** Signed weight delta. */
|
|
119
|
+
deltaW: number;
|
|
120
|
+
/** 'ltp' or 'ltd'. */
|
|
121
|
+
kind: 'ltp' | 'ltd';
|
|
122
|
+
/** ISO 8601 timestamp. */
|
|
123
|
+
timestamp: string;
|
|
124
|
+
/** Session ID, if available. */
|
|
125
|
+
sessionId: string | null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Internal types
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
/** A single row from brain_retrieval_log with STDP columns. */
|
|
133
|
+
interface RetrievalLogRow {
|
|
134
|
+
id: number;
|
|
135
|
+
entry_ids: string;
|
|
136
|
+
created_at: string;
|
|
137
|
+
retrieval_order: number | null;
|
|
138
|
+
delta_ms: number | null;
|
|
139
|
+
session_id?: string | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A spike: one entry ID retrieved at one timestamp, with ordering metadata. */
|
|
143
|
+
interface Spike {
|
|
144
|
+
entryId: string;
|
|
145
|
+
rowId: number;
|
|
146
|
+
retrievedAt: number; // epoch ms
|
|
147
|
+
order: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// Core STDP function
|
|
152
|
+
// ============================================================================
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Apply Spike-Timing-Dependent Plasticity to brain_page_edges.
|
|
156
|
+
*
|
|
157
|
+
* Reads `brain_retrieval_log` for rows within the past `sessionWindowMs`
|
|
158
|
+
* milliseconds, reconstructs the temporal spike sequence per session, and
|
|
159
|
+
* applies the STDP rule to every ordered pair within the window.
|
|
160
|
+
*
|
|
161
|
+
* All weight changes are logged to `brain_plasticity_events` for
|
|
162
|
+
* observability and `cleo brain plasticity stats` reporting.
|
|
163
|
+
*
|
|
164
|
+
* @param projectRoot - Project root directory for brain.db resolution
|
|
165
|
+
* @param sessionWindowMs - Time window (ms) to consider retrievals as
|
|
166
|
+
* temporally related. Defaults to 5 minutes.
|
|
167
|
+
* @returns Counts of LTP/LTD events applied and edges created/updated.
|
|
168
|
+
*/
|
|
169
|
+
export async function applyStdpPlasticity(
|
|
170
|
+
projectRoot: string,
|
|
171
|
+
sessionWindowMs = 5 * 60 * 1000,
|
|
172
|
+
): Promise<StdpPlasticityResult> {
|
|
173
|
+
const { getBrainDb, getBrainNativeDb } = await import('../store/brain-sqlite.js');
|
|
174
|
+
await getBrainDb(projectRoot);
|
|
175
|
+
const nativeDb = getBrainNativeDb();
|
|
176
|
+
|
|
177
|
+
const result: StdpPlasticityResult = {
|
|
178
|
+
ltpEvents: 0,
|
|
179
|
+
ltdEvents: 0,
|
|
180
|
+
edgesCreated: 0,
|
|
181
|
+
pairsExamined: 0,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (!nativeDb) return result;
|
|
185
|
+
|
|
186
|
+
// Guard: retrieval log must exist
|
|
187
|
+
try {
|
|
188
|
+
nativeDb.prepare('SELECT 1 FROM brain_retrieval_log LIMIT 1').get();
|
|
189
|
+
} catch {
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Guard: plasticity events table must exist
|
|
194
|
+
try {
|
|
195
|
+
nativeDb.prepare('SELECT 1 FROM brain_plasticity_events LIMIT 1').get();
|
|
196
|
+
} catch {
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
const cutoffMs = now - sessionWindowMs;
|
|
202
|
+
const cutoffIso = new Date(cutoffMs).toISOString().replace('T', ' ').slice(0, 19);
|
|
203
|
+
const nowIso = new Date(now).toISOString().replace('T', ' ').slice(0, 19);
|
|
204
|
+
|
|
205
|
+
// Fetch recent retrieval log rows including the STDP columns.
|
|
206
|
+
// We use all rows in the window regardless of whether retrieval_order is set —
|
|
207
|
+
// if it is null (legacy rows), we fall back to ordering by created_at.
|
|
208
|
+
let logRows: RetrievalLogRow[] = [];
|
|
209
|
+
try {
|
|
210
|
+
logRows = typedAll<RetrievalLogRow>(
|
|
211
|
+
nativeDb.prepare(
|
|
212
|
+
`SELECT id, entry_ids, created_at, retrieval_order, delta_ms
|
|
213
|
+
FROM brain_retrieval_log
|
|
214
|
+
WHERE created_at >= ?
|
|
215
|
+
ORDER BY created_at ASC, id ASC
|
|
216
|
+
LIMIT 2000`,
|
|
217
|
+
),
|
|
218
|
+
cutoffIso,
|
|
219
|
+
);
|
|
220
|
+
} catch {
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (logRows.length === 0) return result;
|
|
225
|
+
|
|
226
|
+
// Build an ordered spike sequence from the log rows.
|
|
227
|
+
// Each retrieval log row may contain multiple entry_ids (a batch retrieval).
|
|
228
|
+
// We expand them into individual spikes, preserving the retrieval timestamp.
|
|
229
|
+
const spikes: Spike[] = [];
|
|
230
|
+
let globalOrder = 0;
|
|
231
|
+
|
|
232
|
+
for (const row of logRows) {
|
|
233
|
+
let ids: string[];
|
|
234
|
+
try {
|
|
235
|
+
ids = JSON.parse(row.entry_ids) as string[];
|
|
236
|
+
} catch {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const rowTime = new Date(row.created_at.replace(' ', 'T') + 'Z').getTime();
|
|
241
|
+
|
|
242
|
+
for (const rawId of ids) {
|
|
243
|
+
const entryId = rawId.includes(':') ? rawId : `observation:${rawId}`;
|
|
244
|
+
spikes.push({
|
|
245
|
+
entryId,
|
|
246
|
+
rowId: row.id,
|
|
247
|
+
retrievedAt: rowTime,
|
|
248
|
+
order: row.retrieval_order ?? globalOrder,
|
|
249
|
+
});
|
|
250
|
+
globalOrder++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Sort spikes by (retrievedAt, order) to establish canonical temporal sequence.
|
|
255
|
+
spikes.sort((a, b) => a.retrievedAt - b.retrievedAt || a.order - b.order);
|
|
256
|
+
|
|
257
|
+
// For each ordered pair (i, j) where i < j (spike i before spike j),
|
|
258
|
+
// apply the STDP rule if Δt <= sessionWindowMs.
|
|
259
|
+
const prepareGetEdge = nativeDb.prepare(
|
|
260
|
+
`SELECT weight FROM brain_page_edges
|
|
261
|
+
WHERE from_id = ? AND to_id = ? AND edge_type = 'co_retrieved'`,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const prepareUpdateEdge = nativeDb.prepare(
|
|
265
|
+
`UPDATE brain_page_edges
|
|
266
|
+
SET weight = MAX(?, MIN(?, weight + ?))
|
|
267
|
+
WHERE from_id = ? AND to_id = ? AND edge_type = 'co_retrieved'`,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const prepareInsertEdge = nativeDb.prepare(
|
|
271
|
+
`INSERT OR IGNORE INTO brain_page_edges
|
|
272
|
+
(from_id, to_id, edge_type, weight, provenance, created_at)
|
|
273
|
+
VALUES (?, ?, 'co_retrieved', ?, 'plasticity:stdp-ltp', ?)`,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const prepareLogEvent = nativeDb.prepare(
|
|
277
|
+
`INSERT INTO brain_plasticity_events
|
|
278
|
+
(source_node, target_node, delta_w, kind, timestamp)
|
|
279
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
for (let i = 0; i < spikes.length; i++) {
|
|
283
|
+
const spikeA = spikes[i]!;
|
|
284
|
+
|
|
285
|
+
for (let j = i + 1; j < spikes.length; j++) {
|
|
286
|
+
const spikeB = spikes[j]!;
|
|
287
|
+
const deltaT = spikeB.retrievedAt - spikeA.retrievedAt; // ms, always >= 0
|
|
288
|
+
|
|
289
|
+
if (deltaT > sessionWindowMs) break; // spikes are sorted; further pairs exceed window
|
|
290
|
+
|
|
291
|
+
if (spikeA.entryId === spikeB.entryId) continue; // skip self-pairs
|
|
292
|
+
|
|
293
|
+
result.pairsExamined++;
|
|
294
|
+
|
|
295
|
+
// A fired before B → LTP on edge A→B
|
|
296
|
+
const deltaW = A_PRE * Math.exp(-deltaT / TAU_PRE_MS);
|
|
297
|
+
|
|
298
|
+
if (deltaW < 1e-6) continue; // negligible change — skip
|
|
299
|
+
|
|
300
|
+
// Check whether an existing co_retrieved edge A→B exists
|
|
301
|
+
const existingEdge = prepareGetEdge.get(spikeA.entryId, spikeB.entryId) as
|
|
302
|
+
| { weight: number }
|
|
303
|
+
| undefined;
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
if (existingEdge !== undefined) {
|
|
307
|
+
prepareUpdateEdge.run(WEIGHT_MIN, WEIGHT_MAX, deltaW, spikeA.entryId, spikeB.entryId);
|
|
308
|
+
} else {
|
|
309
|
+
// Insert new edge with initial LTP weight (capped at WEIGHT_MAX)
|
|
310
|
+
const initialWeight = Math.min(WEIGHT_MAX, deltaW);
|
|
311
|
+
prepareInsertEdge.run(spikeA.entryId, spikeB.entryId, initialWeight, nowIso);
|
|
312
|
+
result.edgesCreated++;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
prepareLogEvent.run(spikeA.entryId, spikeB.entryId, deltaW, 'ltp', nowIso);
|
|
316
|
+
result.ltpEvents++;
|
|
317
|
+
} catch {
|
|
318
|
+
/* best-effort */
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// B fired after A → LTD on reverse edge B→A (depression)
|
|
322
|
+
// LTD only weakens existing edges; it does not create new ones.
|
|
323
|
+
const deltaWNeg = -(A_POST * Math.exp(-deltaT / TAU_POST_MS));
|
|
324
|
+
|
|
325
|
+
const existingReverseEdge = prepareGetEdge.get(spikeB.entryId, spikeA.entryId) as
|
|
326
|
+
| { weight: number }
|
|
327
|
+
| undefined;
|
|
328
|
+
|
|
329
|
+
if (existingReverseEdge !== undefined && Math.abs(deltaWNeg) >= 1e-6) {
|
|
330
|
+
try {
|
|
331
|
+
prepareUpdateEdge.run(WEIGHT_MIN, WEIGHT_MAX, deltaWNeg, spikeB.entryId, spikeA.entryId);
|
|
332
|
+
prepareLogEvent.run(spikeB.entryId, spikeA.entryId, deltaWNeg, 'ltd', nowIso);
|
|
333
|
+
result.ltdEvents++;
|
|
334
|
+
} catch {
|
|
335
|
+
/* best-effort */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return result;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ============================================================================
|
|
345
|
+
// Plasticity stats query
|
|
346
|
+
// ============================================================================
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Retrieve a summary of recent STDP plasticity events from `brain_plasticity_events`.
|
|
350
|
+
*
|
|
351
|
+
* Used by `cleo brain plasticity stats`.
|
|
352
|
+
*
|
|
353
|
+
* @param projectRoot - Project root directory for brain.db resolution
|
|
354
|
+
* @param limit - Maximum number of recent events to include. Defaults to 20.
|
|
355
|
+
* @returns Aggregated plasticity statistics and the most recent events.
|
|
356
|
+
*/
|
|
357
|
+
export async function getPlasticityStats(
|
|
358
|
+
projectRoot: string,
|
|
359
|
+
limit = 20,
|
|
360
|
+
): Promise<PlasticityStatsSummary> {
|
|
361
|
+
const { getBrainDb, getBrainNativeDb } = await import('../store/brain-sqlite.js');
|
|
362
|
+
await getBrainDb(projectRoot);
|
|
363
|
+
const nativeDb = getBrainNativeDb();
|
|
364
|
+
|
|
365
|
+
const empty: PlasticityStatsSummary = {
|
|
366
|
+
totalEvents: 0,
|
|
367
|
+
ltpCount: 0,
|
|
368
|
+
ltdCount: 0,
|
|
369
|
+
netDeltaW: 0,
|
|
370
|
+
lastEventAt: null,
|
|
371
|
+
recentEvents: [],
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
if (!nativeDb) return empty;
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
nativeDb.prepare('SELECT 1 FROM brain_plasticity_events LIMIT 1').get();
|
|
378
|
+
} catch {
|
|
379
|
+
return empty;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
interface AggRow {
|
|
383
|
+
total: number;
|
|
384
|
+
ltp_count: number;
|
|
385
|
+
ltd_count: number;
|
|
386
|
+
net_delta_w: number;
|
|
387
|
+
last_event_at: string | null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let agg: AggRow | undefined;
|
|
391
|
+
try {
|
|
392
|
+
agg = nativeDb
|
|
393
|
+
.prepare(
|
|
394
|
+
`SELECT
|
|
395
|
+
COUNT(*) AS total,
|
|
396
|
+
SUM(CASE WHEN kind = 'ltp' THEN 1 ELSE 0 END) AS ltp_count,
|
|
397
|
+
SUM(CASE WHEN kind = 'ltd' THEN 1 ELSE 0 END) AS ltd_count,
|
|
398
|
+
SUM(delta_w) AS net_delta_w,
|
|
399
|
+
MAX(timestamp) AS last_event_at
|
|
400
|
+
FROM brain_plasticity_events`,
|
|
401
|
+
)
|
|
402
|
+
.get() as AggRow | undefined;
|
|
403
|
+
} catch {
|
|
404
|
+
return empty;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
interface EventRow {
|
|
408
|
+
id: number;
|
|
409
|
+
source_node: string;
|
|
410
|
+
target_node: string;
|
|
411
|
+
delta_w: number;
|
|
412
|
+
kind: string;
|
|
413
|
+
timestamp: string;
|
|
414
|
+
session_id: string | null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let recentRows: EventRow[] = [];
|
|
418
|
+
try {
|
|
419
|
+
recentRows = typedAll<EventRow>(
|
|
420
|
+
nativeDb.prepare(
|
|
421
|
+
`SELECT id, source_node, target_node, delta_w, kind, timestamp, session_id
|
|
422
|
+
FROM brain_plasticity_events
|
|
423
|
+
ORDER BY timestamp DESC, id DESC
|
|
424
|
+
LIMIT ?`,
|
|
425
|
+
),
|
|
426
|
+
limit,
|
|
427
|
+
);
|
|
428
|
+
} catch {
|
|
429
|
+
// non-fatal
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
totalEvents: agg?.total ?? 0,
|
|
434
|
+
ltpCount: agg?.ltp_count ?? 0,
|
|
435
|
+
ltdCount: agg?.ltd_count ?? 0,
|
|
436
|
+
netDeltaW: agg?.net_delta_w ?? 0,
|
|
437
|
+
lastEventAt: agg?.last_event_at ?? null,
|
|
438
|
+
recentEvents: recentRows.map((r) => ({
|
|
439
|
+
id: r.id,
|
|
440
|
+
sourceNode: r.source_node,
|
|
441
|
+
targetNode: r.target_node,
|
|
442
|
+
deltaW: r.delta_w,
|
|
443
|
+
kind: r.kind as 'ltp' | 'ltd',
|
|
444
|
+
timestamp: r.timestamp,
|
|
445
|
+
sessionId: r.session_id,
|
|
446
|
+
})),
|
|
447
|
+
};
|
|
448
|
+
}
|