@aperdomoll90/ledger-ai 1.0.1 → 1.1.0

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/lib/notes.js CHANGED
@@ -1,26 +1,29 @@
1
+ import { randomUUID } from 'crypto';
1
2
  import { fatal, ExitCode } from './errors.js';
2
3
  import { contentHash } from './hash.js';
4
+ const DELIVERY_BY_TYPE = {
5
+ 'user-preference': 'persona',
6
+ 'feedback': 'persona',
7
+ 'architecture-decision': 'project',
8
+ 'project-status': 'project',
9
+ 'error': 'project',
10
+ 'event': 'project',
11
+ 'reference': 'knowledge',
12
+ 'general': 'knowledge',
13
+ };
14
+ export function inferDelivery(noteType) {
15
+ return DELIVERY_BY_TYPE[noteType] ?? 'knowledge';
16
+ }
3
17
  // --- Queries ---
4
- export async function fetchCachedNotes(supabase) {
5
- const { data: cachedNotes, error: cacheError } = await supabase
6
- .from('notes')
7
- .select('id, content, metadata, created_at, updated_at')
8
- .eq('metadata->>local_cache', 'true');
9
- if (cacheError) {
10
- fatal(`Error querying Ledger: ${cacheError.message}`, ExitCode.SUPABASE_ERROR);
11
- }
12
- const { data: ruleNotes, error: ruleError } = await supabase
18
+ export async function fetchPersonaNotes(supabase) {
19
+ const { data, error } = await supabase
13
20
  .from('notes')
14
21
  .select('id, content, metadata, created_at, updated_at')
15
- .in('metadata->>type', ['feedback', 'user-preference']);
16
- if (ruleError) {
17
- fatal(`Error querying rule notes: ${ruleError.message}`, ExitCode.SUPABASE_ERROR);
18
- }
19
- const allNotes = new Map();
20
- for (const note of [...(cachedNotes || []), ...(ruleNotes || [])]) {
21
- allNotes.set(note.id, note);
22
+ .eq('metadata->>delivery', 'persona');
23
+ if (error) {
24
+ fatal(`Error querying persona notes: ${error.message}`, ExitCode.SUPABASE_ERROR);
22
25
  }
23
- return Array.from(allNotes.values());
26
+ return (data || []);
24
27
  }
25
28
  export async function findNoteByFile(supabase, filename) {
26
29
  const { data: byFile } = await supabase
@@ -75,7 +78,7 @@ export async function searchNotes(supabase, openai, query, threshold = 0.3, maxR
75
78
  return (data || []);
76
79
  }
77
80
  export async function fetchNoteHashes(supabase) {
78
- const notes = await fetchCachedNotes(supabase);
81
+ const notes = await fetchPersonaNotes(supabase);
79
82
  return notes
80
83
  .filter(n => n.metadata.local_file)
81
84
  .map(n => ({
@@ -85,6 +88,36 @@ export async function fetchNoteHashes(supabase) {
85
88
  content: n.content,
86
89
  }));
87
90
  }
91
+ // --- Chunking ---
92
+ export function chunkText(text, maxChars, overlap) {
93
+ if (text.length <= maxChars)
94
+ return [text];
95
+ const paragraphs = text.split(/\n\n+/);
96
+ const chunks = [];
97
+ let current = '';
98
+ for (const paragraph of paragraphs) {
99
+ if (current.length + paragraph.length + 2 > maxChars && current.length > 0) {
100
+ chunks.push(current.trim());
101
+ const overlapText = current.slice(-overlap);
102
+ current = overlapText + '\n\n' + paragraph;
103
+ }
104
+ else {
105
+ current = current ? current + '\n\n' + paragraph : paragraph;
106
+ }
107
+ }
108
+ if (current.trim()) {
109
+ chunks.push(current.trim());
110
+ }
111
+ return chunks.flatMap((chunk) => {
112
+ if (chunk.length <= maxChars)
113
+ return [chunk];
114
+ const forced = [];
115
+ for (let i = 0; i < chunk.length; i += maxChars - overlap) {
116
+ forced.push(chunk.slice(i, i + maxChars));
117
+ }
118
+ return forced;
119
+ });
120
+ }
88
121
  export async function updateNoteHash(supabase, noteId, hash) {
89
122
  const { data: note, error: fetchError } = await supabase
90
123
  .from('notes')
@@ -103,3 +136,382 @@ export async function updateNoteHash(supabase, noteId, hash) {
103
136
  fatal(`Error updating note hash: ${error.message}`, ExitCode.SUPABASE_ERROR);
104
137
  }
105
138
  }
139
+ // --- Constants ---
140
+ const MAX_CHARS_PER_CHUNK = 25_000;
141
+ const CHUNK_OVERLAP = 2_000;
142
+ // --- Shared Helpers ---
143
+ export async function getEmbedding(openai, text) {
144
+ const response = await openai.embeddings.create({
145
+ model: 'text-embedding-3-small',
146
+ input: text,
147
+ });
148
+ return response.data[0].embedding;
149
+ }
150
+ function formatNotePreview(id, meta, content, maxLen = 300) {
151
+ const uKey = meta.upsert_key;
152
+ const noteType = meta.type;
153
+ const project = meta.project;
154
+ const desc = meta.description;
155
+ const label = uKey || `id-${id}`;
156
+ const preview = content.slice(0, maxLen).replace(/\n/g, '\n ');
157
+ const truncated = content.length > maxLen ? '...' : '';
158
+ const descLine = desc ? `\nDescription: ${desc}` : '';
159
+ return `"${label}" (id: ${id}) | type: ${noteType || '-'} | project: ${project || '-'}${descLine}\n ${preview}${truncated}`;
160
+ }
161
+ // --- Shared Operations (called by both MCP and CLI) ---
162
+ export async function opSearchNotes(clients, query, threshold, limit, type, project) {
163
+ const embedding = await getEmbedding(clients.openai, query);
164
+ const fetchLimit = (type || project) ? limit * 3 : limit;
165
+ const { data, error } = await clients.supabase.rpc('match_notes', {
166
+ q_emb: JSON.stringify(embedding),
167
+ threshold,
168
+ max_results: fetchLimit,
169
+ });
170
+ if (error) {
171
+ return { status: 'error', message: `Error: ${error.message}` };
172
+ }
173
+ let results = data;
174
+ if (type)
175
+ results = results.filter(n => n.metadata.type === type);
176
+ if (project)
177
+ results = results.filter(n => n.metadata.project === project);
178
+ results = results.slice(0, limit);
179
+ // Fallback: retry at 0.3 when requested threshold returns empty
180
+ if ((!results || results.length === 0) && threshold > 0.3) {
181
+ const { data: fallbackData, error: fallbackError } = await clients.supabase.rpc('match_notes', {
182
+ q_emb: JSON.stringify(embedding),
183
+ threshold: 0.3,
184
+ max_results: fetchLimit,
185
+ });
186
+ if (!fallbackError && fallbackData && fallbackData.length > 0) {
187
+ let fallbackResults = fallbackData;
188
+ if (type)
189
+ fallbackResults = fallbackResults.filter(n => n.metadata.type === type);
190
+ if (project)
191
+ fallbackResults = fallbackResults.filter(n => n.metadata.project === project);
192
+ fallbackResults = fallbackResults.slice(0, limit);
193
+ if (fallbackResults.length > 0) {
194
+ results = fallbackResults;
195
+ const output = await reassembleResults(clients.supabase, results);
196
+ return { status: 'ok', message: '⚠ No strong matches found. Showing low-confidence results (similarity 0.3–0.5):\n\n' + output };
197
+ }
198
+ }
199
+ }
200
+ if (!results || results.length === 0) {
201
+ return { status: 'ok', message: 'No matching notes found.' };
202
+ }
203
+ const output = await reassembleResults(clients.supabase, results);
204
+ return { status: 'ok', message: output };
205
+ }
206
+ async function reassembleResults(supabase, results) {
207
+ const seenGroups = new Set();
208
+ const output = [];
209
+ for (const note of results) {
210
+ const meta = note.metadata;
211
+ const groupId = meta.chunk_group;
212
+ if (groupId) {
213
+ if (seenGroups.has(groupId))
214
+ continue;
215
+ seenGroups.add(groupId);
216
+ const { data: siblings, error: sibError } = await supabase
217
+ .from('notes')
218
+ .select('id, content, metadata, created_at')
219
+ .eq('metadata->>chunk_group', groupId)
220
+ .order('metadata->>chunk_index', { ascending: true });
221
+ if (sibError || !siblings || siblings.length === 0) {
222
+ output.push(`[${note.id}] (similarity: ${note.similarity.toFixed(3)}) [chunked, sibling fetch failed]\n${note.content}\nMetadata: ${JSON.stringify(note.metadata)}`);
223
+ }
224
+ else {
225
+ const reassembled = siblings.map((s) => s.content).join('\n\n');
226
+ output.push(`[${siblings[0].id}] (similarity: ${note.similarity.toFixed(3)}) [${siblings.length} chunks reassembled]\n${reassembled}\nMetadata: ${JSON.stringify({ ...meta, chunks: siblings.length })}`);
227
+ }
228
+ }
229
+ else {
230
+ output.push(`[${note.id}] (similarity: ${note.similarity.toFixed(3)})\n${note.content}\nMetadata: ${JSON.stringify(note.metadata)}`);
231
+ }
232
+ }
233
+ return output.join('\n\n---\n\n');
234
+ }
235
+ export async function opListNotes(clients, limit, type, project) {
236
+ let query = clients.supabase
237
+ .from('notes')
238
+ .select('id, content, metadata, created_at')
239
+ .order('created_at', { ascending: false })
240
+ .limit(limit);
241
+ if (type)
242
+ query = query.eq('metadata->>type', type);
243
+ if (project)
244
+ query = query.eq('metadata->>project', project);
245
+ const { data, error } = await query;
246
+ if (error) {
247
+ return { status: 'error', message: `Error: ${error.message}` };
248
+ }
249
+ if (!data || data.length === 0) {
250
+ return { status: 'ok', message: 'No notes found.' };
251
+ }
252
+ const formatted = data.map((note) => {
253
+ const meta = note.metadata;
254
+ const chunkInfo = meta.chunk_group ? ` [chunk ${meta.chunk_index + 1}/${meta.total_chunks}]` : '';
255
+ return `[${note.id}]${chunkInfo} ${note.created_at}\n${note.content.slice(0, 200)}${note.content.length > 200 ? '...' : ''}\nMetadata: ${JSON.stringify(note.metadata)}`;
256
+ }).join('\n\n---\n\n');
257
+ return { status: 'ok', message: formatted };
258
+ }
259
+ export async function opAddNote(clients, content, type, agent, metadata, force) {
260
+ const fullMetadata = { ...metadata, type, agent, content_hash: contentHash(content) };
261
+ const upsertKey = metadata.upsert_key;
262
+ // Duplicate guard: if no upsert_key and not forced, check for similar notes
263
+ if (!upsertKey && !force) {
264
+ try {
265
+ const embedding = await getEmbedding(clients.openai, content.slice(0, 2000));
266
+ const { data: similar } = await clients.supabase.rpc('match_notes', {
267
+ q_emb: JSON.stringify(embedding),
268
+ threshold: 0.6,
269
+ max_results: 5,
270
+ });
271
+ if (similar && similar.length > 0) {
272
+ const suggestions = similar.map((n) => {
273
+ const meta = n.metadata;
274
+ const key = meta.upsert_key;
275
+ const proj = meta.project;
276
+ const nType = meta.type;
277
+ const preview = n.content.slice(0, 200).replace(/\n/g, ' ');
278
+ return ` [${n.id}] similarity: ${n.similarity.toFixed(3)} | key: "${key || 'none'}" | project: ${proj || '-'} | type: ${nType || '-'}\n ${preview}...`;
279
+ }).join('\n\n');
280
+ return {
281
+ status: 'confirm',
282
+ message: `SIMILAR NOTES FOUND — ask the user before proceeding:\n\n${suggestions}\n\nOptions:\n 1. Update an existing note: re-call add_note with that note's upsert_key\n 2. Create new note anyway: re-call add_note with force: true\n\nAsk the user: "Should I update [note description] or create a new note?"`,
283
+ };
284
+ }
285
+ }
286
+ catch {
287
+ // If similarity check fails, proceed with creation
288
+ }
289
+ }
290
+ // Upsert: if upsert_key provided, update existing note
291
+ if (upsertKey) {
292
+ const { data: existing } = await clients.supabase
293
+ .from('notes')
294
+ .select('id, metadata, created_at')
295
+ .eq('metadata->>upsert_key', upsertKey)
296
+ .limit(1)
297
+ .single();
298
+ if (existing) {
299
+ return upsertExistingNote(clients, existing, content, fullMetadata);
300
+ }
301
+ }
302
+ // Create new note
303
+ return createNewNote(clients, content, fullMetadata);
304
+ }
305
+ async function upsertExistingNote(clients, existing, content, fullMetadata) {
306
+ const oldGroup = existing.metadata.chunk_group;
307
+ const oldCreatedAt = existing.created_at;
308
+ const newChunks = chunkText(content, MAX_CHARS_PER_CHUNK, CHUNK_OVERLAP);
309
+ const upsertKey = fullMetadata.upsert_key;
310
+ // Single-chunk → single-chunk: real SQL UPDATE (preserves ID + created_at)
311
+ if (!oldGroup && newChunks.length === 1) {
312
+ const embedding = await getEmbedding(clients.openai, content);
313
+ const { data, error } = await clients.supabase
314
+ .from('notes')
315
+ .update({ content, metadata: fullMetadata, embedding })
316
+ .eq('id', existing.id)
317
+ .select('id, created_at')
318
+ .single();
319
+ if (error)
320
+ return { status: 'error', message: `Error: ${error.message}` };
321
+ const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
322
+ return { status: 'ok', message: `Updated "${upsertKey}" (id: ${data.id}, ${content.length} chars)\nPreview: ${preview}` };
323
+ }
324
+ // Chunk count changed — delete old, preserve ID for first insert
325
+ const preserveId = existing.id;
326
+ if (oldGroup) {
327
+ await clients.supabase.from('notes').delete().eq('metadata->>chunk_group', oldGroup);
328
+ }
329
+ else {
330
+ await clients.supabase.from('notes').delete().eq('id', existing.id);
331
+ }
332
+ if (newChunks.length === 1) {
333
+ const embedding = await getEmbedding(clients.openai, content);
334
+ const { data, error } = await clients.supabase
335
+ .from('notes')
336
+ .insert({ id: preserveId, content, metadata: fullMetadata, embedding, created_at: oldCreatedAt })
337
+ .select('id, created_at')
338
+ .single();
339
+ if (error)
340
+ return { status: 'error', message: `Error: ${error.message}` };
341
+ const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
342
+ return { status: 'ok', message: `Updated "${upsertKey}" (id: ${data.id}, ${content.length} chars)\nPreview: ${preview}` };
343
+ }
344
+ // Multiple new chunks
345
+ const newGroupId = randomUUID();
346
+ const ids = [];
347
+ for (let i = 0; i < newChunks.length; i++) {
348
+ const chunkMeta = { ...fullMetadata, chunk_group: newGroupId, chunk_index: i, total_chunks: newChunks.length };
349
+ const embedding = await getEmbedding(clients.openai, newChunks[i]);
350
+ const insertData = { content: newChunks[i], metadata: chunkMeta, embedding, created_at: oldCreatedAt };
351
+ if (i === 0)
352
+ insertData.id = preserveId;
353
+ const { data, error } = await clients.supabase.from('notes').insert(insertData).select('id').single();
354
+ if (error)
355
+ return { status: 'error', message: `Error saving chunk ${i + 1}/${newChunks.length}: ${error.message}` };
356
+ ids.push(data.id);
357
+ }
358
+ return { status: 'ok', message: `Updated "${upsertKey}" as ${newChunks.length} chunks (ids: ${ids.join(', ')}, ${content.length} chars total)` };
359
+ }
360
+ async function createNewNote(clients, content, fullMetadata) {
361
+ const chunks = chunkText(content, MAX_CHARS_PER_CHUNK, CHUNK_OVERLAP);
362
+ const upsertKey = fullMetadata.upsert_key;
363
+ if (chunks.length === 1) {
364
+ const embedding = await getEmbedding(clients.openai, content);
365
+ const { data, error } = await clients.supabase
366
+ .from('notes')
367
+ .insert({ content, metadata: fullMetadata, embedding })
368
+ .select('id, created_at')
369
+ .single();
370
+ if (error)
371
+ return { status: 'error', message: `Error: ${error.message}` };
372
+ const label = upsertKey || `id ${data.id}`;
373
+ const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
374
+ return { status: 'ok', message: `Saved "${label}" (id: ${data.id}, type: ${fullMetadata.type}, ${content.length} chars)\nPreview: ${preview}` };
375
+ }
376
+ const groupId = randomUUID();
377
+ const ids = [];
378
+ for (let i = 0; i < chunks.length; i++) {
379
+ const chunkMeta = { ...fullMetadata, chunk_group: groupId, chunk_index: i, total_chunks: chunks.length };
380
+ const embedding = await getEmbedding(clients.openai, chunks[i]);
381
+ const { data, error } = await clients.supabase.from('notes').insert({ content: chunks[i], metadata: chunkMeta, embedding }).select('id').single();
382
+ if (error)
383
+ return { status: 'error', message: `Error saving chunk ${i + 1}/${chunks.length}: ${error.message}` };
384
+ ids.push(data.id);
385
+ }
386
+ return { status: 'ok', message: `Saved "${upsertKey || 'chunked'}" as ${chunks.length} chunks (ids: ${ids.join(', ')}, ${content.length} chars total)` };
387
+ }
388
+ export async function opUpdateNote(clients, id, content, metadata, confirmed = false) {
389
+ const { data: existing, error: fetchError } = await clients.supabase
390
+ .from('notes')
391
+ .select('id, content, metadata, created_at')
392
+ .eq('id', id)
393
+ .single();
394
+ if (fetchError || !existing) {
395
+ return { status: 'error', message: `Error: note ${id} not found.` };
396
+ }
397
+ // Confirmation gate
398
+ if (!confirmed) {
399
+ const currentPreview = formatNotePreview(existing.id, existing.metadata, existing.content);
400
+ const newPreview = content.slice(0, 300).replace(/\n/g, '\n ');
401
+ return {
402
+ status: 'confirm',
403
+ message: `CONFIRM UPDATE — Is this the correct note to apply this change to?\n\n${currentPreview}\n\nNew content:\n ${newPreview}${content.length > 300 ? '...' : ''}\n\nTo proceed, call update_note again with confirmed: true.`,
404
+ };
405
+ }
406
+ const existingMeta = existing.metadata;
407
+ const groupId = existingMeta.chunk_group;
408
+ const oldCreatedAt = existing.created_at;
409
+ const baseMeta = metadata ?? existingMeta;
410
+ const chunks = chunkText(content, MAX_CHARS_PER_CHUNK, CHUNK_OVERLAP);
411
+ // Single-chunk → single-chunk: real SQL UPDATE
412
+ if (!groupId && chunks.length === 1) {
413
+ const embedding = await getEmbedding(clients.openai, content);
414
+ const updatedMeta = { ...baseMeta, content_hash: contentHash(content) };
415
+ const { data, error } = await clients.supabase
416
+ .from('notes')
417
+ .update({ content, metadata: updatedMeta, embedding })
418
+ .eq('id', id)
419
+ .select('id, created_at')
420
+ .single();
421
+ if (error)
422
+ return { status: 'error', message: `Error: ${error.message}` };
423
+ const uKey = baseMeta.upsert_key;
424
+ const label = uKey || `id ${data.id}`;
425
+ const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
426
+ return { status: 'ok', message: `Updated "${label}" (id: ${data.id}, ${content.length} chars)\nPreview: ${preview}` };
427
+ }
428
+ // Chunk count changed — delete old, re-insert with preserved ID
429
+ const preserveId = id;
430
+ if (groupId) {
431
+ await clients.supabase.from('notes').delete().eq('metadata->>chunk_group', groupId);
432
+ }
433
+ else {
434
+ await clients.supabase.from('notes').delete().eq('id', id);
435
+ }
436
+ if (chunks.length === 1) {
437
+ const embedding = await getEmbedding(clients.openai, content);
438
+ const { chunk_group, chunk_index, total_chunks, ...cleanMeta } = baseMeta;
439
+ const updatedMeta = { ...cleanMeta, content_hash: contentHash(content) };
440
+ const { data, error } = await clients.supabase
441
+ .from('notes')
442
+ .insert({ id: preserveId, content, metadata: updatedMeta, embedding, created_at: oldCreatedAt })
443
+ .select('id, created_at')
444
+ .single();
445
+ if (error)
446
+ return { status: 'error', message: `Error: ${error.message}` };
447
+ const uKey = baseMeta.upsert_key;
448
+ const label = uKey || `id ${data.id}`;
449
+ const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
450
+ return { status: 'ok', message: `Updated "${label}" (id: ${data.id}, ${content.length} chars)\nPreview: ${preview}` };
451
+ }
452
+ const newGroupId = randomUUID();
453
+ const ids = [];
454
+ for (let i = 0; i < chunks.length; i++) {
455
+ const { chunk_group, chunk_index, total_chunks, ...cleanMeta } = baseMeta;
456
+ const chunkMeta = { ...cleanMeta, content_hash: contentHash(content), chunk_group: newGroupId, chunk_index: i, total_chunks: chunks.length };
457
+ const embedding = await getEmbedding(clients.openai, chunks[i]);
458
+ const insertData = { content: chunks[i], metadata: chunkMeta, embedding, created_at: oldCreatedAt };
459
+ if (i === 0)
460
+ insertData.id = preserveId;
461
+ const { data, error } = await clients.supabase.from('notes').insert(insertData).select('id').single();
462
+ if (error)
463
+ return { status: 'error', message: `Error updating chunk ${i + 1}/${chunks.length}: ${error.message}` };
464
+ ids.push(data.id);
465
+ }
466
+ return { status: 'ok', message: `Note updated as ${chunks.length} chunks (ids: ${ids.join(', ')}, group: ${newGroupId})` };
467
+ }
468
+ export async function opUpdateMetadata(clients, id, metadata) {
469
+ const { data: existing, error: fetchError } = await clients.supabase
470
+ .from('notes')
471
+ .select('id, metadata')
472
+ .eq('id', id)
473
+ .single();
474
+ if (fetchError || !existing) {
475
+ return { status: 'error', message: `Error: note ${id} not found.` };
476
+ }
477
+ const merged = { ...existing.metadata, ...metadata };
478
+ const { error } = await clients.supabase
479
+ .from('notes')
480
+ .update({ metadata: merged })
481
+ .eq('id', id);
482
+ if (error)
483
+ return { status: 'error', message: `Error: ${error.message}` };
484
+ const uKey = merged.upsert_key;
485
+ return { status: 'ok', message: `Updated metadata for "${uKey || `id ${id}`}"` };
486
+ }
487
+ export async function opDeleteNote(clients, id, confirmed = false) {
488
+ const { data: existing } = await clients.supabase
489
+ .from('notes')
490
+ .select('id, content, metadata')
491
+ .eq('id', id)
492
+ .single();
493
+ if (!existing) {
494
+ return { status: 'error', message: `Error: note ${id} not found.` };
495
+ }
496
+ const meta = existing.metadata;
497
+ const groupId = meta.chunk_group;
498
+ // Confirmation gate
499
+ if (!confirmed) {
500
+ const chunkInfo = groupId ? ` (chunked, all chunks will be deleted)` : '';
501
+ const preview = formatNotePreview(existing.id, meta, existing.content);
502
+ return {
503
+ status: 'confirm',
504
+ message: `CONFIRM DELETE — This note will be permanently removed${chunkInfo}:\n\n${preview}\n\nTo proceed, call delete_note again with confirmed: true.`,
505
+ };
506
+ }
507
+ if (groupId) {
508
+ const { error } = await clients.supabase.from('notes').delete().eq('metadata->>chunk_group', groupId);
509
+ if (error)
510
+ return { status: 'error', message: `Error: ${error.message}` };
511
+ return { status: 'ok', message: `Deleted all chunks in group ${groupId}.` };
512
+ }
513
+ const { error } = await clients.supabase.from('notes').delete().eq('id', id);
514
+ if (error)
515
+ return { status: 'error', message: `Error: ${error.message}` };
516
+ return { status: 'ok', message: `Note ${id} deleted.` };
517
+ }