@aperdomoll90/ledger-ai 1.0.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.
Files changed (37) hide show
  1. package/README.md +120 -0
  2. package/dist/cli.js +153 -0
  3. package/dist/commands/backup.js +89 -0
  4. package/dist/commands/check.js +79 -0
  5. package/dist/commands/config.js +115 -0
  6. package/dist/commands/export.js +21 -0
  7. package/dist/commands/ingest.js +221 -0
  8. package/dist/commands/init.js +142 -0
  9. package/dist/commands/onboard.js +190 -0
  10. package/dist/commands/pull.js +75 -0
  11. package/dist/commands/push.js +21 -0
  12. package/dist/commands/restore.js +72 -0
  13. package/dist/commands/setup.js +159 -0
  14. package/dist/commands/show.js +34 -0
  15. package/dist/hooks/block-env.sh +54 -0
  16. package/dist/hooks/hooks/block-env.sh +54 -0
  17. package/dist/hooks/hooks/post-write-ledger.sh +40 -0
  18. package/dist/hooks/hooks/session-end-check.sh +23 -0
  19. package/dist/hooks/post-write-ledger.sh +40 -0
  20. package/dist/hooks/session-end-check.sh +23 -0
  21. package/dist/lib/config.js +63 -0
  22. package/dist/lib/errors.js +23 -0
  23. package/dist/lib/generators.js +115 -0
  24. package/dist/lib/hash.js +4 -0
  25. package/dist/lib/migrate.js +23 -0
  26. package/dist/lib/notes.js +105 -0
  27. package/dist/lib/prompt.js +65 -0
  28. package/dist/mcp-server.js +348 -0
  29. package/dist/migrations/000-tracking.sql +4 -0
  30. package/dist/migrations/001-schema.sql +27 -0
  31. package/dist/migrations/002-functions.sql +14 -0
  32. package/dist/migrations/003-rls.sql +5 -0
  33. package/dist/migrations/migrations/000-tracking.sql +4 -0
  34. package/dist/migrations/migrations/001-schema.sql +27 -0
  35. package/dist/migrations/migrations/002-functions.sql +14 -0
  36. package/dist/migrations/migrations/003-rls.sql +5 -0
  37. package/package.json +60 -0
@@ -0,0 +1,115 @@
1
+ // --- CLAUDE.md Section Mapping ---
2
+ const SECTION_MAP = {
3
+ 'Security': ['feedback-no-read-env'],
4
+ 'Coding Conventions': ['feedback-coding-conventions'],
5
+ 'Architecture': [
6
+ 'feedback-mcp-registration',
7
+ 'feedback-prefer-cli-and-skills',
8
+ 'feedback-repo-docs-structure',
9
+ 'feedback-project-logs',
10
+ ],
11
+ 'Communication': ['feedback-communication-style'],
12
+ };
13
+ // --- Helpers ---
14
+ function extractBulletPoints(content) {
15
+ const lines = content.split('\n');
16
+ const bullets = [];
17
+ for (const line of lines) {
18
+ const trimmed = line.trim();
19
+ if (trimmed.startsWith('---') || trimmed.startsWith('#') || trimmed === '')
20
+ continue;
21
+ if (trimmed.startsWith('Why:') || trimmed.startsWith('**Why:**'))
22
+ continue;
23
+ if (trimmed.startsWith('How to apply:') || trimmed.startsWith('**How to apply:**'))
24
+ continue;
25
+ if (trimmed.startsWith('Follow these') ||
26
+ trimmed.startsWith('Never') ||
27
+ trimmed.startsWith('Always') ||
28
+ trimmed.startsWith('When') ||
29
+ trimmed.startsWith('Before')) {
30
+ bullets.push(`- ${trimmed}`);
31
+ }
32
+ else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
33
+ bullets.push(trimmed);
34
+ }
35
+ }
36
+ return bullets.join('\n');
37
+ }
38
+ // --- Generators ---
39
+ export function generateClaudeMd(notes) {
40
+ const notesByKey = new Map();
41
+ for (const note of notes) {
42
+ const key = note.metadata.upsert_key;
43
+ if (key)
44
+ notesByKey.set(key, note);
45
+ }
46
+ const sections = ['# Global Rules'];
47
+ const usedKeys = new Set();
48
+ for (const [sectionName, keys] of Object.entries(SECTION_MAP)) {
49
+ const sectionBullets = [];
50
+ for (const key of keys) {
51
+ const note = notesByKey.get(key);
52
+ if (note) {
53
+ sectionBullets.push(extractBulletPoints(note.content));
54
+ usedKeys.add(key);
55
+ }
56
+ }
57
+ if (sectionBullets.length > 0) {
58
+ sections.push(`\n## ${sectionName}\n${sectionBullets.join('\n')}`);
59
+ }
60
+ }
61
+ const unmapped = [];
62
+ for (const note of notes) {
63
+ const key = note.metadata.upsert_key;
64
+ const type = note.metadata.type;
65
+ if (key && !usedKeys.has(key) && type === 'feedback') {
66
+ unmapped.push(extractBulletPoints(note.content));
67
+ usedKeys.add(key);
68
+ }
69
+ }
70
+ if (unmapped.length > 0) {
71
+ sections.push(`\n## General\n${unmapped.join('\n')}`);
72
+ }
73
+ return sections.join('\n') + '\n';
74
+ }
75
+ export function generateMemoryMd(files) {
76
+ const userFiles = [];
77
+ const feedbackFiles = [];
78
+ const projectFiles = [];
79
+ for (const file of files) {
80
+ if (file.startsWith('user_'))
81
+ userFiles.push(file);
82
+ else if (file.startsWith('feedback_'))
83
+ feedbackFiles.push(file);
84
+ else if (file.startsWith('project_'))
85
+ projectFiles.push(file);
86
+ }
87
+ const lines = [
88
+ '# Memory Index',
89
+ '',
90
+ 'Local cache files auto-loaded into Claude Code context. Source of truth is Ledger.',
91
+ '',
92
+ ];
93
+ if (userFiles.length > 0) {
94
+ lines.push('## User Profile');
95
+ for (const f of userFiles)
96
+ lines.push(`- [${f}](${f})`);
97
+ lines.push('');
98
+ }
99
+ if (feedbackFiles.length > 0) {
100
+ lines.push('## Feedback (Behavioral Rules)');
101
+ for (const f of feedbackFiles)
102
+ lines.push(`- [${f}](${f})`);
103
+ lines.push('');
104
+ }
105
+ if (projectFiles.length > 0) {
106
+ lines.push('## Project Status');
107
+ for (const f of projectFiles)
108
+ lines.push(`- [${f}](${f})`);
109
+ lines.push('');
110
+ }
111
+ lines.push('## Not Auto-Loaded (Search Ledger)');
112
+ lines.push('Architecture, references, project details, events, errors — all in Ledger, search on demand.');
113
+ lines.push('');
114
+ return lines.join('\n');
115
+ }
@@ -0,0 +1,4 @@
1
+ import { createHash } from 'crypto';
2
+ export function contentHash(content) {
3
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
4
+ }
@@ -0,0 +1,23 @@
1
+ import { readFileSync, readdirSync } from 'fs';
2
+ import { resolve, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const MIGRATIONS_DIR = resolve(__dirname, '../migrations');
6
+ export function getMigrationFiles() {
7
+ return readdirSync(MIGRATIONS_DIR)
8
+ .filter(f => f.endsWith('.sql'))
9
+ .sort();
10
+ }
11
+ export function readMigration(filename) {
12
+ return readFileSync(resolve(MIGRATIONS_DIR, filename), 'utf-8');
13
+ }
14
+ export async function getAppliedMigrations(supabase) {
15
+ const { data, error } = await supabase
16
+ .from('schema_migrations')
17
+ .select('version');
18
+ if (error) {
19
+ // Table doesn't exist yet — no migrations applied
20
+ return new Set();
21
+ }
22
+ return new Set((data || []).map(r => r.version));
23
+ }
@@ -0,0 +1,105 @@
1
+ import { fatal, ExitCode } from './errors.js';
2
+ import { contentHash } from './hash.js';
3
+ // --- 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
13
+ .from('notes')
14
+ .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
+ }
23
+ return Array.from(allNotes.values());
24
+ }
25
+ export async function findNoteByFile(supabase, filename) {
26
+ const { data: byFile } = await supabase
27
+ .from('notes')
28
+ .select('id, metadata')
29
+ .eq('metadata->>local_file', filename)
30
+ .limit(1)
31
+ .single();
32
+ if (byFile)
33
+ return byFile;
34
+ const upsertKey = filename.replace(/\.md$/, '');
35
+ const { data: byKey } = await supabase
36
+ .from('notes')
37
+ .select('id, metadata')
38
+ .eq('metadata->>upsert_key', upsertKey)
39
+ .limit(1)
40
+ .single();
41
+ return byKey || null;
42
+ }
43
+ export async function updateNoteContent(supabase, openai, noteId, content) {
44
+ const embeddingResponse = await openai.embeddings.create({
45
+ model: 'text-embedding-3-small',
46
+ input: content,
47
+ });
48
+ const embedding = embeddingResponse.data[0].embedding;
49
+ const { error } = await supabase
50
+ .from('notes')
51
+ .update({
52
+ content,
53
+ embedding,
54
+ updated_at: new Date().toISOString(),
55
+ })
56
+ .eq('id', noteId);
57
+ if (error) {
58
+ fatal(`Error updating note: ${error.message}`, ExitCode.SUPABASE_ERROR);
59
+ }
60
+ }
61
+ export async function searchNotes(supabase, openai, query, threshold = 0.3, maxResults = 1) {
62
+ const embeddingResponse = await openai.embeddings.create({
63
+ model: 'text-embedding-3-small',
64
+ input: query,
65
+ });
66
+ const embedding = embeddingResponse.data[0].embedding;
67
+ const { data, error } = await supabase.rpc('match_notes', {
68
+ q_emb: JSON.stringify(embedding),
69
+ threshold,
70
+ max_results: maxResults,
71
+ });
72
+ if (error) {
73
+ fatal(`Error searching Ledger: ${error.message}`, ExitCode.SUPABASE_ERROR);
74
+ }
75
+ return (data || []);
76
+ }
77
+ export async function fetchNoteHashes(supabase) {
78
+ const notes = await fetchCachedNotes(supabase);
79
+ return notes
80
+ .filter(n => n.metadata.local_file)
81
+ .map(n => ({
82
+ id: n.id,
83
+ localFile: n.metadata.local_file,
84
+ contentHash: n.metadata.content_hash || contentHash(n.content),
85
+ content: n.content,
86
+ }));
87
+ }
88
+ export async function updateNoteHash(supabase, noteId, hash) {
89
+ const { data: note, error: fetchError } = await supabase
90
+ .from('notes')
91
+ .select('metadata')
92
+ .eq('id', noteId)
93
+ .single();
94
+ if (fetchError) {
95
+ fatal(`Error fetching note metadata: ${fetchError.message}`, ExitCode.SUPABASE_ERROR);
96
+ }
97
+ const metadata = { ...note.metadata, content_hash: hash };
98
+ const { error } = await supabase
99
+ .from('notes')
100
+ .update({ metadata })
101
+ .eq('id', noteId);
102
+ if (error) {
103
+ fatal(`Error updating note hash: ${error.message}`, ExitCode.SUPABASE_ERROR);
104
+ }
105
+ }
@@ -0,0 +1,65 @@
1
+ import { createInterface } from 'readline';
2
+ export async function ask(question) {
3
+ const rl = createInterface({
4
+ input: process.stdin,
5
+ output: process.stderr,
6
+ });
7
+ return new Promise((resolve) => {
8
+ rl.question(question, (answer) => {
9
+ rl.close();
10
+ resolve(answer.trim());
11
+ });
12
+ });
13
+ }
14
+ export async function askMasked(question) {
15
+ return new Promise((resolve) => {
16
+ process.stderr.write(question);
17
+ let input = '';
18
+ if (process.stdin.isTTY) {
19
+ process.stdin.setRawMode(true);
20
+ }
21
+ process.stdin.resume();
22
+ const onData = (buf) => {
23
+ const c = buf.toString();
24
+ if (c === '\n' || c === '\r') {
25
+ process.stdin.removeListener('data', onData);
26
+ if (process.stdin.isTTY) {
27
+ process.stdin.setRawMode(false);
28
+ }
29
+ process.stdin.pause();
30
+ process.stderr.write('\n');
31
+ resolve(input.trim());
32
+ }
33
+ else if (c === '\u007f' || c === '\b') {
34
+ if (input.length > 0) {
35
+ input = input.slice(0, -1);
36
+ process.stderr.write('\b \b');
37
+ }
38
+ }
39
+ else if (c === '\u0003') {
40
+ // Ctrl+C
41
+ process.exit(1);
42
+ }
43
+ else {
44
+ input += c;
45
+ process.stderr.write('*');
46
+ }
47
+ };
48
+ process.stdin.on('data', onData);
49
+ });
50
+ }
51
+ export async function confirm(question) {
52
+ const answer = await ask(`${question} [y/N] `);
53
+ return answer === 'y' || answer === 'yes';
54
+ }
55
+ export async function choose(question, options) {
56
+ const optionList = options.map((o, i) => ` ${i + 1}. ${o}`).join('\n');
57
+ const answer = await ask(`${question}\n${optionList}\n> `);
58
+ const index = parseInt(answer, 10) - 1;
59
+ if (index >= 0 && index < options.length) {
60
+ return options[index];
61
+ }
62
+ // Try matching by name
63
+ const match = options.find(o => o.toLowerCase().startsWith(answer));
64
+ return match || options[0];
65
+ }
@@ -0,0 +1,348 @@
1
+ import 'dotenv/config';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { createClient } from '@supabase/supabase-js';
5
+ import OpenAI from 'openai';
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;
11
+ // --- Clients ---
12
+ const supabaseUrl = process.env.SUPABASE_URL;
13
+ const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
14
+ const openaiKey = process.env.OPENAI_API_KEY;
15
+ if (!supabaseUrl || !supabaseKey) {
16
+ console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY. Run `ledger init` to configure.');
17
+ process.exit(1);
18
+ }
19
+ if (!openaiKey) {
20
+ console.error('Missing OPENAI_API_KEY. Run `ledger init` to configure.');
21
+ process.exit(1);
22
+ }
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
+ }
67
+ // --- MCP Server ---
68
+ const server = new McpServer({
69
+ name: 'ledger',
70
+ version: '1.0.0',
71
+ });
72
+ // Tool: Search notes by semantic similarity
73
+ server.tool('search_notes', 'Search memories by meaning using semantic similarity. If a result is chunked, all sibling chunks are returned reassembled.', {
74
+ query: z.string().describe('What to search for'),
75
+ threshold: z.coerce.number().min(0).max(1).default(0.5).describe('Minimum similarity score (0-1)'),
76
+ limit: z.coerce.number().min(1).max(50).default(10).describe('Max results to return'),
77
+ type: z.string().optional().describe('Filter by note type (e.g. feedback, reference, event)'),
78
+ project: z.string().optional().describe('Filter by project name'),
79
+ }, 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') }] };
134
+ });
135
+ // Tool: Add a new note (with automatic chunking for large content)
136
+ 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
+ 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'),
139
+ agent: z.string().describe('Which agent is saving this note (e.g. claude-code, zhuli)'),
140
+ 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
+ };
208
+ });
209
+ // 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.', {
211
+ id: z.coerce.number().describe('The note ID to update'),
212
+ content: z.string().describe('The new content'),
213
+ 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
+ };
280
+ });
281
+ // Tool: List recent notes
282
+ server.tool('list_notes', 'List recent notes from the knowledge base', {
283
+ limit: z.coerce.number().min(1).max(100).default(20).describe('Number of notes to return'),
284
+ type: z.string().optional().describe('Filter by note type (e.g. feedback, reference, event)'),
285
+ project: z.string().optional().describe('Filter by project name'),
286
+ }, 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 }] };
312
+ });
313
+ // 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.', {
315
+ 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.` }] };
345
+ });
346
+ // --- Start ---
347
+ const transport = new StdioServerTransport();
348
+ await server.connect(transport);
@@ -0,0 +1,4 @@
1
+ CREATE TABLE IF NOT EXISTS schema_migrations (
2
+ version text PRIMARY KEY,
3
+ applied_at timestamptz DEFAULT now()
4
+ );