@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
|
@@ -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
|
+
});
|
|
@@ -56,7 +56,7 @@ export function clearEmbeddingProvider(): void {
|
|
|
56
56
|
* Returns null when no provider is set or not available (FTS5-only fallback).
|
|
57
57
|
*/
|
|
58
58
|
export async function embedText(text: string): Promise<Float32Array | null> {
|
|
59
|
-
if (!currentProvider
|
|
59
|
+
if (!currentProvider?.isAvailable()) return null;
|
|
60
60
|
return currentProvider.embed(text);
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain graph export functionality — GEXF (Gephi standard) and JSON formats.
|
|
3
|
+
*
|
|
4
|
+
* Exports brain_page_nodes and brain_page_edges as:
|
|
5
|
+
* - GEXF XML: Gephi-compatible graph interchange format with attributes
|
|
6
|
+
* - JSON: Flat array representation for tooling integration
|
|
7
|
+
*
|
|
8
|
+
* @task T626-M6
|
|
9
|
+
* @epic T626
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { BrainPageEdgeRow, BrainPageNodeRow } from '../store/brain-schema.js';
|
|
13
|
+
import * as brainSchema from '../store/brain-schema.js';
|
|
14
|
+
import { getBrainDb } from '../store/brain-sqlite.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GEXF export result with XML content.
|
|
18
|
+
*/
|
|
19
|
+
export interface BrainExportGexfResult {
|
|
20
|
+
success: boolean;
|
|
21
|
+
format: 'gexf';
|
|
22
|
+
nodeCount: number;
|
|
23
|
+
edgeCount: number;
|
|
24
|
+
content: string;
|
|
25
|
+
generatedAt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* JSON export result with nodes and edges arrays.
|
|
30
|
+
*/
|
|
31
|
+
export interface BrainExportJsonResult {
|
|
32
|
+
success: boolean;
|
|
33
|
+
format: 'json';
|
|
34
|
+
nodeCount: number;
|
|
35
|
+
edgeCount: number;
|
|
36
|
+
nodes: BrainPageNodeRow[];
|
|
37
|
+
edges: BrainPageEdgeRow[];
|
|
38
|
+
generatedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type BrainExportResult = BrainExportGexfResult | BrainExportJsonResult;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Export brain graph as GEXF XML (Gephi standard format).
|
|
45
|
+
*
|
|
46
|
+
* Generates a valid GEXF 1.3 document with:
|
|
47
|
+
* - Node elements with attributes (type, quality, label)
|
|
48
|
+
* - Edge elements with weight and provenance
|
|
49
|
+
* - Static, directed graph
|
|
50
|
+
*
|
|
51
|
+
* @param projectRoot - Root directory of the CLEO project
|
|
52
|
+
* @returns GEXF XML export result
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* const result = await exportBrainAsGexf('/path/to/project');
|
|
57
|
+
* console.log(result.content); // Valid XML for import into Gephi
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export async function exportBrainAsGexf(projectRoot: string): Promise<BrainExportGexfResult> {
|
|
61
|
+
const db = await getBrainDb(projectRoot);
|
|
62
|
+
|
|
63
|
+
// Fetch all nodes and edges using basic select without provenance column
|
|
64
|
+
// This handles older schemas gracefully
|
|
65
|
+
let nodes: BrainPageNodeRow[] = [];
|
|
66
|
+
let edges: BrainPageEdgeRow[] = [];
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
nodes = await db
|
|
70
|
+
.select({
|
|
71
|
+
id: brainSchema.brainPageNodes.id,
|
|
72
|
+
nodeType: brainSchema.brainPageNodes.nodeType,
|
|
73
|
+
label: brainSchema.brainPageNodes.label,
|
|
74
|
+
qualityScore: brainSchema.brainPageNodes.qualityScore,
|
|
75
|
+
contentHash: brainSchema.brainPageNodes.contentHash,
|
|
76
|
+
lastActivityAt: brainSchema.brainPageNodes.lastActivityAt,
|
|
77
|
+
metadataJson: brainSchema.brainPageNodes.metadataJson,
|
|
78
|
+
createdAt: brainSchema.brainPageNodes.createdAt,
|
|
79
|
+
updatedAt: brainSchema.brainPageNodes.updatedAt,
|
|
80
|
+
})
|
|
81
|
+
.from(brainSchema.brainPageNodes);
|
|
82
|
+
} catch {
|
|
83
|
+
// If the graph nodes table doesn't exist, default to empty
|
|
84
|
+
nodes = [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Select edges without the provenance column to handle older schemas
|
|
89
|
+
const rawEdges = await db
|
|
90
|
+
.select({
|
|
91
|
+
fromId: brainSchema.brainPageEdges.fromId,
|
|
92
|
+
toId: brainSchema.brainPageEdges.toId,
|
|
93
|
+
edgeType: brainSchema.brainPageEdges.edgeType,
|
|
94
|
+
weight: brainSchema.brainPageEdges.weight,
|
|
95
|
+
createdAt: brainSchema.brainPageEdges.createdAt,
|
|
96
|
+
})
|
|
97
|
+
.from(brainSchema.brainPageEdges);
|
|
98
|
+
// Add provenance as null for missing rows and cast to the full row type
|
|
99
|
+
edges = rawEdges.map((e) => ({ ...e, provenance: null }) as BrainPageEdgeRow);
|
|
100
|
+
} catch {
|
|
101
|
+
// If that fails, default to empty
|
|
102
|
+
edges = [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Build GEXF document
|
|
106
|
+
const gexf = buildGexfDocument(nodes, edges);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
format: 'gexf',
|
|
111
|
+
nodeCount: nodes.length,
|
|
112
|
+
edgeCount: edges.length,
|
|
113
|
+
content: gexf,
|
|
114
|
+
generatedAt: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Export brain graph as JSON.
|
|
120
|
+
*
|
|
121
|
+
* Outputs nodes and edges as flat arrays for programmatic processing.
|
|
122
|
+
* Suitable for visualization libraries and data integration.
|
|
123
|
+
*
|
|
124
|
+
* @param projectRoot - Root directory of the CLEO project
|
|
125
|
+
* @returns JSON export result with nodes and edges arrays
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* const result = await exportBrainAsJson('/path/to/project');
|
|
130
|
+
* console.log(JSON.stringify(result, null, 2)); // Pretty-printed JSON
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export async function exportBrainAsJson(projectRoot: string): Promise<BrainExportJsonResult> {
|
|
134
|
+
const db = await getBrainDb(projectRoot);
|
|
135
|
+
|
|
136
|
+
// Fetch all nodes and edges using basic select without provenance column
|
|
137
|
+
// This handles older schemas gracefully
|
|
138
|
+
let nodes: BrainPageNodeRow[] = [];
|
|
139
|
+
let edges: BrainPageEdgeRow[] = [];
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
nodes = await db
|
|
143
|
+
.select({
|
|
144
|
+
id: brainSchema.brainPageNodes.id,
|
|
145
|
+
nodeType: brainSchema.brainPageNodes.nodeType,
|
|
146
|
+
label: brainSchema.brainPageNodes.label,
|
|
147
|
+
qualityScore: brainSchema.brainPageNodes.qualityScore,
|
|
148
|
+
contentHash: brainSchema.brainPageNodes.contentHash,
|
|
149
|
+
lastActivityAt: brainSchema.brainPageNodes.lastActivityAt,
|
|
150
|
+
metadataJson: brainSchema.brainPageNodes.metadataJson,
|
|
151
|
+
createdAt: brainSchema.brainPageNodes.createdAt,
|
|
152
|
+
updatedAt: brainSchema.brainPageNodes.updatedAt,
|
|
153
|
+
})
|
|
154
|
+
.from(brainSchema.brainPageNodes);
|
|
155
|
+
} catch {
|
|
156
|
+
// If the graph nodes table doesn't exist, default to empty
|
|
157
|
+
nodes = [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
// Select edges without the provenance column to handle older schemas
|
|
162
|
+
const rawEdges = await db
|
|
163
|
+
.select({
|
|
164
|
+
fromId: brainSchema.brainPageEdges.fromId,
|
|
165
|
+
toId: brainSchema.brainPageEdges.toId,
|
|
166
|
+
edgeType: brainSchema.brainPageEdges.edgeType,
|
|
167
|
+
weight: brainSchema.brainPageEdges.weight,
|
|
168
|
+
createdAt: brainSchema.brainPageEdges.createdAt,
|
|
169
|
+
})
|
|
170
|
+
.from(brainSchema.brainPageEdges);
|
|
171
|
+
// Add provenance as null for missing rows and cast to the full row type
|
|
172
|
+
edges = rawEdges.map((e) => ({ ...e, provenance: null }) as BrainPageEdgeRow);
|
|
173
|
+
} catch {
|
|
174
|
+
// If that fails, default to empty
|
|
175
|
+
edges = [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
success: true,
|
|
180
|
+
format: 'json',
|
|
181
|
+
nodeCount: nodes.length,
|
|
182
|
+
edgeCount: edges.length,
|
|
183
|
+
nodes,
|
|
184
|
+
edges,
|
|
185
|
+
generatedAt: new Date().toISOString(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build a GEXF 1.3 XML document from nodes and edges.
|
|
191
|
+
*
|
|
192
|
+
* GEXF structure:
|
|
193
|
+
* - gexf@xmlns, @version
|
|
194
|
+
* - graph@mode=static, @defaultedgetype=directed
|
|
195
|
+
* - nodes with attvalues (node_type, quality_score, label)
|
|
196
|
+
* - edges with attributes (edge_type, weight, provenance)
|
|
197
|
+
*
|
|
198
|
+
* @param nodes - Array of brain page nodes
|
|
199
|
+
* @param edges - Array of brain page edges
|
|
200
|
+
* @returns Valid GEXF 1.3 XML string
|
|
201
|
+
*/
|
|
202
|
+
function buildGexfDocument(nodes: BrainPageNodeRow[], edges: BrainPageEdgeRow[]): string {
|
|
203
|
+
const lines: string[] = [
|
|
204
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
205
|
+
'<gexf xmlns="http://www.gexf.net/1.3draft" version="1.3">',
|
|
206
|
+
' <meta lastmodifieddate="' + new Date().toISOString() + '">',
|
|
207
|
+
' <creator>CLEO Brain Export (T626-M6)</creator>',
|
|
208
|
+
' <description>Living brain knowledge graph (brain_page_nodes + brain_page_edges)</description>',
|
|
209
|
+
' </meta>',
|
|
210
|
+
' <graph mode="static" defaultedgetype="directed">',
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
// Define node attributes schema
|
|
214
|
+
lines.push(' <attributes class="node">');
|
|
215
|
+
lines.push(' <attribute id="node_type" title="Node Type" type="string"/>');
|
|
216
|
+
lines.push(' <attribute id="quality_score" title="Quality Score" type="double"/>');
|
|
217
|
+
lines.push(' <attribute id="content_hash" title="Content Hash" type="string"/>');
|
|
218
|
+
lines.push(' <attribute id="last_activity_at" title="Last Activity" type="string"/>');
|
|
219
|
+
lines.push(' <attribute id="created_at" title="Created At" type="string"/>');
|
|
220
|
+
lines.push(' </attributes>');
|
|
221
|
+
|
|
222
|
+
// Define edge attributes schema
|
|
223
|
+
lines.push(' <attributes class="edge">');
|
|
224
|
+
lines.push(' <attribute id="edge_type" title="Edge Type" type="string"/>');
|
|
225
|
+
lines.push(' <attribute id="provenance" title="Provenance" type="string"/>');
|
|
226
|
+
lines.push(' <attribute id="created_at" title="Created At" type="string"/>');
|
|
227
|
+
lines.push(' </attributes>');
|
|
228
|
+
|
|
229
|
+
// Add nodes
|
|
230
|
+
lines.push(' <nodes>');
|
|
231
|
+
for (const node of nodes) {
|
|
232
|
+
lines.push(` <node id="${escapeXml(node.id)}" label="${escapeXml(node.label)}">`);
|
|
233
|
+
lines.push(' <attvalues>');
|
|
234
|
+
lines.push(` <attvalue for="node_type" value="${escapeXml(node.nodeType)}"/>`);
|
|
235
|
+
lines.push(` <attvalue for="quality_score" value="${node.qualityScore ?? 0.5}"/>`);
|
|
236
|
+
if (node.contentHash) {
|
|
237
|
+
lines.push(` <attvalue for="content_hash" value="${escapeXml(node.contentHash)}"/>`);
|
|
238
|
+
}
|
|
239
|
+
lines.push(
|
|
240
|
+
` <attvalue for="last_activity_at" value="${escapeXml(node.lastActivityAt)}"/>`,
|
|
241
|
+
);
|
|
242
|
+
lines.push(` <attvalue for="created_at" value="${escapeXml(node.createdAt)}"/>`);
|
|
243
|
+
lines.push(' </attvalues>');
|
|
244
|
+
lines.push(' </node>');
|
|
245
|
+
}
|
|
246
|
+
lines.push(' </nodes>');
|
|
247
|
+
|
|
248
|
+
// Add edges
|
|
249
|
+
lines.push(' <edges>');
|
|
250
|
+
for (let i = 0; i < edges.length; i++) {
|
|
251
|
+
const edge = edges[i];
|
|
252
|
+
const weight = edge.weight ?? 1.0;
|
|
253
|
+
lines.push(
|
|
254
|
+
` <edge id="${i}" source="${escapeXml(edge.fromId)}" target="${escapeXml(edge.toId)}" weight="${weight}">`,
|
|
255
|
+
);
|
|
256
|
+
lines.push(' <attvalues>');
|
|
257
|
+
lines.push(` <attvalue for="edge_type" value="${escapeXml(edge.edgeType)}"/>`);
|
|
258
|
+
if (edge.provenance) {
|
|
259
|
+
lines.push(` <attvalue for="provenance" value="${escapeXml(edge.provenance)}"/>`);
|
|
260
|
+
}
|
|
261
|
+
lines.push(` <attvalue for="created_at" value="${escapeXml(edge.createdAt)}"/>`);
|
|
262
|
+
lines.push(' </attvalues>');
|
|
263
|
+
lines.push(' </edge>');
|
|
264
|
+
}
|
|
265
|
+
lines.push(' </edges>');
|
|
266
|
+
|
|
267
|
+
lines.push(' </graph>');
|
|
268
|
+
lines.push('</gexf>');
|
|
269
|
+
|
|
270
|
+
return lines.join('\n');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Escape XML special characters to prevent injection/parsing errors.
|
|
275
|
+
*
|
|
276
|
+
* @param text - Text to escape
|
|
277
|
+
* @returns XML-safe string
|
|
278
|
+
*/
|
|
279
|
+
function escapeXml(text: string): string {
|
|
280
|
+
return text
|
|
281
|
+
.replace(/&/g, '&')
|
|
282
|
+
.replace(/</g, '<')
|
|
283
|
+
.replace(/>/g, '>')
|
|
284
|
+
.replace(/"/g, '"')
|
|
285
|
+
.replace(/'/g, ''');
|
|
286
|
+
}
|
|
@@ -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 {
|