@aperdomoll90/ledger-ai 1.0.2 → 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 +70 -23
- 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/mcp-server.js
CHANGED
|
@@ -4,10 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import { createClient } from '@supabase/supabase-js';
|
|
5
5
|
import OpenAI from 'openai';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import {
|
|
8
|
-
// --- Constants ---
|
|
9
|
-
const MAX_CHARS_PER_CHUNK = 25_000;
|
|
10
|
-
const CHUNK_OVERLAP = 2_000;
|
|
7
|
+
import { opSearchNotes, opListNotes, opAddNote, opUpdateNote, opUpdateMetadata, opDeleteNote, } from './lib/notes.js';
|
|
11
8
|
// --- Clients ---
|
|
12
9
|
const supabaseUrl = process.env.SUPABASE_URL;
|
|
13
10
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
@@ -20,50 +17,10 @@ if (!openaiKey) {
|
|
|
20
17
|
console.error('Missing OPENAI_API_KEY. Run `ledger init` to configure.');
|
|
21
18
|
process.exit(1);
|
|
22
19
|
}
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return createHash('sha256').update(text, 'utf-8').digest('hex');
|
|
28
|
-
}
|
|
29
|
-
async function getEmbedding(text) {
|
|
30
|
-
const response = await openai.embeddings.create({
|
|
31
|
-
model: 'text-embedding-3-small',
|
|
32
|
-
input: text,
|
|
33
|
-
});
|
|
34
|
-
return response.data[0].embedding;
|
|
35
|
-
}
|
|
36
|
-
function chunkText(text, maxChars, overlap) {
|
|
37
|
-
if (text.length <= maxChars)
|
|
38
|
-
return [text];
|
|
39
|
-
const paragraphs = text.split(/\n\n+/);
|
|
40
|
-
const chunks = [];
|
|
41
|
-
let current = '';
|
|
42
|
-
for (const paragraph of paragraphs) {
|
|
43
|
-
if (current.length + paragraph.length + 2 > maxChars && current.length > 0) {
|
|
44
|
-
chunks.push(current.trim());
|
|
45
|
-
// Start next chunk with overlap from end of current
|
|
46
|
-
const overlapText = current.slice(-overlap);
|
|
47
|
-
current = overlapText + '\n\n' + paragraph;
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
current = current ? current + '\n\n' + paragraph : paragraph;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
if (current.trim()) {
|
|
54
|
-
chunks.push(current.trim());
|
|
55
|
-
}
|
|
56
|
-
// Edge case: a single paragraph exceeds maxChars — force split by character
|
|
57
|
-
return chunks.flatMap((chunk) => {
|
|
58
|
-
if (chunk.length <= maxChars)
|
|
59
|
-
return [chunk];
|
|
60
|
-
const forced = [];
|
|
61
|
-
for (let i = 0; i < chunk.length; i += maxChars - overlap) {
|
|
62
|
-
forced.push(chunk.slice(i, i + maxChars));
|
|
63
|
-
}
|
|
64
|
-
return forced;
|
|
65
|
-
});
|
|
66
|
-
}
|
|
20
|
+
const clients = {
|
|
21
|
+
supabase: createClient(supabaseUrl, supabaseKey),
|
|
22
|
+
openai: new OpenAI({ apiKey: openaiKey }),
|
|
23
|
+
};
|
|
67
24
|
// --- MCP Server ---
|
|
68
25
|
const server = new McpServer({
|
|
69
26
|
name: 'ledger',
|
|
@@ -77,206 +34,29 @@ server.tool('search_notes', 'Search memories by meaning using semantic similarit
|
|
|
77
34
|
type: z.string().optional().describe('Filter by note type (e.g. feedback, reference, event)'),
|
|
78
35
|
project: z.string().optional().describe('Filter by project name'),
|
|
79
36
|
}, async ({ query, threshold, limit, type, project }) => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
const fetchLimit = (type || project) ? limit * 3 : limit;
|
|
83
|
-
const { data, error } = await supabase.rpc('match_notes', {
|
|
84
|
-
q_emb: JSON.stringify(embedding),
|
|
85
|
-
threshold,
|
|
86
|
-
max_results: fetchLimit,
|
|
87
|
-
});
|
|
88
|
-
if (error) {
|
|
89
|
-
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
90
|
-
}
|
|
91
|
-
let results = data;
|
|
92
|
-
// Apply filters
|
|
93
|
-
if (type) {
|
|
94
|
-
results = results.filter(n => n.metadata.type === type);
|
|
95
|
-
}
|
|
96
|
-
if (project) {
|
|
97
|
-
results = results.filter(n => n.metadata.project === project);
|
|
98
|
-
}
|
|
99
|
-
results = results.slice(0, limit);
|
|
100
|
-
if (!results || results.length === 0) {
|
|
101
|
-
return { content: [{ type: 'text', text: 'No matching notes found.' }] };
|
|
102
|
-
}
|
|
103
|
-
// Reassemble chunked notes: fetch all sibling chunks for any chunked result
|
|
104
|
-
const seenGroups = new Set();
|
|
105
|
-
const output = [];
|
|
106
|
-
for (const note of results) {
|
|
107
|
-
const meta = note.metadata;
|
|
108
|
-
const groupId = meta.chunk_group;
|
|
109
|
-
if (groupId) {
|
|
110
|
-
if (seenGroups.has(groupId))
|
|
111
|
-
continue;
|
|
112
|
-
seenGroups.add(groupId);
|
|
113
|
-
// Fetch all chunks in this group, ordered by index
|
|
114
|
-
const { data: siblings, error: sibError } = await supabase
|
|
115
|
-
.from('notes')
|
|
116
|
-
.select('id, content, metadata, created_at')
|
|
117
|
-
.eq('metadata->>chunk_group', groupId)
|
|
118
|
-
.order('metadata->>chunk_index', { ascending: true });
|
|
119
|
-
if (sibError || !siblings || siblings.length === 0) {
|
|
120
|
-
output.push(`[${note.id}] (similarity: ${note.similarity.toFixed(3)}) [chunked, sibling fetch failed]\n${note.content}\nMetadata: ${JSON.stringify(note.metadata)}`);
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
const reassembled = siblings.map((s) => s.content).join('\n\n');
|
|
124
|
-
const firstId = siblings[0].id;
|
|
125
|
-
const chunkCount = siblings.length;
|
|
126
|
-
output.push(`[${firstId}] (similarity: ${note.similarity.toFixed(3)}) [${chunkCount} chunks reassembled]\n${reassembled}\nMetadata: ${JSON.stringify({ ...meta, chunk_group: groupId, chunks: chunkCount })}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
else {
|
|
130
|
-
output.push(`[${note.id}] (similarity: ${note.similarity.toFixed(3)})\n${note.content}\nMetadata: ${JSON.stringify(note.metadata)}`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return { content: [{ type: 'text', text: output.join('\n\n---\n\n') }] };
|
|
37
|
+
const result = await opSearchNotes(clients, query, threshold, limit, type, project);
|
|
38
|
+
return { content: [{ type: 'text', text: result.message }] };
|
|
134
39
|
});
|
|
135
|
-
// Tool: Add a new note
|
|
40
|
+
// Tool: Add a new note
|
|
136
41
|
server.tool('add_note', 'Save a new memory/note to the knowledge base. Large notes are automatically chunked for embedding. Use upsert_key in metadata to update an existing note instead of creating a duplicate.', {
|
|
137
42
|
content: z.string().describe('The note content to save'),
|
|
138
|
-
type: z.enum(['user-preference', 'feedback', 'architecture-decision', 'project-status', 'reference', 'event', 'error', 'general']).describe('Note type for consistent categorization'),
|
|
43
|
+
type: z.enum(['user-preference', 'feedback', 'architecture-decision', 'project-status', 'reference', 'event', 'error', 'knowledge-guide', 'general']).describe('Note type for consistent categorization'),
|
|
139
44
|
agent: z.string().describe('Which agent is saving this note (e.g. claude-code, zhuli)'),
|
|
140
45
|
metadata: z.record(z.string(), z.unknown()).default({}).describe('Optional metadata (project, local_file, upsert_key, etc.)'),
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
// If upsert_key provided, check for existing note and update it instead
|
|
146
|
-
if (upsertKey) {
|
|
147
|
-
const { data: existing } = await supabase
|
|
148
|
-
.from('notes')
|
|
149
|
-
.select('id, metadata')
|
|
150
|
-
.eq('metadata->>upsert_key', upsertKey)
|
|
151
|
-
.limit(1)
|
|
152
|
-
.single();
|
|
153
|
-
if (existing) {
|
|
154
|
-
// Delete old note (and its chunks if any)
|
|
155
|
-
const oldMeta = existing.metadata;
|
|
156
|
-
const oldGroup = oldMeta.chunk_group;
|
|
157
|
-
if (oldGroup) {
|
|
158
|
-
await supabase.from('notes').delete().eq('metadata->>chunk_group', oldGroup);
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
await supabase.from('notes').delete().eq('id', existing.id);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
const chunks = chunkText(content, MAX_CHARS_PER_CHUNK, CHUNK_OVERLAP);
|
|
166
|
-
if (chunks.length === 1) {
|
|
167
|
-
// Single note — no chunking needed
|
|
168
|
-
const embedding = await getEmbedding(content);
|
|
169
|
-
const { data, error } = await supabase
|
|
170
|
-
.from('notes')
|
|
171
|
-
.insert({ content, metadata: fullMetadata, embedding })
|
|
172
|
-
.select('id, created_at')
|
|
173
|
-
.single();
|
|
174
|
-
if (error) {
|
|
175
|
-
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
176
|
-
}
|
|
177
|
-
const uKey = fullMetadata.upsert_key;
|
|
178
|
-
const label = uKey || `id ${data.id}`;
|
|
179
|
-
const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
|
|
180
|
-
return {
|
|
181
|
-
content: [{ type: 'text', text: `Saved "${label}" (id: ${data.id}, type: ${type}, ${content.length} chars)\nPreview: ${preview}` }],
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
// Multiple chunks — embed and store each with shared group ID
|
|
185
|
-
const groupId = randomUUID();
|
|
186
|
-
const ids = [];
|
|
187
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
188
|
-
const chunkMeta = {
|
|
189
|
-
...fullMetadata,
|
|
190
|
-
chunk_group: groupId,
|
|
191
|
-
chunk_index: i,
|
|
192
|
-
total_chunks: chunks.length,
|
|
193
|
-
};
|
|
194
|
-
const embedding = await getEmbedding(chunks[i]);
|
|
195
|
-
const { data, error } = await supabase
|
|
196
|
-
.from('notes')
|
|
197
|
-
.insert({ content: chunks[i], metadata: chunkMeta, embedding })
|
|
198
|
-
.select('id')
|
|
199
|
-
.single();
|
|
200
|
-
if (error) {
|
|
201
|
-
return { content: [{ type: 'text', text: `Error saving chunk ${i + 1}/${chunks.length}: ${error.message}` }] };
|
|
202
|
-
}
|
|
203
|
-
ids.push(data.id);
|
|
204
|
-
}
|
|
205
|
-
return {
|
|
206
|
-
content: [{ type: 'text', text: `Saved "${fullMetadata.upsert_key || 'chunked'}" as ${chunks.length} chunks (ids: ${ids.join(', ')}, ${content.length} chars total)` }],
|
|
207
|
-
};
|
|
46
|
+
force: z.boolean().default(false).describe('Skip duplicate check and force creation of a new note'),
|
|
47
|
+
}, async ({ content, type, agent, metadata, force }) => {
|
|
48
|
+
const result = await opAddNote(clients, content, type, agent, metadata, force);
|
|
49
|
+
return { content: [{ type: 'text', text: result.message }] };
|
|
208
50
|
});
|
|
209
51
|
// Tool: Update an existing note by ID
|
|
210
|
-
server.tool('update_note', 'Update an existing note by ID. Replaces content and re-generates embedding. If the note was chunked, all chunks are replaced.', {
|
|
52
|
+
server.tool('update_note', 'Update an existing note by ID. Replaces content and re-generates embedding. If the note was chunked, all chunks are replaced. First call without confirmed shows current content for verification. Call with confirmed: true to execute.', {
|
|
211
53
|
id: z.coerce.number().describe('The note ID to update'),
|
|
212
54
|
content: z.string().describe('The new content'),
|
|
213
55
|
metadata: z.record(z.string(), z.unknown()).optional().describe('Optional: replace metadata (keeps existing if omitted)'),
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
.select('id, metadata')
|
|
219
|
-
.eq('id', id)
|
|
220
|
-
.single();
|
|
221
|
-
if (fetchError || !existing) {
|
|
222
|
-
return { content: [{ type: 'text', text: `Error: note ${id} not found.` }] };
|
|
223
|
-
}
|
|
224
|
-
const existingMeta = existing.metadata;
|
|
225
|
-
const groupId = existingMeta.chunk_group;
|
|
226
|
-
const baseMeta = metadata ?? existingMeta;
|
|
227
|
-
// Delete old chunks if this was a chunked note
|
|
228
|
-
if (groupId) {
|
|
229
|
-
await supabase.from('notes').delete().eq('metadata->>chunk_group', groupId);
|
|
230
|
-
}
|
|
231
|
-
else {
|
|
232
|
-
await supabase.from('notes').delete().eq('id', id);
|
|
233
|
-
}
|
|
234
|
-
// Re-insert with chunking support
|
|
235
|
-
const chunks = chunkText(content, MAX_CHARS_PER_CHUNK, CHUNK_OVERLAP);
|
|
236
|
-
if (chunks.length === 1) {
|
|
237
|
-
const embedding = await getEmbedding(content);
|
|
238
|
-
// Remove old chunk metadata, add content hash
|
|
239
|
-
const { chunk_group, chunk_index, total_chunks, ...cleanMeta } = baseMeta;
|
|
240
|
-
const updatedMeta = { ...cleanMeta, content_hash: contentHash(content) };
|
|
241
|
-
const { data, error } = await supabase
|
|
242
|
-
.from('notes')
|
|
243
|
-
.insert({ content, metadata: updatedMeta, embedding })
|
|
244
|
-
.select('id, created_at')
|
|
245
|
-
.single();
|
|
246
|
-
if (error) {
|
|
247
|
-
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
248
|
-
}
|
|
249
|
-
const uKey = baseMeta.upsert_key;
|
|
250
|
-
const label = uKey || `id ${data.id}`;
|
|
251
|
-
const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
|
|
252
|
-
return {
|
|
253
|
-
content: [{ type: 'text', text: `Updated "${label}" (id: ${data.id}, ${content.length} chars)\nPreview: ${preview}` }],
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
const newGroupId = randomUUID();
|
|
257
|
-
const ids = [];
|
|
258
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
259
|
-
const { chunk_group, chunk_index, total_chunks, ...cleanMeta } = baseMeta;
|
|
260
|
-
const chunkMeta = {
|
|
261
|
-
...cleanMeta,
|
|
262
|
-
chunk_group: newGroupId,
|
|
263
|
-
chunk_index: i,
|
|
264
|
-
total_chunks: chunks.length,
|
|
265
|
-
};
|
|
266
|
-
const embedding = await getEmbedding(chunks[i]);
|
|
267
|
-
const { data, error } = await supabase
|
|
268
|
-
.from('notes')
|
|
269
|
-
.insert({ content: chunks[i], metadata: chunkMeta, embedding })
|
|
270
|
-
.select('id')
|
|
271
|
-
.single();
|
|
272
|
-
if (error) {
|
|
273
|
-
return { content: [{ type: 'text', text: `Error updating chunk ${i + 1}/${chunks.length}: ${error.message}` }] };
|
|
274
|
-
}
|
|
275
|
-
ids.push(data.id);
|
|
276
|
-
}
|
|
277
|
-
return {
|
|
278
|
-
content: [{ type: 'text', text: `Note updated as ${chunks.length} chunks (ids: ${ids.join(', ')}, group: ${newGroupId})` }],
|
|
279
|
-
};
|
|
56
|
+
confirmed: z.boolean().default(false).describe('Set to true to execute the update. Without this, shows the current note for confirmation.'),
|
|
57
|
+
}, async ({ id, content, metadata, confirmed }) => {
|
|
58
|
+
const result = await opUpdateNote(clients, id, content, metadata, confirmed);
|
|
59
|
+
return { content: [{ type: 'text', text: result.message }] };
|
|
280
60
|
});
|
|
281
61
|
// Tool: List recent notes
|
|
282
62
|
server.tool('list_notes', 'List recent notes from the knowledge base', {
|
|
@@ -284,64 +64,24 @@ server.tool('list_notes', 'List recent notes from the knowledge base', {
|
|
|
284
64
|
type: z.string().optional().describe('Filter by note type (e.g. feedback, reference, event)'),
|
|
285
65
|
project: z.string().optional().describe('Filter by project name'),
|
|
286
66
|
}, async ({ limit, type, project }) => {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
.select('id, content, metadata, created_at')
|
|
290
|
-
.order('created_at', { ascending: false })
|
|
291
|
-
.limit(limit);
|
|
292
|
-
if (type) {
|
|
293
|
-
query = query.eq('metadata->>type', type);
|
|
294
|
-
}
|
|
295
|
-
if (project) {
|
|
296
|
-
query = query.eq('metadata->>project', project);
|
|
297
|
-
}
|
|
298
|
-
const { data, error } = await query;
|
|
299
|
-
if (error) {
|
|
300
|
-
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
301
|
-
}
|
|
302
|
-
const notes = data;
|
|
303
|
-
if (!notes || notes.length === 0) {
|
|
304
|
-
return { content: [{ type: 'text', text: 'No notes found.' }] };
|
|
305
|
-
}
|
|
306
|
-
const formatted = notes.map((note) => {
|
|
307
|
-
const meta = note.metadata;
|
|
308
|
-
const chunkInfo = meta.chunk_group ? ` [chunk ${meta.chunk_index + 1}/${meta.total_chunks}]` : '';
|
|
309
|
-
return `[${note.id}]${chunkInfo} ${note.created_at}\n${note.content.slice(0, 200)}${note.content.length > 200 ? '...' : ''}\nMetadata: ${JSON.stringify(note.metadata)}`;
|
|
310
|
-
}).join('\n\n---\n\n');
|
|
311
|
-
return { content: [{ type: 'text', text: formatted }] };
|
|
67
|
+
const result = await opListNotes(clients, limit, type, project);
|
|
68
|
+
return { content: [{ type: 'text', text: result.message }] };
|
|
312
69
|
});
|
|
313
70
|
// Tool: Delete a note
|
|
314
|
-
server.tool('delete_note', 'Delete a note from the knowledge base by ID. If the note is chunked, all chunks in the group are deleted.', {
|
|
71
|
+
server.tool('delete_note', 'Delete a note from the knowledge base by ID. If the note is chunked, all chunks in the group are deleted. First call without confirmed shows the note for verification. Call with confirmed: true to execute.', {
|
|
315
72
|
id: z.coerce.number().describe('The note ID to delete'),
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
.from('notes')
|
|
329
|
-
.delete()
|
|
330
|
-
.eq('metadata->>chunk_group', groupId);
|
|
331
|
-
if (error) {
|
|
332
|
-
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
333
|
-
}
|
|
334
|
-
return { content: [{ type: 'text', text: `Deleted all chunks in group ${groupId}.` }] };
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
const { error } = await supabase
|
|
338
|
-
.from('notes')
|
|
339
|
-
.delete()
|
|
340
|
-
.eq('id', id);
|
|
341
|
-
if (error) {
|
|
342
|
-
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
343
|
-
}
|
|
344
|
-
return { content: [{ type: 'text', text: `Note ${id} deleted.` }] };
|
|
73
|
+
confirmed: z.boolean().default(false).describe('Set to true to execute the deletion. Without this, shows the note for confirmation.'),
|
|
74
|
+
}, async ({ id, confirmed }) => {
|
|
75
|
+
const result = await opDeleteNote(clients, id, confirmed);
|
|
76
|
+
return { content: [{ type: 'text', text: result.message }] };
|
|
77
|
+
});
|
|
78
|
+
// Tool: Update metadata only (no content change, no confirmation needed)
|
|
79
|
+
server.tool('update_metadata', 'Update metadata fields on an existing note without changing content. Useful for adding descriptions, tags, project, or scope.', {
|
|
80
|
+
id: z.coerce.number().describe('The note ID to update'),
|
|
81
|
+
metadata: z.record(z.string(), z.unknown()).describe('Metadata fields to merge (existing fields are preserved unless overwritten)'),
|
|
82
|
+
}, async ({ id, metadata }) => {
|
|
83
|
+
const result = await opUpdateMetadata(clients, id, metadata);
|
|
84
|
+
return { content: [{ type: 'text', text: result.message }] };
|
|
345
85
|
});
|
|
346
86
|
// --- Start ---
|
|
347
87
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED