@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.
Files changed (52) hide show
  1. package/dist/index.js +511 -41
  2. package/dist/index.js.map +4 -4
  3. package/dist/internal.d.ts +4 -0
  4. package/dist/internal.d.ts.map +1 -1
  5. package/dist/internal.js +669 -45
  6. package/dist/internal.js.map +4 -4
  7. package/dist/memory/brain-export.d.ts +70 -0
  8. package/dist/memory/brain-export.d.ts.map +1 -0
  9. package/dist/memory/brain-lifecycle.d.ts +7 -0
  10. package/dist/memory/brain-lifecycle.d.ts.map +1 -1
  11. package/dist/memory/brain-retrieval.d.ts.map +1 -1
  12. package/dist/memory/brain-stdp.d.ts +122 -0
  13. package/dist/memory/brain-stdp.d.ts.map +1 -0
  14. package/dist/memory/decision-cross-link.d.ts +70 -0
  15. package/dist/memory/decision-cross-link.d.ts.map +1 -0
  16. package/dist/memory/decisions.d.ts.map +1 -1
  17. package/dist/memory/edge-types.d.ts +24 -0
  18. package/dist/memory/edge-types.d.ts.map +1 -0
  19. package/dist/memory/index.d.ts +1 -0
  20. package/dist/memory/index.d.ts.map +1 -1
  21. package/dist/store/brain-schema.d.ts +150 -3
  22. package/dist/store/brain-schema.d.ts.map +1 -1
  23. package/dist/store/brain-sqlite.d.ts.map +1 -1
  24. package/dist/store/validation-schemas.d.ts +1 -0
  25. package/dist/store/validation-schemas.d.ts.map +1 -1
  26. package/dist/validation/verification.d.ts.map +1 -1
  27. package/migrations/drizzle-brain/20260415000001_t626-normalize-co-retrieved-edge-type/migration.sql +14 -0
  28. package/package.json +8 -8
  29. package/src/internal.ts +14 -0
  30. package/src/memory/__tests__/brain-stdp.test.ts +452 -0
  31. package/src/memory/__tests__/decision-cross-link.test.ts +240 -0
  32. package/src/memory/brain-embedding.ts +1 -1
  33. package/src/memory/brain-export.ts +286 -0
  34. package/src/memory/brain-lifecycle.ts +23 -4
  35. package/src/memory/brain-retrieval.ts +80 -14
  36. package/src/memory/brain-similarity.ts +1 -1
  37. package/src/memory/brain-stdp.ts +448 -0
  38. package/src/memory/claude-mem-migration.ts +1 -1
  39. package/src/memory/decision-cross-link.ts +276 -0
  40. package/src/memory/decisions.ts +7 -0
  41. package/src/memory/edge-types.ts +31 -0
  42. package/src/memory/index.ts +2 -0
  43. package/src/sessions/briefing.ts +1 -1
  44. package/src/skills/dispatch.ts +1 -1
  45. package/src/skills/injection/subagent.ts +1 -1
  46. package/src/skills/orchestrator/spawn.ts +1 -1
  47. package/src/store/brain-schema.ts +54 -0
  48. package/src/store/brain-sqlite.ts +17 -0
  49. package/src/store/json.ts +2 -2
  50. package/src/system/archive-analytics.ts +1 -1
  51. package/src/tasks/task-ops.ts +2 -2
  52. 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 || !currentProvider.isAvailable()) return null;
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, '&amp;')
282
+ .replace(/</g, '&lt;')
283
+ .replace(/>/g, '&gt;')
284
+ .replace(/"/g, '&quot;')
285
+ .replace(/'/g, '&apos;');
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 = 'relates_to'
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 (?, ?, 'relates_to', 0.3, 'consolidation:co-retrieval', ?)
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 {