@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/cli.js +93 -4
- package/dist/commands/add.js +25 -0
- package/dist/commands/delete.js +22 -0
- package/dist/commands/ingest.js +22 -9
- package/dist/commands/init.js +80 -26
- package/dist/commands/list.js +10 -0
- package/dist/commands/migrate.js +9 -8
- package/dist/commands/onboard.js +3 -3
- package/dist/commands/pull.js +2 -2
- package/dist/commands/setup.js +88 -4
- package/dist/commands/sync.js +206 -0
- package/dist/commands/tag.js +20 -0
- package/dist/commands/update.js +22 -0
- package/dist/commands/wizard.js +430 -0
- package/dist/hooks/hooks/session-end-check.sh +3 -3
- package/dist/hooks/hooks/strip-ai-coauthor.sh +22 -0
- package/dist/lib/notes.js +430 -18
- package/dist/mcp-server.js +33 -293
- package/package.json +1 -1
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
|
|
5
|
-
const { data
|
|
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
|
-
.
|
|
16
|
-
if (
|
|
17
|
-
fatal(`Error querying
|
|
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
|
|
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
|
|
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
|
+
}
|