@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 +8 -0
- package/dist/commands/migrate.js +461 -0
- package/dist/hooks/hooks/block-env.sh +13 -0
- package/package.json +1 -1
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