@aperdomoll90/ledger-ai 1.0.0 → 1.0.1

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/cli.js CHANGED
@@ -13,6 +13,7 @@ import { backup, enableBackupCron, disableBackupCron } from './commands/backup.j
13
13
  import { restore } from './commands/restore.js';
14
14
  import { onboard } from './commands/onboard.js';
15
15
  import { configGet, configSet, configList } from './commands/config.js';
16
+ import { migrate } from './commands/migrate.js';
16
17
  process.on('unhandledRejection', (err) => {
17
18
  console.error(err instanceof Error ? err.message : String(err));
18
19
  process.exit(1);
@@ -150,4 +151,11 @@ setupCmd
150
151
  .action(async () => {
151
152
  await setupChatgpt();
152
153
  });
154
+ program
155
+ .command('migrate')
156
+ .description('Safely migrate local files to Ledger (backup, compare, upload)')
157
+ .action(async () => {
158
+ const config = loadConfig();
159
+ await migrate(config);
160
+ });
153
161
  program.parse();
@@ -0,0 +1,461 @@
1
+ import { readFileSync, existsSync, mkdirSync, cpSync, readdirSync } from 'fs';
2
+ import { resolve, basename, join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { searchNotes } from '../lib/notes.js';
5
+ import { contentHash } from '../lib/hash.js';
6
+ import { confirm, choose } from '../lib/prompt.js';
7
+ export async function migrate(config) {
8
+ const stats = { uploaded: 0, combined: 0, skipped: 0, alreadyInLedger: 0 };
9
+ // Phase 1: Backup
10
+ console.error('\n=== Phase 1: Backup ===\n');
11
+ const backupDir = await backupExisting(config);
12
+ console.error(` Backup saved to ${backupDir}\n`);
13
+ // Load all existing notes from Ledger once
14
+ const existingNotes = await fetchAllNotes(config);
15
+ console.error(` Ledger has ${existingNotes.length} notes.\n`);
16
+ // Phase 2: Parse references from CLAUDE.md and MEMORY.md
17
+ console.error('=== Phase 2: Scan references ===\n');
18
+ const referencedFiles = parseReferences(config);
19
+ console.error(` Found ${referencedFiles.size} referenced files.\n`);
20
+ // Phase 3: Process referenced files first
21
+ console.error('=== Phase 3: Process referenced files ===\n');
22
+ const memoryFiles = getMemoryFiles(config);
23
+ const referencedList = memoryFiles.filter(f => referencedFiles.has(f));
24
+ const orphanList = memoryFiles.filter(f => !referencedFiles.has(f));
25
+ if (referencedList.length > 0) {
26
+ console.error(` ${referencedList.length} referenced file(s) to process:\n`);
27
+ for (const filename of referencedList) {
28
+ const filePath = resolve(config.memoryDir, filename);
29
+ await processFile(config, filePath, existingNotes, stats);
30
+ }
31
+ }
32
+ else {
33
+ console.error(' No referenced files found.\n');
34
+ }
35
+ // Phase 4: Process CLAUDE.md rules
36
+ console.error('\n=== Phase 4: CLAUDE.md rules ===\n');
37
+ await processClaudeMd(config, existingNotes, stats);
38
+ // Phase 5: Process orphaned files
39
+ if (orphanList.length > 0) {
40
+ console.error('\n=== Phase 5: Orphaned files ===\n');
41
+ console.error(` ${orphanList.length} file(s) not linked in CLAUDE.md or MEMORY.md:\n`);
42
+ for (const filename of orphanList) {
43
+ const filePath = resolve(config.memoryDir, filename);
44
+ await processFile(config, filePath, existingNotes, stats);
45
+ }
46
+ }
47
+ // Phase 6: Summary
48
+ console.error('\n=== Migration complete ===\n');
49
+ console.error(` ${stats.uploaded} uploaded`);
50
+ console.error(` ${stats.combined} combined`);
51
+ console.error(` ${stats.alreadyInLedger} already in Ledger`);
52
+ console.error(` ${stats.skipped} skipped`);
53
+ console.error(` Backup: ${backupDir}`);
54
+ console.error(`\n Next: run \`ledger setup claude\` to install hooks and pull.\n`);
55
+ }
56
+ async function backupExisting(config) {
57
+ const date = new Date().toISOString().split('T')[0];
58
+ const backupDir = resolve(homedir(), '.ledger', 'migration-backup', date);
59
+ mkdirSync(backupDir, { recursive: true });
60
+ // Backup memory directory
61
+ if (existsSync(config.memoryDir)) {
62
+ const memBackup = join(backupDir, 'memory');
63
+ mkdirSync(memBackup, { recursive: true });
64
+ const files = readdirSync(config.memoryDir).filter(f => f.endsWith('.md'));
65
+ for (const f of files) {
66
+ const src = resolve(config.memoryDir, f);
67
+ const dst = join(memBackup, f);
68
+ cpSync(src, dst);
69
+ }
70
+ console.error(` Backed up ${files.length} memory files`);
71
+ }
72
+ // Backup global CLAUDE.md
73
+ if (existsSync(config.claudeMdPath)) {
74
+ cpSync(config.claudeMdPath, join(backupDir, 'CLAUDE.md'));
75
+ console.error(' Backed up CLAUDE.md');
76
+ }
77
+ return backupDir;
78
+ }
79
+ function parseReferences(config) {
80
+ const referenced = new Set();
81
+ // Parse MEMORY.md for linked files: [name](filename.md)
82
+ const memoryMdPath = resolve(config.memoryDir, 'MEMORY.md');
83
+ if (existsSync(memoryMdPath)) {
84
+ const content = readFileSync(memoryMdPath, 'utf-8');
85
+ const linkRegex = /\[.*?\]\(([^)]+\.md)\)/g;
86
+ let match;
87
+ while ((match = linkRegex.exec(content)) !== null) {
88
+ referenced.add(basename(match[1]));
89
+ }
90
+ console.error(` MEMORY.md links to ${referenced.size} files`);
91
+ }
92
+ // Parse CLAUDE.md for .md file references
93
+ if (existsSync(config.claudeMdPath)) {
94
+ const content = readFileSync(config.claudeMdPath, 'utf-8');
95
+ const mdRefRegex = /[\w_-]+\.md/g;
96
+ let match;
97
+ while ((match = mdRefRegex.exec(content)) !== null) {
98
+ const filename = match[0];
99
+ if (filename !== 'CLAUDE.md' && filename !== 'MEMORY.md' && filename !== 'README.md') {
100
+ referenced.add(filename);
101
+ }
102
+ }
103
+ }
104
+ return referenced;
105
+ }
106
+ function getMemoryFiles(config) {
107
+ if (!existsSync(config.memoryDir))
108
+ return [];
109
+ return readdirSync(config.memoryDir)
110
+ .filter(f => f.endsWith('.md') && f !== 'MEMORY.md');
111
+ }
112
+ async function processFile(config, filePath, existingNotes, stats) {
113
+ const filename = basename(filePath);
114
+ if (!existsSync(filePath))
115
+ return;
116
+ const content = readFileSync(filePath, 'utf-8').trim();
117
+ if (!content)
118
+ return;
119
+ const hash = contentHash(content);
120
+ console.error(` --- ${filename} ---`);
121
+ // Check 1: exact match by upsert_key
122
+ const upsertKey = filename.replace(/\.md$/, '').replace(/_/g, '-');
123
+ const keyMatch = existingNotes.find(n => n.metadata.upsert_key === upsertKey);
124
+ if (keyMatch) {
125
+ const ledgerHash = keyMatch.metadata.content_hash;
126
+ if (ledgerHash === hash) {
127
+ console.error(` Exact match in Ledger (note ${keyMatch.id}). Skipping.\n`);
128
+ stats.alreadyInLedger++;
129
+ return;
130
+ }
131
+ // Same key but different content
132
+ console.error(` Found in Ledger (note ${keyMatch.id}) but content differs.`);
133
+ await handleDifference(config, content, keyMatch, stats);
134
+ return;
135
+ }
136
+ // Check 2: exact match by content hash
137
+ const hashMatch = existingNotes.find(n => n.metadata.content_hash === hash);
138
+ if (hashMatch) {
139
+ const key = hashMatch.metadata.upsert_key || `note-${hashMatch.id}`;
140
+ console.error(` Identical content found in Ledger as "${key}". Skipping.\n`);
141
+ stats.alreadyInLedger++;
142
+ return;
143
+ }
144
+ // Check 3: semantic similarity
145
+ const similar = await searchNotes(config.supabase, config.openai, content, 0.5, 1);
146
+ if (similar.length > 0 && similar[0].similarity > 0.85) {
147
+ const match = similar[0];
148
+ const key = match.metadata.upsert_key || `note-${match.id}`;
149
+ console.error(` Similar to "${key}" (${(match.similarity * 100).toFixed(0)}% match)`);
150
+ await handleDifference(config, content, match, stats);
151
+ return;
152
+ }
153
+ // No match — new file
154
+ console.error(' Not in Ledger.');
155
+ const shouldUpload = await confirm(' Upload to Ledger?');
156
+ if (!shouldUpload) {
157
+ console.error(' Skipped.\n');
158
+ stats.skipped++;
159
+ return;
160
+ }
161
+ await uploadNewNote(config, filename, content, hash);
162
+ stats.uploaded++;
163
+ console.error('');
164
+ }
165
+ async function handleDifference(config, localContent, ledgerNote, stats) {
166
+ const key = ledgerNote.metadata.upsert_key || `note-${ledgerNote.id}`;
167
+ // Show preview of differences
168
+ const localLines = localContent.split('\n').length;
169
+ const ledgerLines = ledgerNote.content.split('\n').length;
170
+ console.error(`\n Local: ${localLines} lines`);
171
+ console.error(` Ledger "${key}": ${ledgerLines} lines`);
172
+ // Find lines only in local
173
+ const localSet = new Set(localContent.split('\n').map(l => l.trim()).filter(Boolean));
174
+ const ledgerSet = new Set(ledgerNote.content.split('\n').map(l => l.trim()).filter(Boolean));
175
+ const onlyLocal = [...localSet].filter(l => !ledgerSet.has(l));
176
+ const onlyLedger = [...ledgerSet].filter(l => !localSet.has(l));
177
+ if (onlyLocal.length > 0) {
178
+ console.error(`\n Only in local (${onlyLocal.length} lines):`);
179
+ for (const line of onlyLocal.slice(0, 10)) {
180
+ console.error(` + ${line}`);
181
+ }
182
+ if (onlyLocal.length > 10)
183
+ console.error(` ... and ${onlyLocal.length - 10} more`);
184
+ }
185
+ if (onlyLedger.length > 0) {
186
+ console.error(`\n Only in Ledger (${onlyLedger.length} lines):`);
187
+ for (const line of onlyLedger.slice(0, 10)) {
188
+ console.error(` - ${line}`);
189
+ }
190
+ if (onlyLedger.length > 10)
191
+ console.error(` ... and ${onlyLedger.length - 10} more`);
192
+ }
193
+ console.error('');
194
+ const action = await choose(' Action:', [
195
+ 'Combine (merge both)',
196
+ 'Keep Ledger version',
197
+ 'Keep local version',
198
+ 'Keep both as separate notes',
199
+ 'Skip',
200
+ ]);
201
+ switch (action) {
202
+ case 'Combine (merge both)': {
203
+ const combined = combineContent(ledgerNote.content, localContent);
204
+ await updateNote(config, ledgerNote.id, combined, ledgerNote.metadata);
205
+ console.error(` Combined into note ${ledgerNote.id}.\n`);
206
+ stats.combined++;
207
+ break;
208
+ }
209
+ case 'Keep Ledger version': {
210
+ console.error(` Kept Ledger version.\n`);
211
+ stats.alreadyInLedger++;
212
+ break;
213
+ }
214
+ case 'Keep local version': {
215
+ await updateNote(config, ledgerNote.id, localContent, ledgerNote.metadata);
216
+ console.error(` Updated Ledger with local version.\n`);
217
+ stats.uploaded++;
218
+ break;
219
+ }
220
+ case 'Keep both as separate notes': {
221
+ const filename = `migrated-${Date.now()}.md`;
222
+ await uploadNewNote(config, filename, localContent, contentHash(localContent));
223
+ console.error(` Added local as separate note.\n`);
224
+ stats.uploaded++;
225
+ break;
226
+ }
227
+ case 'Skip': {
228
+ console.error(' Skipped.\n');
229
+ stats.skipped++;
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ export function combineContent(ledgerContent, localContent) {
235
+ const ledgerLines = new Set(ledgerContent.split('\n').map(l => l.trim()));
236
+ const localLines = localContent.split('\n');
237
+ // Add lines from local that aren't in Ledger
238
+ const newLines = localLines.filter(l => !ledgerLines.has(l.trim()) && l.trim() !== '');
239
+ if (newLines.length === 0)
240
+ return ledgerContent;
241
+ return `${ledgerContent}\n\n${newLines.join('\n')}`;
242
+ }
243
+ async function processClaudeMd(config, existingNotes, stats) {
244
+ if (!existsSync(config.claudeMdPath)) {
245
+ console.error(' No CLAUDE.md found. Skipping.\n');
246
+ return;
247
+ }
248
+ const content = readFileSync(config.claudeMdPath, 'utf-8');
249
+ // Extract sections (## headings)
250
+ const sections = extractSections(content);
251
+ const feedbackNotes = existingNotes.filter(n => n.metadata.type === 'feedback');
252
+ // Pre-compute hash map for feedback notes to avoid repeated hashing
253
+ const feedbackHashMap = new Map();
254
+ for (const note of feedbackNotes) {
255
+ feedbackHashMap.set(contentHash(note.content), note);
256
+ }
257
+ console.error(` Found ${sections.length} sections in CLAUDE.md`);
258
+ console.error(` Ledger has ${feedbackNotes.length} feedback notes\n`);
259
+ for (const section of sections) {
260
+ const sectionContent = section.content.trim();
261
+ if (!sectionContent)
262
+ continue;
263
+ console.error(` --- ${section.heading} ---`);
264
+ // Check if this section's content exists in any feedback note
265
+ const hash = contentHash(sectionContent);
266
+ const exactMatch = feedbackHashMap.get(hash);
267
+ if (exactMatch) {
268
+ const key = exactMatch.metadata.upsert_key || `note-${exactMatch.id}`;
269
+ console.error(` Matches feedback note "${key}". Skipping.\n`);
270
+ stats.alreadyInLedger++;
271
+ continue;
272
+ }
273
+ // Semantic check
274
+ const similar = await searchNotes(config.supabase, config.openai, sectionContent, 0.5, 1);
275
+ if (similar.length > 0 && similar[0].similarity > 0.8) {
276
+ const match = similar[0];
277
+ const key = match.metadata.upsert_key || `note-${match.id}`;
278
+ console.error(` Similar to "${key}" (${(match.similarity * 100).toFixed(0)}% match)`);
279
+ // Check for lines that are in local but not in Ledger
280
+ const localSet = new Set(sectionContent.split('\n').map(l => l.trim()).filter(Boolean));
281
+ const ledgerSet = new Set(match.content.split('\n').map(l => l.trim()).filter(Boolean));
282
+ const onlyLocal = [...localSet].filter(l => !ledgerSet.has(l));
283
+ if (onlyLocal.length > 0) {
284
+ console.error(` Local has ${onlyLocal.length} lines not in Ledger:`);
285
+ for (const line of onlyLocal.slice(0, 5)) {
286
+ console.error(` + ${line}`);
287
+ }
288
+ const action = await choose(' Action:', [
289
+ 'Combine (add missing lines to Ledger)',
290
+ 'Keep Ledger version',
291
+ 'Skip',
292
+ ]);
293
+ if (action === 'Combine (add missing lines to Ledger)') {
294
+ const combined = combineContent(match.content, sectionContent);
295
+ await updateNote(config, match.id, combined, match.metadata);
296
+ console.error(` Combined into "${key}".\n`);
297
+ stats.combined++;
298
+ }
299
+ else {
300
+ console.error(' Kept Ledger version.\n');
301
+ stats.alreadyInLedger++;
302
+ }
303
+ }
304
+ else {
305
+ console.error(' Ledger version is a superset. Skipping.\n');
306
+ stats.alreadyInLedger++;
307
+ }
308
+ continue;
309
+ }
310
+ // New section not in Ledger
311
+ console.error(' Not found in Ledger.');
312
+ const shouldUpload = await confirm(' Upload as new feedback note?');
313
+ if (shouldUpload) {
314
+ const upsertKey = `feedback-${section.heading.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, '')}`;
315
+ await uploadFeedbackNote(config, upsertKey, sectionContent);
316
+ console.error(` Uploaded as "${upsertKey}".\n`);
317
+ stats.uploaded++;
318
+ }
319
+ else {
320
+ console.error(' Skipped.\n');
321
+ stats.skipped++;
322
+ }
323
+ }
324
+ }
325
+ export function extractSections(markdown) {
326
+ const sections = [];
327
+ const lines = markdown.split('\n');
328
+ let currentHeading = '';
329
+ let currentContent = [];
330
+ for (const line of lines) {
331
+ const headingMatch = line.match(/^#{1,3}\s+(.+)$/);
332
+ if (headingMatch) {
333
+ if (currentHeading && currentContent.length > 0) {
334
+ sections.push({ heading: currentHeading, content: currentContent.join('\n') });
335
+ }
336
+ currentHeading = headingMatch[1].trim();
337
+ currentContent = [];
338
+ }
339
+ else {
340
+ currentContent.push(line);
341
+ }
342
+ }
343
+ if (currentHeading && currentContent.length > 0) {
344
+ sections.push({ heading: currentHeading, content: currentContent.join('\n') });
345
+ }
346
+ return sections;
347
+ }
348
+ async function uploadNewNote(config, filename, content, hash) {
349
+ // Infer type from filename
350
+ let noteType = 'general';
351
+ if (filename.startsWith('feedback_') || filename.startsWith('feedback-'))
352
+ noteType = 'feedback';
353
+ else if (filename.startsWith('user_') || filename.startsWith('user-'))
354
+ noteType = 'user-preference';
355
+ else if (filename.startsWith('project_') || filename.startsWith('project-'))
356
+ noteType = 'project-status';
357
+ else if (filename.startsWith('reference_') || filename.startsWith('reference-'))
358
+ noteType = 'reference';
359
+ const embeddingResponse = await config.openai.embeddings.create({
360
+ model: 'text-embedding-3-small',
361
+ input: content,
362
+ });
363
+ const embedding = embeddingResponse.data[0].embedding;
364
+ const upsertKey = filename.replace(/\.md$/, '').replace(/_/g, '-');
365
+ // Check if upsert_key already exists to avoid duplicates on re-run
366
+ const { data: existing } = await config.supabase
367
+ .from('notes')
368
+ .select('id')
369
+ .eq('metadata->>upsert_key', upsertKey)
370
+ .limit(1)
371
+ .single();
372
+ if (existing) {
373
+ await updateNote(config, existing.id, content, {
374
+ type: noteType,
375
+ agent: 'ledger-migrate',
376
+ upsert_key: upsertKey,
377
+ local_file: filename,
378
+ content_hash: hash,
379
+ local_cache: true,
380
+ });
381
+ console.error(` Updated existing note ${existing.id} (type: ${noteType}, cached)`);
382
+ return;
383
+ }
384
+ const { data, error } = await config.supabase
385
+ .from('notes')
386
+ .insert({
387
+ content,
388
+ metadata: {
389
+ type: noteType,
390
+ agent: 'ledger-migrate',
391
+ upsert_key: upsertKey,
392
+ local_file: filename,
393
+ content_hash: hash,
394
+ local_cache: true,
395
+ },
396
+ embedding,
397
+ })
398
+ .select('id')
399
+ .single();
400
+ if (error) {
401
+ console.error(` Error uploading: ${error.message}`);
402
+ return;
403
+ }
404
+ console.error(` Uploaded (note ${data.id}, type: ${noteType}, cached)`);
405
+ }
406
+ async function uploadFeedbackNote(config, upsertKey, content) {
407
+ const embeddingResponse = await config.openai.embeddings.create({
408
+ model: 'text-embedding-3-small',
409
+ input: content,
410
+ });
411
+ const embedding = embeddingResponse.data[0].embedding;
412
+ const localFile = upsertKey.replace(/-/g, '_') + '.md';
413
+ const hash = contentHash(content);
414
+ const { data, error } = await config.supabase
415
+ .from('notes')
416
+ .insert({
417
+ content,
418
+ metadata: {
419
+ type: 'feedback',
420
+ agent: 'ledger-migrate',
421
+ upsert_key: upsertKey,
422
+ local_file: localFile,
423
+ content_hash: hash,
424
+ local_cache: true,
425
+ },
426
+ embedding,
427
+ })
428
+ .select('id')
429
+ .single();
430
+ if (error) {
431
+ console.error(` Error uploading: ${error.message}`);
432
+ return;
433
+ }
434
+ console.error(` Saved (note ${data.id})`);
435
+ }
436
+ async function updateNote(config, noteId, content, existingMetadata) {
437
+ const embeddingResponse = await config.openai.embeddings.create({
438
+ model: 'text-embedding-3-small',
439
+ input: content,
440
+ });
441
+ const embedding = embeddingResponse.data[0].embedding;
442
+ const hash = contentHash(content);
443
+ const metadata = { ...existingMetadata, content_hash: hash };
444
+ const { error } = await config.supabase
445
+ .from('notes')
446
+ .update({ content, embedding, metadata, updated_at: new Date().toISOString() })
447
+ .eq('id', noteId);
448
+ if (error) {
449
+ console.error(` Error updating: ${error.message}`);
450
+ }
451
+ }
452
+ async function fetchAllNotes(config) {
453
+ const { data, error } = await config.supabase
454
+ .from('notes')
455
+ .select('id, content, metadata, created_at, updated_at');
456
+ if (error) {
457
+ console.error(`Error fetching notes: ${error.message}`);
458
+ return [];
459
+ }
460
+ return (data || []);
461
+ }
@@ -51,4 +51,17 @@ if [[ "$FILE_PATH" =~ /.aws/credentials ]]; then
51
51
  exit 2
52
52
  fi
53
53
 
54
+ # Block .md file creation — knowledge lives in Ledger, not in local files
55
+ # Allowed exceptions: README.md, CLAUDE.md, MEMORY.md, devlog.md, CHANGELOG.md
56
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
57
+ if [[ "$TOOL_NAME" == "Write" ]] && [[ "$FILENAME" == *.md ]]; then
58
+ case "$FILENAME" in
59
+ README.md|CLAUDE.md|MEMORY.md|devlog.md|CHANGELOG.md) ;;
60
+ *)
61
+ echo "BLOCKED: Do not create .md files locally. Write to Ledger instead using add_note or update_note. Allowed: README.md, CLAUDE.md, MEMORY.md, devlog.md" >&2
62
+ exit 2
63
+ ;;
64
+ esac
65
+ fi
66
+
54
67
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aperdomoll90/ledger-ai",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "AI identity and memory system — portable persona, knowledge sync, semantic search across agents and devices",
5
5
  "type": "module",
6
6
  "bin": {