@aperdomoll90/ledger-ai 1.0.2 → 1.1.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.
@@ -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 { randomUUID, createHash } from 'crypto';
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 supabase = createClient(supabaseUrl, supabaseKey);
24
- const openai = new OpenAI({ apiKey: openaiKey });
25
- // --- Helpers ---
26
- function contentHash(text) {
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 embedding = await getEmbedding(query);
81
- // Fetch more results than needed so we can filter and still meet limit
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 (with automatic chunking for large content)
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
- }, async ({ content, type, agent, metadata }) => {
142
- // Merge type and agent into metadata, include content hash
143
- const fullMetadata = { ...metadata, type, agent, content_hash: contentHash(content) };
144
- const upsertKey = metadata.upsert_key;
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
- }, async ({ id, content, metadata }) => {
215
- // Check if this note is part of a chunk group
216
- const { data: existing, error: fetchError } = await supabase
217
- .from('notes')
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
- let query = supabase
288
- .from('notes')
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
- }, async ({ id }) => {
317
- // Check if this note is part of a chunk group
318
- const { data: existing } = await supabase
319
- .from('notes')
320
- .select('metadata')
321
- .eq('id', id)
322
- .single();
323
- if (existing) {
324
- const meta = existing.metadata;
325
- const groupId = meta.chunk_group;
326
- if (groupId) {
327
- const { error } = await supabase
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aperdomoll90/ledger-ai",
3
- "version": "1.0.2",
3
+ "version": "1.1.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": {