@cleocode/core 2026.4.49 → 2026.4.51
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 +445 -9
- package/dist/index.js.map +4 -4
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +448 -9
- package/dist/internal.js.map +4 -4
- package/dist/memory/brain-lifecycle.d.ts +7 -0
- package/dist/memory/brain-lifecycle.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 +134 -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/migrations/drizzle-brain/20260415000001_t626-normalize-co-retrieved-edge-type/migration.sql +14 -0
- package/package.json +8 -8
- package/src/internal.ts +7 -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-lifecycle.ts +23 -4
- package/src/memory/brain-stdp.ts +448 -0
- 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/store/brain-schema.ts +50 -0
- package/src/store/brain-sqlite.ts +17 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for decision-cross-link module.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - extractReferencedSymbols: file-path detection, symbol detection, dedup,
|
|
6
|
+
* stop-word filtering, short-name filtering.
|
|
7
|
+
* - linkDecisionToTargets: verifies edges are written to brain_page_edges
|
|
8
|
+
* when autoCapture is enabled.
|
|
9
|
+
* - autoCrossLinkDecision: end-to-end smoke test (never throws).
|
|
10
|
+
*
|
|
11
|
+
* @task T626
|
|
12
|
+
* @epic T626
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
let tempDir: string;
|
|
25
|
+
let cleoDir: string;
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
tempDir = await mkdtemp(join(tmpdir(), 'cleo-decision-cross-link-'));
|
|
29
|
+
cleoDir = join(tempDir, '.cleo');
|
|
30
|
+
await mkdir(cleoDir, { recursive: true });
|
|
31
|
+
process.env['CLEO_DIR'] = cleoDir;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
const { closeBrainDb } = await import('../../store/brain-sqlite.js');
|
|
36
|
+
closeBrainDb();
|
|
37
|
+
delete process.env['CLEO_DIR'];
|
|
38
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// extractReferencedSymbols
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe('extractReferencedSymbols', () => {
|
|
47
|
+
it('extracts a relative TypeScript file path', async () => {
|
|
48
|
+
const { extractReferencedSymbols } = await import('../decision-cross-link.js');
|
|
49
|
+
const refs = extractReferencedSymbols('Changed src/store/brain-schema.ts to add new column');
|
|
50
|
+
const fileRefs = refs.filter((r) => r.nodeType === 'file');
|
|
51
|
+
expect(fileRefs.length).toBeGreaterThanOrEqual(1);
|
|
52
|
+
expect(fileRefs.some((r) => r.raw === 'src/store/brain-schema.ts')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('extracts an absolute TypeScript file path', async () => {
|
|
56
|
+
const { extractReferencedSymbols } = await import('../decision-cross-link.js');
|
|
57
|
+
const refs = extractReferencedSymbols(
|
|
58
|
+
'See /mnt/projects/cleocode/packages/core/src/memory/decisions.ts for details',
|
|
59
|
+
);
|
|
60
|
+
const fileRefs = refs.filter((r) => r.nodeType === 'file');
|
|
61
|
+
expect(fileRefs.some((r) => r.raw.endsWith('decisions.ts'))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('extracts a PascalCase class reference', async () => {
|
|
65
|
+
const { extractReferencedSymbols } = await import('../decision-cross-link.js');
|
|
66
|
+
const refs = extractReferencedSymbols('BrainDataAccessor must expose the new query method');
|
|
67
|
+
const symRefs = refs.filter((r) => r.nodeType === 'symbol');
|
|
68
|
+
expect(symRefs.some((r) => r.raw === 'BrainDataAccessor')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('extracts a camelCase function reference', async () => {
|
|
72
|
+
const { extractReferencedSymbols } = await import('../decision-cross-link.js');
|
|
73
|
+
const refs = extractReferencedSymbols('Call upsertGraphNode before addGraphEdge');
|
|
74
|
+
const symRefs = refs.filter((r) => r.nodeType === 'symbol');
|
|
75
|
+
const names = symRefs.map((r) => r.raw);
|
|
76
|
+
expect(names).toContain('upsertGraphNode');
|
|
77
|
+
expect(names).toContain('addGraphEdge');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('deduplicates identical references', async () => {
|
|
81
|
+
const { extractReferencedSymbols } = await import('../decision-cross-link.js');
|
|
82
|
+
const refs = extractReferencedSymbols(
|
|
83
|
+
'upsertGraphNode is called twice: upsertGraphNode for nodes, upsertGraphNode for edges',
|
|
84
|
+
);
|
|
85
|
+
const symRefs = refs.filter((r) => r.raw === 'upsertGraphNode');
|
|
86
|
+
expect(symRefs).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('filters out stop-words', async () => {
|
|
90
|
+
const { extractReferencedSymbols } = await import('../decision-cross-link.js');
|
|
91
|
+
// "always", "should", "never" are in the stop-word list
|
|
92
|
+
const refs = extractReferencedSymbols('always should never with this that from into');
|
|
93
|
+
const symRefs = refs.filter((r) => r.nodeType === 'symbol');
|
|
94
|
+
const names = symRefs.map((r) => r.raw.toLowerCase());
|
|
95
|
+
expect(names).not.toContain('always');
|
|
96
|
+
expect(names).not.toContain('should');
|
|
97
|
+
expect(names).not.toContain('never');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('filters out symbols shorter than 4 characters', async () => {
|
|
101
|
+
const { extractReferencedSymbols } = await import('../decision-cross-link.js');
|
|
102
|
+
const refs = extractReferencedSymbols('Use DB to store it');
|
|
103
|
+
const symRefs = refs.filter((r) => r.nodeType === 'symbol' && r.raw.length < 4);
|
|
104
|
+
expect(symRefs).toHaveLength(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns empty array for plain-English text with no references', async () => {
|
|
108
|
+
const { extractReferencedSymbols } = await import('../decision-cross-link.js');
|
|
109
|
+
const refs = extractReferencedSymbols('We decided to use a relational database.');
|
|
110
|
+
// May have some false positives for symbols, but no file refs
|
|
111
|
+
const fileRefs = refs.filter((r) => r.nodeType === 'file');
|
|
112
|
+
expect(fileRefs).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('assigns correct nodeIds', async () => {
|
|
116
|
+
const { extractReferencedSymbols } = await import('../decision-cross-link.js');
|
|
117
|
+
const refs = extractReferencedSymbols(
|
|
118
|
+
'Updated src/memory/decisions.ts and called storeDecision',
|
|
119
|
+
);
|
|
120
|
+
const fileRef = refs.find((r) => r.nodeType === 'file');
|
|
121
|
+
const symRef = refs.find((r) => r.nodeType === 'symbol' && r.raw === 'storeDecision');
|
|
122
|
+
expect(fileRef?.nodeId).toMatch(/^file:/);
|
|
123
|
+
expect(symRef?.nodeId).toBe('symbol:storeDecision');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// linkDecisionToTargets — graph writes
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
describe('linkDecisionToTargets', () => {
|
|
132
|
+
it('creates applies_to edges in brain_page_edges when autoCapture is enabled', async () => {
|
|
133
|
+
// Stub shouldAutoPopulateGraph to return true by setting autoCapture config
|
|
134
|
+
// The easiest approach: write a minimal config.json so isAutoCaptureEnabled returns true
|
|
135
|
+
const { writeFile } = await import('node:fs/promises');
|
|
136
|
+
await writeFile(join(cleoDir, 'config.json'), JSON.stringify({ brain: { autoCapture: true } }));
|
|
137
|
+
|
|
138
|
+
const { closeBrainDb, getBrainDb } = await import('../../store/brain-sqlite.js');
|
|
139
|
+
closeBrainDb();
|
|
140
|
+
|
|
141
|
+
// Initialise DB
|
|
142
|
+
const db = await getBrainDb(tempDir);
|
|
143
|
+
|
|
144
|
+
const { extractReferencedSymbols, linkDecisionToTargets } = await import(
|
|
145
|
+
'../decision-cross-link.js'
|
|
146
|
+
);
|
|
147
|
+
const { brainPageEdges } = await import('../../store/brain-schema.js');
|
|
148
|
+
|
|
149
|
+
const refs = extractReferencedSymbols(
|
|
150
|
+
'Refactored src/store/brain-schema.ts to add BrainPageNodes column',
|
|
151
|
+
);
|
|
152
|
+
expect(refs.length).toBeGreaterThan(0);
|
|
153
|
+
|
|
154
|
+
await linkDecisionToTargets(tempDir, 'D001', refs);
|
|
155
|
+
|
|
156
|
+
// Check that at least one applies_to edge was created
|
|
157
|
+
const edges = await db.select().from(brainPageEdges);
|
|
158
|
+
const affectsEdges = edges.filter(
|
|
159
|
+
(e) => e.fromId === 'decision:D001' && e.edgeType === 'applies_to',
|
|
160
|
+
);
|
|
161
|
+
expect(affectsEdges.length).toBeGreaterThanOrEqual(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('is a no-op when refs array is empty', async () => {
|
|
165
|
+
const { writeFile } = await import('node:fs/promises');
|
|
166
|
+
await writeFile(join(cleoDir, 'config.json'), JSON.stringify({ brain: { autoCapture: true } }));
|
|
167
|
+
|
|
168
|
+
const { closeBrainDb, getBrainDb } = await import('../../store/brain-sqlite.js');
|
|
169
|
+
closeBrainDb();
|
|
170
|
+
const db = await getBrainDb(tempDir);
|
|
171
|
+
const { brainPageEdges } = await import('../../store/brain-schema.js');
|
|
172
|
+
|
|
173
|
+
const { linkDecisionToTargets } = await import('../decision-cross-link.js');
|
|
174
|
+
await linkDecisionToTargets(tempDir, 'D001', []);
|
|
175
|
+
|
|
176
|
+
const edges = await db.select().from(brainPageEdges);
|
|
177
|
+
expect(edges.filter((e) => e.fromId === 'decision:D001')).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('does not throw when autoCapture is disabled', async () => {
|
|
181
|
+
const { writeFile } = await import('node:fs/promises');
|
|
182
|
+
await writeFile(
|
|
183
|
+
join(cleoDir, 'config.json'),
|
|
184
|
+
JSON.stringify({ brain: { autoCapture: false } }),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const { closeBrainDb } = await import('../../store/brain-sqlite.js');
|
|
188
|
+
closeBrainDb();
|
|
189
|
+
|
|
190
|
+
const { extractReferencedSymbols, linkDecisionToTargets } = await import(
|
|
191
|
+
'../decision-cross-link.js'
|
|
192
|
+
);
|
|
193
|
+
const refs = extractReferencedSymbols('Changed src/store/brain-schema.ts');
|
|
194
|
+
// Should not throw even when autoCapture is off
|
|
195
|
+
await expect(linkDecisionToTargets(tempDir, 'D001', refs)).resolves.toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// autoCrossLinkDecision — convenience facade
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('autoCrossLinkDecision', () => {
|
|
204
|
+
it('never throws for any input', async () => {
|
|
205
|
+
const { autoCrossLinkDecision } = await import('../decision-cross-link.js');
|
|
206
|
+
|
|
207
|
+
// Should not throw even without a DB or config
|
|
208
|
+
await expect(
|
|
209
|
+
autoCrossLinkDecision(tempDir, 'D001', 'some decision text', 'some rationale'),
|
|
210
|
+
).resolves.toBeUndefined();
|
|
211
|
+
|
|
212
|
+
// Empty strings
|
|
213
|
+
await expect(autoCrossLinkDecision(tempDir, 'D001', '', '')).resolves.toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('creates edges when decision text mentions a known file path', async () => {
|
|
217
|
+
const { writeFile } = await import('node:fs/promises');
|
|
218
|
+
await writeFile(join(cleoDir, 'config.json'), JSON.stringify({ brain: { autoCapture: true } }));
|
|
219
|
+
|
|
220
|
+
const { closeBrainDb, getBrainDb } = await import('../../store/brain-sqlite.js');
|
|
221
|
+
closeBrainDb();
|
|
222
|
+
const db = await getBrainDb(tempDir);
|
|
223
|
+
const { brainPageEdges } = await import('../../store/brain-schema.js');
|
|
224
|
+
|
|
225
|
+
const { autoCrossLinkDecision } = await import('../decision-cross-link.js');
|
|
226
|
+
await autoCrossLinkDecision(
|
|
227
|
+
tempDir,
|
|
228
|
+
'D042',
|
|
229
|
+
'Use packages/core/src/memory/decisions.ts as the write gate',
|
|
230
|
+
'Centralises all decision writes in one module',
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const edges = await db.select().from(brainPageEdges);
|
|
234
|
+
const decisionsEdges = edges.filter(
|
|
235
|
+
(e) =>
|
|
236
|
+
e.fromId === 'decision:D042' && e.edgeType === 'applies_to' && e.toId.startsWith('file:'),
|
|
237
|
+
);
|
|
238
|
+
expect(decisionsEdges.length).toBeGreaterThanOrEqual(1);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -21,6 +21,7 @@ import { createHash } from 'node:crypto';
|
|
|
21
21
|
import { getBrainAccessor } from '../store/brain-accessor.js';
|
|
22
22
|
import { typedAll } from '../store/typed-query.js';
|
|
23
23
|
import type { BrainConsolidationObservationRow } from './brain-row-types.js';
|
|
24
|
+
import { EDGE_TYPES } from './edge-types.js';
|
|
24
25
|
|
|
25
26
|
/** Result from applying temporal decay. */
|
|
26
27
|
export interface DecayResult {
|
|
@@ -592,6 +593,13 @@ export interface RunConsolidationResult {
|
|
|
592
593
|
summariesGenerated: number;
|
|
593
594
|
/** Code↔memory graph links created. */
|
|
594
595
|
graphLinksCreated?: number;
|
|
596
|
+
/** STDP plasticity result from step 9. */
|
|
597
|
+
stdpPlasticity?: {
|
|
598
|
+
ltpEvents: number;
|
|
599
|
+
ltdEvents: number;
|
|
600
|
+
edgesCreated: number;
|
|
601
|
+
pairsExamined: number;
|
|
602
|
+
};
|
|
595
603
|
}
|
|
596
604
|
|
|
597
605
|
/**
|
|
@@ -695,6 +703,17 @@ export async function runConsolidation(projectRoot: string): Promise<RunConsolid
|
|
|
695
703
|
console.warn('[consolidation] Step 8 graph memory bridge failed:', err);
|
|
696
704
|
}
|
|
697
705
|
|
|
706
|
+
// Step 9: STDP timing-dependent plasticity (T626 phase 5)
|
|
707
|
+
// Refines co_retrieved edge weights using retrieval temporal order.
|
|
708
|
+
// Runs after Hebbian strengthening (step 6) so it builds on existing edges.
|
|
709
|
+
try {
|
|
710
|
+
const { applyStdpPlasticity } = await import('./brain-stdp.js');
|
|
711
|
+
const stdpResult = await applyStdpPlasticity(projectRoot);
|
|
712
|
+
result.stdpPlasticity = stdpResult;
|
|
713
|
+
} catch (err) {
|
|
714
|
+
console.warn('[consolidation] Step 9 STDP plasticity failed:', err);
|
|
715
|
+
}
|
|
716
|
+
|
|
698
717
|
return result;
|
|
699
718
|
}
|
|
700
719
|
|
|
@@ -979,9 +998,9 @@ async function strengthenCoRetrievedEdges(projectRoot: string): Promise<number>
|
|
|
979
998
|
const updateStmt = nativeDb.prepare(`
|
|
980
999
|
UPDATE brain_page_edges
|
|
981
1000
|
SET weight = MIN(1.0, weight + 0.1)
|
|
982
|
-
WHERE from_id = ? AND to_id = ? AND edge_type =
|
|
1001
|
+
WHERE from_id = ? AND to_id = ? AND edge_type = ?
|
|
983
1002
|
`);
|
|
984
|
-
const updateResult = updateStmt.run(nodeFrom, nodeTo);
|
|
1003
|
+
const updateResult = updateStmt.run(nodeFrom, nodeTo, EDGE_TYPES.CO_RETRIEVED);
|
|
985
1004
|
const changes = typeof updateResult.changes === 'number' ? updateResult.changes : 0;
|
|
986
1005
|
|
|
987
1006
|
if (changes === 0) {
|
|
@@ -990,9 +1009,9 @@ async function strengthenCoRetrievedEdges(projectRoot: string): Promise<number>
|
|
|
990
1009
|
.prepare(`
|
|
991
1010
|
INSERT OR IGNORE INTO brain_page_edges
|
|
992
1011
|
(from_id, to_id, edge_type, weight, provenance, created_at)
|
|
993
|
-
VALUES (?, ?,
|
|
1012
|
+
VALUES (?, ?, ?, 0.3, 'consolidation:co-retrieval', ?)
|
|
994
1013
|
`)
|
|
995
|
-
.run(nodeFrom, nodeTo, now);
|
|
1014
|
+
.run(nodeFrom, nodeTo, EDGE_TYPES.CO_RETRIEVED, now);
|
|
996
1015
|
}
|
|
997
1016
|
strengthened++;
|
|
998
1017
|
} catch {
|