@andespindola/brainlink 0.1.0-beta.24 → 0.1.0-beta.26
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/README.md
CHANGED
|
@@ -668,6 +668,18 @@ blink migrate-vault --from ~/.brainlink/vault --to ./team-vault --report ./migra
|
|
|
668
668
|
Runs explicit markdown migration between vaults while preserving conflicts as `.conflict-<timestamp>` files.
|
|
669
669
|
Use `--dry-run` to preview `copied`, `conflicted` and `unchanged` counts before writing.
|
|
670
670
|
|
|
671
|
+
### `db-import`
|
|
672
|
+
|
|
673
|
+
```bash
|
|
674
|
+
blink db-import --vault ./team-vault
|
|
675
|
+
blink db-import --vault ./team-vault --db ./legacy/brainlink.db
|
|
676
|
+
blink db-import --vault ./team-vault --db ./legacy/brainlink.db --table legacy_notes --dry-run
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
Imports durable memory from a legacy SQLite database into Markdown notes (`agents/<agent-id>/*.md`) and reindexes by default.
|
|
680
|
+
When `--db` is omitted, Brainlink auto-detects common legacy paths such as `<vault>/.brainlink/brainlink.db`.
|
|
681
|
+
Use `--agent <id>` to force all imported rows into one namespace, `--limit` for incremental imports, `--dry-run` to preview without writing files, and `--no-index` to defer reindexing.
|
|
682
|
+
|
|
671
683
|
### `init`
|
|
672
684
|
|
|
673
685
|
```bash
|
|
@@ -156,14 +156,14 @@ const graphBounds = nodes => {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
const fitScaleBiasByNodeCount = nodeCount => {
|
|
159
|
-
if (nodeCount <= 6) return 2.
|
|
160
|
-
if (nodeCount <= 20) return
|
|
161
|
-
if (nodeCount <= 60) return 1.
|
|
162
|
-
if (nodeCount <= 180) return 1.
|
|
163
|
-
if (nodeCount <= 600) return 1.
|
|
159
|
+
if (nodeCount <= 6) return 2.8
|
|
160
|
+
if (nodeCount <= 20) return 2.2
|
|
161
|
+
if (nodeCount <= 60) return 1.72
|
|
162
|
+
if (nodeCount <= 180) return 1.34
|
|
163
|
+
if (nodeCount <= 600) return 1.08
|
|
164
164
|
if (nodeCount <= 2000) return 0.9
|
|
165
165
|
if (nodeCount <= 6000) return 0.72
|
|
166
|
-
return 0.
|
|
166
|
+
return 0.58
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
const fitView = (options = { useFiltered: true }) => {
|
|
@@ -178,13 +178,34 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
178
178
|
return
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
const
|
|
181
|
+
const paddingByNodeCount = nodeCount => {
|
|
182
|
+
if (nodeCount <= 6) return 28
|
|
183
|
+
if (nodeCount <= 20) return 44
|
|
184
|
+
if (nodeCount <= 60) return 68
|
|
185
|
+
if (nodeCount <= 180) return 86
|
|
186
|
+
if (nodeCount <= 600) return 110
|
|
187
|
+
if (nodeCount <= 2000) return 140
|
|
188
|
+
return 180
|
|
189
|
+
}
|
|
190
|
+
const minFitScaleByNodeCount = nodeCount => {
|
|
191
|
+
if (nodeCount <= 6) return 2.4
|
|
192
|
+
if (nodeCount <= 20) return 1.8
|
|
193
|
+
if (nodeCount <= 60) return 1.2
|
|
194
|
+
if (nodeCount <= 180) return 0.86
|
|
195
|
+
if (nodeCount <= 600) return 0.58
|
|
196
|
+
if (nodeCount <= 2000) return 0.34
|
|
197
|
+
if (nodeCount <= 6000) return 0.2
|
|
198
|
+
return 0.13
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const padding = paddingByNodeCount(nodes.length)
|
|
182
202
|
const scaleX = width / (bounds.width + padding * 2)
|
|
183
203
|
const scaleY = height / (bounds.height + padding * 2)
|
|
184
204
|
const fitScale = clampScale(Math.min(scaleX, scaleY))
|
|
185
205
|
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
206
|
+
const minimumScale = minFitScaleByNodeCount(nodes.length)
|
|
186
207
|
const minimumLargeGraphScale = nodes.length > largeGraphNodeThreshold ? 0.13 : zoomRange.min
|
|
187
|
-
const scale = Math.max(biasedScale, minimumLargeGraphScale)
|
|
208
|
+
const scale = Math.max(biasedScale, minimumScale, minimumLargeGraphScale)
|
|
188
209
|
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
189
210
|
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
190
211
|
|
|
@@ -653,16 +674,17 @@ const zoomAtPoint = (screenX, screenY, factor) => {
|
|
|
653
674
|
|
|
654
675
|
const wheelZoomFactor = event => {
|
|
655
676
|
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
656
|
-
const
|
|
677
|
+
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
678
|
+
const normalizedDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1200) / 220
|
|
657
679
|
|
|
658
|
-
if (
|
|
659
|
-
return
|
|
680
|
+
if (normalizedDelta <= 0.0001) {
|
|
681
|
+
return 1
|
|
660
682
|
}
|
|
661
683
|
|
|
662
|
-
const
|
|
663
|
-
const
|
|
684
|
+
const sensitivity = isModifierZoom ? 0.2 : 0.14
|
|
685
|
+
const direction = event.deltaY < 0 ? 1 : -1
|
|
664
686
|
|
|
665
|
-
return
|
|
687
|
+
return Math.exp(direction * normalizedDelta * sensitivity)
|
|
666
688
|
}
|
|
667
689
|
|
|
668
690
|
const bindEvents = () => {
|
|
@@ -749,6 +771,9 @@ const bindEvents = () => {
|
|
|
749
771
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
750
772
|
canvas.releasePointerCapture(event.pointerId)
|
|
751
773
|
})
|
|
774
|
+
canvas.addEventListener('pointercancel', () => {
|
|
775
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
776
|
+
})
|
|
752
777
|
}
|
|
753
778
|
|
|
754
779
|
const loadAgents = async () => {
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { extractTags, extractWikiLinks } from '../domain/markdown.js';
|
|
6
|
+
import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
|
|
7
|
+
import { ensureVault, listVaultFiles, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
8
|
+
import { getBrainlinkHomePath } from '../infrastructure/paths.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const fieldSeparator = '\u001f';
|
|
11
|
+
const rowSeparator = '\u001e';
|
|
12
|
+
const contentColumnCandidates = ['content', 'markdown', 'body', 'text', 'note'];
|
|
13
|
+
const titleColumnCandidates = ['title', 'note_title', 'name', 'headline'];
|
|
14
|
+
const pathColumnCandidates = ['path', 'file_path', 'filepath', 'source_path', 'source'];
|
|
15
|
+
const agentColumnCandidates = ['agent', 'agent_id', 'namespace', 'scope'];
|
|
16
|
+
const tagColumnCandidates = ['tags', 'tag_list', 'keywords'];
|
|
17
|
+
const createdColumnCandidates = ['created_at', 'createdat', 'created', 'ctime'];
|
|
18
|
+
const updatedColumnCandidates = ['updated_at', 'updatedat', 'updated', 'mtime'];
|
|
19
|
+
const systemHubTitle = 'Memory Hub';
|
|
20
|
+
const systemRootTitle = 'Knowledge Root';
|
|
21
|
+
const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '').toLowerCase();
|
|
22
|
+
const slugify = (title) => title
|
|
23
|
+
.normalize('NFKD')
|
|
24
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
27
|
+
.replace(/^-+|-+$/g, '');
|
|
28
|
+
const quoteIdentifier = (value) => `"${value.replaceAll('"', '""')}"`;
|
|
29
|
+
const pickColumn = (columns, candidates) => {
|
|
30
|
+
const byLower = new Map(columns.map((column) => [column.toLowerCase(), column]));
|
|
31
|
+
return candidates.map((candidate) => byLower.get(candidate)).find((column) => Boolean(column)) ?? null;
|
|
32
|
+
};
|
|
33
|
+
const parseDelimitedRows = (rawOutput) => {
|
|
34
|
+
const normalized = rawOutput.trim();
|
|
35
|
+
if (normalized.length === 0) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
return normalized
|
|
39
|
+
.split(rowSeparator)
|
|
40
|
+
.map((row) => row.trim())
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((row) => row.split(fieldSeparator));
|
|
43
|
+
};
|
|
44
|
+
const runSqliteQuery = async (databasePath, sql) => {
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await execFileAsync('sqlite3', ['-readonly', '-noheader', '-separator', fieldSeparator, '-newline', rowSeparator, databasePath, sql], { maxBuffer: 1024 * 1024 * 64 });
|
|
47
|
+
return parseDelimitedRows(stdout);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
const lower = message.toLowerCase();
|
|
52
|
+
if (lower.includes('enoent') || lower.includes('not found')) {
|
|
53
|
+
throw new Error('sqlite3 CLI was not found. Install sqlite3 to use db-import.');
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`Unable to read SQLite database: ${message}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const detectLegacyDbPath = async (vaultPath, explicitPath) => {
|
|
59
|
+
if (explicitPath) {
|
|
60
|
+
return resolve(explicitPath);
|
|
61
|
+
}
|
|
62
|
+
const vaultRoot = await ensureVault(vaultPath);
|
|
63
|
+
const candidates = [
|
|
64
|
+
join(vaultRoot, '.brainlink', 'brainlink.db'),
|
|
65
|
+
join(vaultRoot, '.brainlink', 'index.db'),
|
|
66
|
+
join(getBrainlinkHomePath(), 'brainlink.db'),
|
|
67
|
+
join(getBrainlinkHomePath(), 'vault', '.brainlink', 'brainlink.db')
|
|
68
|
+
];
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
try {
|
|
71
|
+
await access(candidate);
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`No legacy SQLite database found. Checked: ${candidates.join(', ')}. Use --db <path-to-db> to import explicitly.`);
|
|
77
|
+
};
|
|
78
|
+
const listTables = async (dbPath) => {
|
|
79
|
+
const rows = await runSqliteQuery(dbPath, `SELECT name
|
|
80
|
+
FROM sqlite_master
|
|
81
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
82
|
+
ORDER BY name`);
|
|
83
|
+
return rows.map((columns) => columns[0]).filter(Boolean);
|
|
84
|
+
};
|
|
85
|
+
const listColumns = async (dbPath, table) => {
|
|
86
|
+
const rows = await runSqliteQuery(dbPath, `PRAGMA table_info(${quoteIdentifier(table)})`);
|
|
87
|
+
return rows.map((columns) => columns[1]).filter(Boolean);
|
|
88
|
+
};
|
|
89
|
+
const tableScore = (columns) => {
|
|
90
|
+
const contentColumn = pickColumn(columns, contentColumnCandidates);
|
|
91
|
+
const titleColumn = pickColumn(columns, titleColumnCandidates);
|
|
92
|
+
const pathColumn = pickColumn(columns, pathColumnCandidates);
|
|
93
|
+
const agentColumn = pickColumn(columns, agentColumnCandidates);
|
|
94
|
+
return (contentColumn ? 6 : 0) + (titleColumn ? 4 : 0) + (pathColumn ? 2 : 0) + (agentColumn ? 1 : 0);
|
|
95
|
+
};
|
|
96
|
+
const detectTableMapping = async (dbPath, tableOverride) => {
|
|
97
|
+
const tables = await listTables(dbPath);
|
|
98
|
+
if (tables.length === 0) {
|
|
99
|
+
throw new Error('Legacy SQLite database has no readable tables.');
|
|
100
|
+
}
|
|
101
|
+
const mappings = await Promise.all(tables.map(async (table) => {
|
|
102
|
+
const columns = await listColumns(dbPath, table);
|
|
103
|
+
return {
|
|
104
|
+
table,
|
|
105
|
+
columns,
|
|
106
|
+
titleColumn: pickColumn(columns, titleColumnCandidates),
|
|
107
|
+
contentColumn: pickColumn(columns, contentColumnCandidates),
|
|
108
|
+
pathColumn: pickColumn(columns, pathColumnCandidates),
|
|
109
|
+
agentColumn: pickColumn(columns, agentColumnCandidates),
|
|
110
|
+
tagsColumn: pickColumn(columns, tagColumnCandidates),
|
|
111
|
+
createdColumn: pickColumn(columns, createdColumnCandidates),
|
|
112
|
+
updatedColumn: pickColumn(columns, updatedColumnCandidates),
|
|
113
|
+
score: tableScore(columns)
|
|
114
|
+
};
|
|
115
|
+
}));
|
|
116
|
+
if (tableOverride) {
|
|
117
|
+
const overridden = mappings.find((mapping) => mapping.table === tableOverride);
|
|
118
|
+
if (!overridden) {
|
|
119
|
+
throw new Error(`Table not found in SQLite database: ${tableOverride}`);
|
|
120
|
+
}
|
|
121
|
+
if (!overridden.contentColumn) {
|
|
122
|
+
throw new Error(`Table ${tableOverride} does not expose a readable content column.`);
|
|
123
|
+
}
|
|
124
|
+
return { mapping: overridden, detectedTables: tables };
|
|
125
|
+
}
|
|
126
|
+
const selected = [...mappings]
|
|
127
|
+
.filter((mapping) => mapping.contentColumn)
|
|
128
|
+
.sort((left, right) => right.score - left.score)[0];
|
|
129
|
+
if (!selected) {
|
|
130
|
+
throw new Error('Could not detect a legacy table with content column in SQLite database.');
|
|
131
|
+
}
|
|
132
|
+
return { mapping: selected, detectedTables: tables };
|
|
133
|
+
};
|
|
134
|
+
const hexExpression = (column) => column ? `hex(COALESCE(CAST(${quoteIdentifier(column)} AS BLOB), X''))` : `hex(X'')`;
|
|
135
|
+
const decodeHexUtf8 = (value) => value ? Buffer.from(value, 'hex').toString('utf8') : '';
|
|
136
|
+
const parseLegacyTags = (value) => Array.from(new Set(value
|
|
137
|
+
.split(/[\s,;|]+/)
|
|
138
|
+
.map((item) => item.trim().replace(/^#/, '').toLowerCase())
|
|
139
|
+
.filter((item) => /^[a-z0-9][a-z0-9_-]*$/i.test(item))));
|
|
140
|
+
const titleFromPath = (pathValue) => basename(pathValue).replace(extname(pathValue), '').replace(/[-_]+/g, ' ').trim();
|
|
141
|
+
const appendMissingTags = (content, tags) => {
|
|
142
|
+
if (tags.length === 0) {
|
|
143
|
+
return content;
|
|
144
|
+
}
|
|
145
|
+
const existingTags = new Set(extractTags(content).map((tag) => tag.toLowerCase()));
|
|
146
|
+
const missing = tags.filter((tag) => !existingTags.has(tag.toLowerCase()));
|
|
147
|
+
if (missing.length === 0) {
|
|
148
|
+
return content;
|
|
149
|
+
}
|
|
150
|
+
return `${content.trim()}\n\nTags: ${missing.map((tag) => `#${tag}`).join(' ')}`;
|
|
151
|
+
};
|
|
152
|
+
const buildNote = (title, content, agentId) => [
|
|
153
|
+
'---',
|
|
154
|
+
`title: "${title.replaceAll('"', '\\"')}"`,
|
|
155
|
+
`agent: "${agentId}"`,
|
|
156
|
+
'---',
|
|
157
|
+
'',
|
|
158
|
+
`# ${title}`,
|
|
159
|
+
'',
|
|
160
|
+
content.trim(),
|
|
161
|
+
''
|
|
162
|
+
].join('\n');
|
|
163
|
+
const parseLegacyRow = (columns, rowIndex) => {
|
|
164
|
+
const [titleHex, contentHex, pathHex, agentHex, tagsHex] = columns;
|
|
165
|
+
const content = decodeHexUtf8(contentHex).trim();
|
|
166
|
+
const path = decodeHexUtf8(pathHex).trim();
|
|
167
|
+
const titleCandidate = decodeHexUtf8(titleHex).trim();
|
|
168
|
+
const fallbackTitleFromPath = path ? titleFromPath(path) : '';
|
|
169
|
+
const title = titleCandidate || fallbackTitleFromPath || `Imported Memory ${rowIndex + 1}`;
|
|
170
|
+
return {
|
|
171
|
+
title,
|
|
172
|
+
content,
|
|
173
|
+
path,
|
|
174
|
+
agent: decodeHexUtf8(agentHex).trim(),
|
|
175
|
+
tags: parseLegacyTags(decodeHexUtf8(tagsHex))
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
const noteRelativePath = (agentId, slug, suffix = 0) => `agents/${agentId}/${suffix > 0 ? `${slug}-${suffix + 1}` : slug || 'untitled'}.md`;
|
|
179
|
+
const reserveUniquePath = (agentId, title, reserved) => {
|
|
180
|
+
const slug = slugify(title);
|
|
181
|
+
for (let suffix = 0; suffix < 10_000; suffix += 1) {
|
|
182
|
+
const relativePath = noteRelativePath(agentId, slug, suffix);
|
|
183
|
+
if (!reserved.has(relativePath)) {
|
|
184
|
+
reserved.add(relativePath);
|
|
185
|
+
return relativePath;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
throw new Error(`Could not allocate unique path for imported note: ${title}`);
|
|
189
|
+
};
|
|
190
|
+
const ensureSystemNote = async (vaultPath, reserved, created, agentId, title, content, dryRun) => {
|
|
191
|
+
const filename = noteRelativePath(agentId, slugify(title));
|
|
192
|
+
if (reserved.has(filename)) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
reserved.add(filename);
|
|
196
|
+
created.add(filename);
|
|
197
|
+
if (dryRun) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
await writeMarkdownFile(vaultPath, filename, buildNote(title, content, agentId));
|
|
201
|
+
};
|
|
202
|
+
const applyConnectivityRule = async (vaultPath, reserved, created, title, content, agentId, dryRun) => {
|
|
203
|
+
const links = extractWikiLinks(content).filter((link) => normalizeTitle(link) !== normalizeTitle(title));
|
|
204
|
+
if (links.length > 0) {
|
|
205
|
+
return content.trim();
|
|
206
|
+
}
|
|
207
|
+
const normalized = normalizeTitle(title);
|
|
208
|
+
if (normalized === normalizeTitle(systemHubTitle)) {
|
|
209
|
+
await ensureSystemNote(vaultPath, reserved, created, agentId, systemRootTitle, `Entry point for agent memory. [[${systemHubTitle}]] #memory #root`, dryRun);
|
|
210
|
+
return `${content.trim()}\n\nRelated: [[${systemRootTitle}]]`;
|
|
211
|
+
}
|
|
212
|
+
await ensureSystemNote(vaultPath, reserved, created, agentId, systemHubTitle, 'Central memory index for this agent namespace. #memory #hub', dryRun);
|
|
213
|
+
return `${content.trim()}\n\nRelated: [[${systemHubTitle}]]`;
|
|
214
|
+
};
|
|
215
|
+
const importRowsFromMapping = async (vaultPath, dbPath, mapping, options, reserved) => {
|
|
216
|
+
const limit = Number.isFinite(options.limit) && (options.limit ?? 0) > 0 ? Math.floor(options.limit ?? 0) : undefined;
|
|
217
|
+
const sql = [
|
|
218
|
+
'SELECT',
|
|
219
|
+
`${hexExpression(mapping.titleColumn)} AS title_hex,`,
|
|
220
|
+
`${hexExpression(mapping.contentColumn)} AS content_hex,`,
|
|
221
|
+
`${hexExpression(mapping.pathColumn)} AS path_hex,`,
|
|
222
|
+
`${hexExpression(mapping.agentColumn)} AS agent_hex,`,
|
|
223
|
+
`${hexExpression(mapping.tagsColumn)} AS tags_hex,`,
|
|
224
|
+
`${hexExpression(mapping.createdColumn)} AS created_hex,`,
|
|
225
|
+
`${hexExpression(mapping.updatedColumn)} AS updated_hex`,
|
|
226
|
+
`FROM ${quoteIdentifier(mapping.table)}`,
|
|
227
|
+
...(limit ? [`LIMIT ${limit}`] : [])
|
|
228
|
+
].join(' ');
|
|
229
|
+
const rows = await runSqliteQuery(dbPath, sql);
|
|
230
|
+
const createdSystemNotes = new Set();
|
|
231
|
+
const importedFiles = [];
|
|
232
|
+
let imported = 0;
|
|
233
|
+
let skipped = 0;
|
|
234
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
|
235
|
+
const row = parseLegacyRow(rows[rowIndex], rowIndex);
|
|
236
|
+
if (!row.content) {
|
|
237
|
+
skipped += 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const agentId = sanitizeAgentId(options.agentOverride || row.agent || sharedAgentId);
|
|
241
|
+
const filename = reserveUniquePath(agentId, row.title, reserved);
|
|
242
|
+
const mergedContent = appendMissingTags(row.content, row.tags);
|
|
243
|
+
const connectedContent = await applyConnectivityRule(vaultPath, reserved, createdSystemNotes, row.title, mergedContent, agentId, options.dryRun === true);
|
|
244
|
+
const note = buildNote(row.title, connectedContent, agentId);
|
|
245
|
+
if (options.dryRun !== true) {
|
|
246
|
+
await writeMarkdownFile(vaultPath, filename, note);
|
|
247
|
+
}
|
|
248
|
+
importedFiles.push(filename);
|
|
249
|
+
imported += 1;
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
rowsRead: rows.length,
|
|
253
|
+
imported,
|
|
254
|
+
skipped,
|
|
255
|
+
createdSystemNotes: createdSystemNotes.size,
|
|
256
|
+
importedFiles
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
export const importLegacySqliteDatabase = async (vaultPath, options = {}) => {
|
|
260
|
+
const vault = await ensureVault(vaultPath);
|
|
261
|
+
const dbPath = await detectLegacyDbPath(vaultPath, options.dbPath);
|
|
262
|
+
const { mapping, detectedTables } = await detectTableMapping(dbPath, options.table);
|
|
263
|
+
const existingFiles = (await listVaultFiles(vaultPath))
|
|
264
|
+
.filter((path) => extname(path).toLowerCase() === '.md')
|
|
265
|
+
.map((path) => relative(vault, path));
|
|
266
|
+
const reserved = new Set(existingFiles);
|
|
267
|
+
const imported = await importRowsFromMapping(vaultPath, dbPath, mapping, options, reserved);
|
|
268
|
+
return {
|
|
269
|
+
vault,
|
|
270
|
+
dbPath,
|
|
271
|
+
table: mapping.table,
|
|
272
|
+
detectedTables,
|
|
273
|
+
rowsRead: imported.rowsRead,
|
|
274
|
+
imported: imported.imported,
|
|
275
|
+
skipped: imported.skipped,
|
|
276
|
+
createdSystemNotes: imported.createdSystemNotes,
|
|
277
|
+
dryRun: options.dryRun === true,
|
|
278
|
+
importedFiles: imported.importedFiles
|
|
279
|
+
};
|
|
280
|
+
};
|
|
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { dirname, relative, resolve } from 'node:path';
|
|
4
4
|
import { addNote } from '../../application/add-note.js';
|
|
5
5
|
import { buildContextPackage } from '../../application/build-context.js';
|
|
6
|
+
import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
|
|
6
7
|
import { indexVault } from '../../application/index-vault.js';
|
|
7
8
|
import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
|
|
8
9
|
import { startServer } from '../../application/start-server.js';
|
|
@@ -99,6 +100,37 @@ export const registerWriteCommands = (program) => {
|
|
|
99
100
|
return `${summary}${indexMessage}${reportMessage}`;
|
|
100
101
|
});
|
|
101
102
|
});
|
|
103
|
+
program
|
|
104
|
+
.command('db-import')
|
|
105
|
+
.option('-v, --vault <vault>', 'vault directory')
|
|
106
|
+
.option('--db <path>', 'legacy SQLite database path (default: <vault>/.brainlink/brainlink.db)')
|
|
107
|
+
.option('--table <name>', 'legacy table name override')
|
|
108
|
+
.option('-a, --agent <agent>', 'force imported notes into a target agent namespace')
|
|
109
|
+
.option('-l, --limit <limit>', 'maximum number of rows to import')
|
|
110
|
+
.option('--dry-run', 'preview import without writing Markdown files')
|
|
111
|
+
.option('--no-index', 'skip reindexing after import')
|
|
112
|
+
.option('--json', 'print machine-readable JSON')
|
|
113
|
+
.description('import legacy SQLite memory into Markdown vault and current index model')
|
|
114
|
+
.action(async (options) => {
|
|
115
|
+
const resolved = await resolveOptions(options);
|
|
116
|
+
const result = await importLegacySqliteDatabase(resolved.vault, {
|
|
117
|
+
dbPath: options.db,
|
|
118
|
+
table: options.table,
|
|
119
|
+
agentOverride: options.agent ? resolved.agent : undefined,
|
|
120
|
+
limit: options.limit ? parsePositiveInteger(options.limit, 100_000) : undefined,
|
|
121
|
+
dryRun: Boolean(options.dryRun)
|
|
122
|
+
});
|
|
123
|
+
const shouldIndex = options.index !== false && !result.dryRun && result.imported > 0;
|
|
124
|
+
const index = shouldIndex ? await indexVault(resolved.vault) : undefined;
|
|
125
|
+
print(options.json, { ...result, ...(index ? { index } : {}) }, () => {
|
|
126
|
+
const summary = `Imported ${result.imported}/${result.rowsRead} rows from ${result.table} (skipped ${result.skipped}).`;
|
|
127
|
+
const indexMessage = index
|
|
128
|
+
? ` Indexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} links.`
|
|
129
|
+
: '';
|
|
130
|
+
const dryRunMessage = result.dryRun ? ' Dry run only; no files were written.' : '';
|
|
131
|
+
return `${summary}${indexMessage}${dryRunMessage}`;
|
|
132
|
+
});
|
|
133
|
+
});
|
|
102
134
|
program
|
|
103
135
|
.command('add')
|
|
104
136
|
.argument('<title>', 'note title')
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -377,6 +377,18 @@ blink migrate-vault --from ~/.brainlink/vault --to ./team-vault --report ./migra
|
|
|
377
377
|
|
|
378
378
|
Use `--dry-run` to preview `copied`, `conflicted`, `unchanged` before writing files.
|
|
379
379
|
|
|
380
|
+
### Import Legacy SQLite DB
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
blink db-import --vault ./team-vault
|
|
384
|
+
blink db-import --vault ./team-vault --db ./legacy/brainlink.db
|
|
385
|
+
blink db-import --vault ./team-vault --db ./legacy/brainlink.db --table legacy_notes --dry-run
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`db-import` migrates rows from legacy SQLite memory into Markdown notes in the current vault and indexes the result by default.
|
|
389
|
+
Without `--db`, Brainlink auto-detects common legacy database paths.
|
|
390
|
+
Use `--agent` to force namespace, `--limit` for staged migration, `--dry-run` to preview writes, and `--no-index` to postpone indexing.
|
|
391
|
+
|
|
380
392
|
### Install Agent Integration
|
|
381
393
|
|
|
382
394
|
```bash
|
package/docs/QUICKSTART.md
CHANGED
|
@@ -102,3 +102,10 @@ S3 target:
|
|
|
102
102
|
```bash
|
|
103
103
|
blink migrate-vault --from ~/.brainlink/vault --to "s3://my-memory-bucket/brainlink" --dry-run
|
|
104
104
|
```
|
|
105
|
+
|
|
106
|
+
Legacy SQLite import:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
blink db-import --vault ./team-vault
|
|
110
|
+
blink db-import --vault ./team-vault --db ./legacy/brainlink.db --dry-run
|
|
111
|
+
```
|
package/package.json
CHANGED