@andespindola/brainlink 0.1.0-beta.23 → 0.1.0-beta.25

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
@@ -201,7 +201,9 @@ const createLayout = graph => {
201
201
  const nodes = graph.nodes.map(node => ({
202
202
  ...node,
203
203
  x: Number.isFinite(node.x) ? node.x : 0,
204
- y: Number.isFinite(node.y) ? node.y : 0
204
+ y: Number.isFinite(node.y) ? node.y : 0,
205
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
206
+ vy: Number.isFinite(node.vy) ? node.vy : 0
205
207
  }))
206
208
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
207
209
  const edges = graph.edges
@@ -296,6 +298,10 @@ const tick = delta => {
296
298
  edges.forEach(edge => {
297
299
  const source = edge.sourceNode
298
300
  const target = edge.targetNode
301
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
302
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
303
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
304
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
299
305
  const dx = target.x - source.x
300
306
  const dy = target.y - source.y
301
307
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -312,6 +318,10 @@ const tick = delta => {
312
318
  for (let j = i + 1; j < nodes.length; j += 1) {
313
319
  const a = nodes[i]
314
320
  const b = nodes[j]
321
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
322
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
323
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
324
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
315
325
  const dx = b.x - a.x
316
326
  const dy = b.y - a.y
317
327
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -326,6 +336,10 @@ const tick = delta => {
326
336
  }
327
337
 
328
338
  nodes.forEach(node => {
339
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
340
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
341
+ node.x = Number.isFinite(node.x) ? node.x : 0
342
+ node.y = Number.isFinite(node.y) ? node.y : 0
329
343
  if (state.pointer.dragNode === node) {
330
344
  node.vx = 0
331
345
  node.vy = 0
@@ -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')
@@ -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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.23",
3
+ "version": "0.1.0-beta.25",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",