@aperdomoll90/ledger-ai 1.3.0 → 1.4.2

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 (84) hide show
  1. package/dist/cli.js +177 -221
  2. package/dist/commands/add.js +51 -100
  3. package/dist/commands/backfill.js +55 -0
  4. package/dist/commands/backup.js +10 -10
  5. package/dist/commands/check.js +21 -29
  6. package/dist/commands/config.js +13 -12
  7. package/dist/commands/delete.js +22 -17
  8. package/dist/commands/eval-judge.js +11 -0
  9. package/dist/commands/eval.js +321 -0
  10. package/dist/commands/export.js +8 -10
  11. package/dist/commands/get.js +9 -0
  12. package/dist/commands/hunt.js +206 -0
  13. package/dist/commands/ingest.js +15 -14
  14. package/dist/commands/init.js +18 -20
  15. package/dist/commands/list.js +21 -7
  16. package/dist/commands/migrate.js +11 -11
  17. package/dist/commands/onboard.js +2 -2
  18. package/dist/commands/pull.js +3 -2
  19. package/dist/commands/push.js +8 -8
  20. package/dist/commands/restore.js +38 -38
  21. package/dist/commands/show.js +13 -16
  22. package/dist/commands/sync.js +58 -19
  23. package/dist/commands/tag.js +20 -14
  24. package/dist/commands/update.js +50 -18
  25. package/dist/commands/wizard.js +3 -3
  26. package/dist/lib/ai-search.js +163 -0
  27. package/dist/lib/audit.js +19 -0
  28. package/dist/lib/backfill.js +60 -0
  29. package/dist/lib/config.js +19 -2
  30. package/dist/lib/document-classification.js +5 -0
  31. package/dist/lib/document-fetching.js +77 -0
  32. package/dist/lib/document-operations.js +150 -0
  33. package/dist/lib/documents/classification.js +5 -0
  34. package/dist/lib/documents/fetching.js +89 -0
  35. package/dist/lib/documents/operations.js +304 -0
  36. package/dist/lib/domains.js +116 -0
  37. package/dist/lib/embeddings.js +190 -0
  38. package/dist/lib/errors.js +3 -1
  39. package/dist/lib/eval/eval-advanced.js +289 -0
  40. package/dist/lib/eval/eval-judge-session.js +233 -0
  41. package/dist/lib/eval/eval-store.js +105 -0
  42. package/dist/lib/eval/eval.js +303 -0
  43. package/dist/lib/file-writer.js +23 -0
  44. package/dist/lib/generators.js +44 -45
  45. package/dist/lib/hunter-db.js +235 -0
  46. package/dist/lib/hunter-rss.js +30 -0
  47. package/dist/lib/hunter-scoring.js +55 -0
  48. package/dist/lib/hunter-types.js +36 -0
  49. package/dist/lib/lint-configs.js +20 -0
  50. package/dist/lib/migrate.js +2 -2
  51. package/dist/lib/notes.js +173 -59
  52. package/dist/lib/observability.js +296 -0
  53. package/dist/lib/op-add-note-types.test.js +7 -6
  54. package/dist/lib/prompt.js +8 -8
  55. package/dist/lib/rate-limiter.js +103 -0
  56. package/dist/lib/search/ai-search.js +396 -0
  57. package/dist/lib/search/chunk-context-enrichment.js +155 -0
  58. package/dist/lib/search/embeddings.js +293 -0
  59. package/dist/lib/search/reranker.js +120 -0
  60. package/dist/lib/search/semantic-cache.js +53 -0
  61. package/dist/lib/type-registry.test.js +6 -6
  62. package/dist/mcp-server.js +553 -66
  63. package/dist/migrations/migrations/005-audit-log.sql +22 -0
  64. package/dist/migrations/migrations/005_opportunities.sql +48 -0
  65. package/dist/migrations/migrations/006-audited-operations.sql +235 -0
  66. package/dist/migrations/migrations/006_hunt_analytics.sql +38 -0
  67. package/dist/migrations/migrations/007-eval-golden-judgments.sql +119 -0
  68. package/dist/migrations/migrations/008-drop-expected-doc-ids.sql +9 -0
  69. package/dist/migrations/migrations/008-judge-helpers.sql +21 -0
  70. package/dist/migrations/migrations/009-semantic-cache.sql +216 -0
  71. package/dist/scripts/batch-grade.js +344 -0
  72. package/dist/scripts/benchmark-ingestion.js +376 -0
  73. package/dist/scripts/convert-judgments-to-graded.js +88 -0
  74. package/dist/scripts/diagnose-first-result.js +333 -0
  75. package/dist/scripts/drop-golden-query.js +53 -0
  76. package/dist/scripts/eval-search.js +115 -0
  77. package/dist/scripts/grade-unjudged-top1.js +138 -0
  78. package/dist/scripts/hunter-analytics.js +38 -0
  79. package/dist/scripts/hunter-cron.js +63 -0
  80. package/dist/scripts/hunter-purge.js +25 -0
  81. package/dist/scripts/migrate-v2.js +140 -0
  82. package/dist/scripts/reindex.js +74 -0
  83. package/dist/scripts/sync-local-docs.js +153 -0
  84. package/package.json +7 -1
@@ -1,11 +1,39 @@
1
+ // mcp-server.ts
2
+ // MCP server — the API surface that AI agents call.
3
+ // Each tool is a thin wrapper: validate input (Zod) → check protection → call library → format response.
4
+ // Zero business logic here — that all lives in the library files.
1
5
  import 'dotenv/config';
2
6
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
8
  import { createClient } from '@supabase/supabase-js';
5
9
  import OpenAI from 'openai';
10
+ import { observeOpenAI } from '@langfuse/openai';
11
+ import { randomUUID } from 'node:crypto';
6
12
  import { z } from 'zod';
7
- import { opSearchNotes, opListNotes, opAddNote, opUpdateNote, opUpdateMetadata, opDeleteNote, getTypeRegistry, } from './lib/notes.js';
8
- // --- Clients ---
13
+ import { resolve } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { createDocument, updateDocument, updateDocumentFields, deleteDocument, restoreDocument, updateDocumentFromFile, createDocumentFromFile, VerifyMismatchError } from './lib/documents/operations.js';
16
+ import { getDocumentById, listDocuments } from './lib/documents/fetching.js';
17
+ import { searchHybrid, searchByVector, searchByKeyword, retrieveContext } from './lib/search/ai-search.js';
18
+ import { initObservability, shutdownObservability } from './lib/observability.js';
19
+ // =============================================================================
20
+ // Observability
21
+ // =============================================================================
22
+ // Call before constructing the OpenAI client so observeOpenAI() has a tracer
23
+ // provider to attach to.
24
+ initObservability();
25
+ // One session ID per MCP process. MCP stdio transport is one client per
26
+ // process, so process-scoped UUID is the natural session boundary.
27
+ const MCP_SESSION_ID = `mcp-${randomUUID()}`;
28
+ const MCP_ENVIRONMENT = process.env.NODE_ENV ?? 'development';
29
+ for (const signal of ['SIGINT', 'SIGTERM']) {
30
+ process.on(signal, () => {
31
+ void shutdownObservability().finally(() => process.exit(0));
32
+ });
33
+ }
34
+ // =============================================================================
35
+ // Clients
36
+ // =============================================================================
9
37
  const supabaseUrl = process.env.SUPABASE_URL;
10
38
  const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
11
39
  const openaiKey = process.env.OPENAI_API_KEY;
@@ -19,77 +47,536 @@ if (!openaiKey) {
19
47
  }
20
48
  const clients = {
21
49
  supabase: createClient(supabaseUrl, supabaseKey),
22
- openai: new OpenAI({ apiKey: openaiKey }),
50
+ openai: observeOpenAI(new OpenAI({ apiKey: openaiKey })),
51
+ cohereApiKey: process.env.COHERE_API_KEY || undefined,
52
+ sessionId: MCP_SESSION_ID,
53
+ observabilityEnvironment: MCP_ENVIRONMENT,
23
54
  };
24
- // Build dynamic type description from registry
25
- const typeRegistry = getTypeRegistry();
26
- const typeList = Object.entries(typeRegistry)
27
- .map(([t, d]) => `${t} (${d})`)
28
- .join(', ');
29
- // --- MCP Server ---
55
+ // =============================================================================
56
+ // Helpers
57
+ // =============================================================================
58
+ function textResponse(text) {
59
+ return { content: [{ type: 'text', text }] };
60
+ }
61
+ function errorResponse(message) {
62
+ return { content: [{ type: 'text', text: `Error: ${message}` }] };
63
+ }
64
+ // =============================================================================
65
+ // File-access allowlist for *_from_file tools
66
+ // =============================================================================
67
+ // The MCP server runs with the user's full FS permissions. The allowlist is
68
+ // defense-in-depth: it stops accidental pushes of arbitrary files (e.g. /etc/passwd)
69
+ // when an agent constructs a path it shouldn't. Agents that already have direct
70
+ // FS access via Read tool can bypass this — that's expected. The point is to
71
+ // keep wrong-path mistakes from silently succeeding through the Ledger pipeline.
72
+ //
73
+ // Override the defaults via env var `LEDGER_MCP_FILE_ACCESS_ALLOWLIST` (colon-separated
74
+ // absolute paths or `~`-prefixed paths). Setting the env var REPLACES defaults
75
+ // rather than extending them, so an explicit override is always strictly enforced.
76
+ const FILE_ACCESS_ALLOWLIST_DEFAULTS = ['~/.ledger/', '~/repos/', '/tmp/ledger-edit/'];
77
+ function expandHome(rawPath) {
78
+ return rawPath.startsWith('~/') ? rawPath.replace(/^~/, homedir()) : rawPath;
79
+ }
80
+ function getFileAccessAllowlist() {
81
+ const envOverride = process.env.LEDGER_MCP_FILE_ACCESS_ALLOWLIST;
82
+ const sources = envOverride
83
+ ? envOverride.split(':').filter(entry => entry.length > 0)
84
+ : FILE_ACCESS_ALLOWLIST_DEFAULTS;
85
+ return sources.map(entry => resolve(expandHome(entry)) + '/');
86
+ }
87
+ function assertPathAllowed(absolutePath) {
88
+ const allowlist = getFileAccessAllowlist();
89
+ if (!allowlist.some(prefix => absolutePath.startsWith(prefix))) {
90
+ throw new Error(`Path "${absolutePath}" is outside the MCP file-access allowlist. ` +
91
+ `Allowed prefixes: ${allowlist.join(', ')}. ` +
92
+ `Override via the LEDGER_MCP_FILE_ACCESS_ALLOWLIST env var if intentional.`);
93
+ }
94
+ }
95
+ function verifyMismatchResponse(error) {
96
+ return errorResponse(`Verify mismatch on document ${error.id}: ` +
97
+ `pushed ${error.expectedLength} bytes, pulled ${error.actualLength} bytes. ` +
98
+ `${error.diffPreview}`);
99
+ }
100
+ /**
101
+ * Protection check — called before update and delete operations.
102
+ * Returns null if the operation can proceed, or a response object to return.
103
+ */
104
+ async function checkProtection(id, confirmed, operation) {
105
+ const document = await getDocumentById(clients.supabase, id);
106
+ if (!document)
107
+ return errorResponse(`Document ${id} not found`);
108
+ if (document.protection === 'immutable') {
109
+ return errorResponse(`Document "${document.name}" (id: ${id}) is immutable and cannot be ${operation}d`);
110
+ }
111
+ if ((document.protection === 'protected' || document.protection === 'guarded') && !confirmed) {
112
+ return textResponse(`Document "${document.name}" (id: ${id}) has protection: ${document.protection}.\n` +
113
+ `Current content preview: ${document.content.slice(0, 200)}${document.content.length > 200 ? '...' : ''}\n\n` +
114
+ `Call again with confirmed: true to proceed with ${operation}.`);
115
+ }
116
+ return null; // proceed
117
+ }
118
+ // =============================================================================
119
+ // Zod schemas — reusable across new and deprecated tools
120
+ // =============================================================================
121
+ const domainEnum = z.enum(['system', 'persona', 'workspace', 'project', 'general']);
122
+ const protectionEnum = z.enum(['open', 'guarded', 'protected', 'immutable']);
123
+ const ownerTypeEnum = z.enum(['system', 'user', 'team']);
124
+ const sourceTypeEnum = z.enum(['text', 'pdf', 'docx', 'spreadsheet', 'code', 'image', 'audio', 'video', 'web', 'email', 'slides', 'handwriting']);
125
+ const statusEnum = z.enum(['idea', 'planning', 'active', 'done']);
126
+ // =============================================================================
127
+ // MCP Server
128
+ // =============================================================================
30
129
  const server = new McpServer({
31
130
  name: 'ledger',
32
- version: '1.0.0',
131
+ version: '2.0.0',
33
132
  });
34
- // Tool: Search notes by semantic similarity
35
- server.tool('search_notes', 'Search memories by meaning using semantic similarity. If a result is chunked, all sibling chunks are returned reassembled.', {
133
+ // =============================================================================
134
+ // New tools *_documents
135
+ // =============================================================================
136
+ server.tool('search_documents', 'Search documents by meaning and keywords (hybrid search). Combines vector similarity with full-text keyword matching. Documents found by both methods rank highest.', {
36
137
  query: z.string().describe('What to search for'),
37
- threshold: z.coerce.number().min(0).max(1).default(0.5).describe('Minimum similarity score (0-1)'),
138
+ threshold: z.coerce.number().min(0).max(1).default(0.38).describe('Minimum vector similarity score (0-1). Lower = more results, higher = stricter matching'),
38
139
  limit: z.coerce.number().min(1).max(50).default(10).describe('Max results to return'),
39
- type: z.string().optional().describe('Filter by note type (e.g. feedback, reference, event)'),
140
+ domain: domainEnum.optional().describe('Filter by domain'),
141
+ document_type: z.string().optional().describe('Filter by document type'),
40
142
  project: z.string().optional().describe('Filter by project name'),
41
- }, async ({ query, threshold, limit, type, project }) => {
42
- const result = await opSearchNotes(clients, query, threshold, limit, type, project);
43
- return { content: [{ type: 'text', text: result.message }] };
44
- });
45
- // Tool: Add a new note
46
- 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.', {
47
- content: z.string().describe('The note content to save'),
48
- type: z.string().describe(`Note type. Registered: ${typeList}. Unknown types will prompt for registration.`),
49
- agent: z.string().describe('Which agent is saving this note (e.g. claude-code, zhuli)'),
50
- metadata: z.record(z.string(), z.unknown()).default({}).describe('Optional metadata (project, local_file, upsert_key, etc.)'),
51
- force: z.boolean().default(false).describe('Skip duplicate check and force creation of a new note'),
52
- register_type: z.boolean().default(false).describe('Set to true to register an unknown type before saving. Pass delivery in metadata if not using default (knowledge).'),
53
- }, async ({ content, type, agent, metadata, force, register_type }) => {
54
- const result = await opAddNote(clients, content, type, agent, metadata, force, register_type);
55
- return { content: [{ type: 'text', text: result.message }] };
56
- });
57
- // Tool: Update an existing note by ID
58
- 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.', {
59
- id: z.coerce.number().describe('The note ID to update'),
60
- content: z.string().describe('The new content'),
61
- metadata: z.record(z.string(), z.unknown()).optional().describe('Optional: replace metadata (keeps existing if omitted)'),
62
- confirmed: z.boolean().default(false).describe('Set to true to execute the update. Without this, shows the current note for confirmation.'),
63
- }, async ({ id, content, metadata, confirmed }) => {
64
- const result = await opUpdateNote(clients, id, content, metadata, confirmed);
65
- return { content: [{ type: 'text', text: result.message }] };
66
- });
67
- // Tool: List recent notes
68
- server.tool('list_notes', 'List recent notes from the knowledge base', {
69
- limit: z.coerce.number().min(1).max(100).default(20).describe('Number of notes to return'),
70
- type: z.string().optional().describe('Filter by note type (e.g. feedback, reference, event)'),
143
+ }, async (params) => {
144
+ try {
145
+ const results = await searchHybrid(clients, {
146
+ query: params.query,
147
+ threshold: params.threshold,
148
+ limit: params.limit,
149
+ domain: params.domain,
150
+ document_type: params.document_type,
151
+ project: params.project,
152
+ });
153
+ if (results.length === 0) {
154
+ return textResponse('No documents found matching your query.');
155
+ }
156
+ const formatted = results.map((result, index) => {
157
+ const score = result.score?.toFixed(3) ?? result.similarity?.toFixed(3) ?? 'n/a';
158
+ return [
159
+ `--- Result ${index + 1} [id: ${result.id}, score: ${score}] ---`,
160
+ `Name: ${result.name}`,
161
+ `Domain: ${result.domain} | Type: ${result.document_type}${result.project ? ` | Project: ${result.project}` : ''}`,
162
+ result.description ? `Description: ${result.description}` : null,
163
+ `Content:\n${result.content}`,
164
+ ].filter(Boolean).join('\n');
165
+ });
166
+ return textResponse(`Found ${results.length} result(s):\n\n${formatted.join('\n\n')}`);
167
+ }
168
+ catch (error) {
169
+ return errorResponse(error.message);
170
+ }
171
+ });
172
+ server.tool('add_document_from_file', 'Create a new document by reading content from an absolute file path on the local FS. Bytes flow disk -> Postgres without string composition (drift-safe). Auto-verified after create: the doc is pulled back and byte-compared against the file we sent. Path must be inside the MCP file-access allowlist.', {
173
+ path: z.string().describe('Absolute path to the file to ingest. Must be inside the configured allowlist (defaults: ~/.ledger/, ~/repos/, /tmp/ledger-edit/).'),
174
+ name: z.string().describe('Document name (unique identifier)'),
175
+ domain: domainEnum.describe('Document domain'),
176
+ document_type: z.string().describe('Document type (e.g. knowledge-guide, project-status, reference)'),
177
+ description: z.string().optional().describe('Short description of the document'),
178
+ project: z.string().optional().describe('Project name'),
179
+ protection: protectionEnum.optional().describe('Protection level (default: open)'),
180
+ agent: z.string().optional().describe('Agent creating this document'),
181
+ status: statusEnum.optional().describe('Document status'),
182
+ }, async (params) => {
183
+ try {
184
+ const absolutePath = resolve(expandHome(params.path));
185
+ assertPathAllowed(absolutePath);
186
+ const result = await createDocumentFromFile(clients, {
187
+ filePath: absolutePath,
188
+ name: params.name,
189
+ domain: params.domain,
190
+ document_type: params.document_type,
191
+ description: params.description,
192
+ project: params.project,
193
+ protection: params.protection,
194
+ agent: params.agent ?? 'mcp',
195
+ status: params.status,
196
+ });
197
+ return textResponse(`Document created and verified (id: ${result.id}, ${result.bytes} bytes).`);
198
+ }
199
+ catch (error) {
200
+ if (error instanceof VerifyMismatchError)
201
+ return verifyMismatchResponse(error);
202
+ return errorResponse(error.message);
203
+ }
204
+ });
205
+ server.tool('list_documents', 'List documents from the knowledge base with optional filters. Returns newest first.', {
206
+ domain: domainEnum.optional().describe('Filter by domain'),
207
+ document_type: z.string().optional().describe('Filter by document type'),
208
+ project: z.string().optional().describe('Filter by project name'),
209
+ limit: z.coerce.number().min(1).max(100).default(20).describe('Max results to return'),
210
+ }, async (params) => {
211
+ try {
212
+ const documents = await listDocuments(clients.supabase, {
213
+ domain: params.domain,
214
+ document_type: params.document_type,
215
+ project: params.project,
216
+ limit: params.limit,
217
+ });
218
+ if (documents.length === 0) {
219
+ return textResponse('No documents found.');
220
+ }
221
+ const formatted = documents.map((document) => {
222
+ return [
223
+ `[${document.id}] ${document.name}`,
224
+ ` Domain: ${document.domain} | Type: ${document.document_type}${document.project ? ` | Project: ${document.project}` : ''}`,
225
+ ` Protection: ${document.protection} | Auto-load: ${document.is_auto_load}`,
226
+ document.description ? ` Description: ${document.description}` : null,
227
+ ` Content: ${document.content.slice(0, 150)}${document.content.length > 150 ? '...' : ''}`,
228
+ ` Updated: ${document.updated_at}`,
229
+ ].filter(Boolean).join('\n');
230
+ });
231
+ return textResponse(`${documents.length} document(s):\n\n${formatted.join('\n\n')}`);
232
+ }
233
+ catch (error) {
234
+ return errorResponse(error.message);
235
+ }
236
+ });
237
+ server.tool('update_document_from_file', 'Update a document by reading new content from an absolute file path on the local FS. Bytes flow disk -> Postgres without string composition (drift-safe). Auto-verified after push: the doc is pulled back and byte-compared against the file we sent. Path must be inside the MCP file-access allowlist. Respects protection levels.', {
238
+ id: z.coerce.number().describe('Document ID to update'),
239
+ path: z.string().describe('Absolute path to the file containing the new content. Must be inside the configured allowlist (defaults: ~/.ledger/, ~/repos/, /tmp/ledger-edit/).'),
240
+ agent: z.string().optional().describe('Agent performing the update'),
241
+ confirmed: z.boolean().default(false).describe('Required for protected/guarded documents'),
242
+ }, async (params) => {
243
+ try {
244
+ const blocked = await checkProtection(params.id, params.confirmed, 'update');
245
+ if (blocked)
246
+ return blocked;
247
+ const absolutePath = resolve(expandHome(params.path));
248
+ assertPathAllowed(absolutePath);
249
+ const result = await updateDocumentFromFile(clients, {
250
+ id: params.id,
251
+ filePath: absolutePath,
252
+ agent: params.agent ?? 'mcp',
253
+ });
254
+ return textResponse(`Document ${result.id} updated and verified (${result.bytes} bytes).`);
255
+ }
256
+ catch (error) {
257
+ if (error instanceof VerifyMismatchError)
258
+ return verifyMismatchResponse(error);
259
+ return errorResponse(error.message);
260
+ }
261
+ });
262
+ server.tool('update_document_fields', 'Update document fields without changing content. No re-embedding needed. Respects protection levels.', {
263
+ id: z.coerce.number().describe('Document ID to update'),
264
+ agent: z.string().optional().describe('Agent performing the update'),
265
+ name: z.string().optional().describe('New document name'),
266
+ domain: domainEnum.optional().describe('New domain'),
267
+ document_type: z.string().optional().describe('New document type'),
268
+ project: z.string().optional().describe('New project name'),
269
+ protection: protectionEnum.optional().describe('New protection level'),
270
+ owner_type: ownerTypeEnum.optional().describe('New owner type'),
271
+ owner_id: z.string().optional().describe('New owner ID'),
272
+ is_auto_load: z.boolean().optional().describe('New auto-load setting'),
273
+ description: z.string().optional().describe('New description'),
274
+ source_type: sourceTypeEnum.optional().describe('New source type'),
275
+ source_url: z.string().optional().describe('New source URL'),
276
+ file_path: z.string().optional().describe('New file path'),
277
+ file_permissions: z.string().optional().describe('New file permissions'),
278
+ status: statusEnum.optional().describe('New status'),
279
+ skill_ref: z.string().optional().describe('New skill reference'),
280
+ confirmed: z.boolean().default(false).describe('Required for protected/guarded documents'),
281
+ }, async (params) => {
282
+ try {
283
+ const blocked = await checkProtection(params.id, params.confirmed, 'update');
284
+ if (blocked)
285
+ return blocked;
286
+ const { confirmed, ...fields } = params;
287
+ await updateDocumentFields(clients, fields);
288
+ return textResponse(`Document ${params.id} fields updated successfully.`);
289
+ }
290
+ catch (error) {
291
+ return errorResponse(error.message);
292
+ }
293
+ });
294
+ server.tool('delete_document', 'Soft-delete a document. Can be restored within 30 days. Respects protection levels.', {
295
+ id: z.coerce.number().describe('Document ID to delete'),
296
+ agent: z.string().describe('Agent performing the deletion'),
297
+ confirmed: z.boolean().default(false).describe('Required for protected/guarded documents. Also shows preview before deletion if false.'),
298
+ }, async (params) => {
299
+ try {
300
+ const blocked = await checkProtection(params.id, params.confirmed, 'delete');
301
+ if (blocked)
302
+ return blocked;
303
+ await deleteDocument(clients, params.id, params.agent);
304
+ return textResponse(`Document ${params.id} soft-deleted. Can be restored within 30 days.`);
305
+ }
306
+ catch (error) {
307
+ return errorResponse(error.message);
308
+ }
309
+ });
310
+ server.tool('restore_document', 'Undo a soft-delete. Use when a document was accidentally deleted. Only works within 30 days of deletion.', {
311
+ id: z.coerce.number().describe('Document ID to restore'),
312
+ agent: z.string().describe('Agent performing the restore'),
313
+ }, async (params) => {
314
+ try {
315
+ await restoreDocument(clients, params.id, params.agent);
316
+ return textResponse(`Document ${params.id} restored. Note: chunks were removed during delete — update the document content to regenerate search index.`);
317
+ }
318
+ catch (error) {
319
+ return errorResponse(error.message);
320
+ }
321
+ });
322
+ server.tool('search_by_meaning', 'Search by meaning only (vector similarity). Use when you want conceptual matches — "how does auth work" finds OAuth docs even without those exact words. Prefer search_documents for general use.', {
323
+ query: z.string().describe('What to search for'),
324
+ threshold: z.coerce.number().min(0).max(1).default(0.38).describe('Minimum cosine similarity (0-1)'),
325
+ limit: z.coerce.number().min(1).max(50).default(10).describe('Max results'),
326
+ domain: domainEnum.optional().describe('Filter by domain'),
327
+ document_type: z.string().optional().describe('Filter by document type'),
328
+ project: z.string().optional().describe('Filter by project name'),
329
+ }, async (params) => {
330
+ try {
331
+ const results = await searchByVector(clients, {
332
+ query: params.query,
333
+ threshold: params.threshold,
334
+ limit: params.limit,
335
+ domain: params.domain,
336
+ document_type: params.document_type,
337
+ project: params.project,
338
+ });
339
+ if (results.length === 0) {
340
+ return textResponse('No documents found matching your query.');
341
+ }
342
+ const formatted = results.map((result, index) => {
343
+ const score = result.similarity?.toFixed(3) ?? 'n/a';
344
+ return [
345
+ `--- Result ${index + 1} [id: ${result.id}, similarity: ${score}] ---`,
346
+ `Name: ${result.name}`,
347
+ `Domain: ${result.domain} | Type: ${result.document_type}${result.project ? ` | Project: ${result.project}` : ''}`,
348
+ result.description ? `Description: ${result.description}` : null,
349
+ `Content:\n${result.content}`,
350
+ ].filter(Boolean).join('\n');
351
+ });
352
+ return textResponse(`Found ${results.length} result(s):\n\n${formatted.join('\n\n')}`);
353
+ }
354
+ catch (error) {
355
+ return errorResponse(error.message);
356
+ }
357
+ });
358
+ server.tool('search_by_keyword', 'Search by exact words only (full-text). Use for code identifiers, error messages, proper nouns, or exact phrases that must appear in the document.', {
359
+ query: z.string().describe('Exact words to search for'),
360
+ limit: z.coerce.number().min(1).max(50).default(10).describe('Max results'),
361
+ domain: domainEnum.optional().describe('Filter by domain'),
362
+ document_type: z.string().optional().describe('Filter by document type'),
71
363
  project: z.string().optional().describe('Filter by project name'),
72
- }, async ({ limit, type, project }) => {
73
- const result = await opListNotes(clients, limit, type, project);
74
- return { content: [{ type: 'text', text: result.message }] };
75
- });
76
- // Tool: Delete a note
77
- 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.', {
78
- id: z.coerce.number().describe('The note ID to delete'),
79
- confirmed: z.boolean().default(false).describe('Set to true to execute the deletion. Without this, shows the note for confirmation.'),
80
- }, async ({ id, confirmed }) => {
81
- const result = await opDeleteNote(clients, id, confirmed);
82
- return { content: [{ type: 'text', text: result.message }] };
83
- });
84
- // Tool: Update metadata only (confirmation required for protected notes)
85
- server.tool('update_metadata', 'Update metadata fields on an existing note without changing content. Useful for adding descriptions, tags, project, or scope. Protected notes (delivery: protected) require confirmed: true.', {
86
- id: z.coerce.number().describe('The note ID to update'),
87
- metadata: z.record(z.string(), z.unknown()).describe('Metadata fields to merge (existing fields are preserved unless overwritten)'),
88
- confirmed: z.boolean().default(false).describe('Set to true to confirm update of protected notes. Required when the note has delivery: protected.'),
89
- }, async ({ id, metadata, confirmed }) => {
90
- const result = await opUpdateMetadata(clients, id, metadata, confirmed);
91
- return { content: [{ type: 'text', text: result.message }] };
92
- });
93
- // --- Start ---
364
+ }, async (params) => {
365
+ try {
366
+ const results = await searchByKeyword(clients, {
367
+ query: params.query,
368
+ limit: params.limit,
369
+ domain: params.domain,
370
+ document_type: params.document_type,
371
+ project: params.project,
372
+ });
373
+ if (results.length === 0) {
374
+ return textResponse('No documents found matching your keywords.');
375
+ }
376
+ const formatted = results.map((result, index) => {
377
+ const score = result.rank?.toFixed(3) ?? 'n/a';
378
+ return [
379
+ `--- Result ${index + 1} [id: ${result.id}, rank: ${score}] ---`,
380
+ `Name: ${result.name}`,
381
+ `Domain: ${result.domain} | Type: ${result.document_type}${result.project ? ` | Project: ${result.project}` : ''}`,
382
+ result.description ? `Description: ${result.description}` : null,
383
+ `Content:\n${result.content}`,
384
+ ].filter(Boolean).join('\n');
385
+ });
386
+ return textResponse(`Found ${results.length} result(s):\n\n${formatted.join('\n\n')}`);
387
+ }
388
+ catch (error) {
389
+ return errorResponse(error.message);
390
+ }
391
+ });
392
+ server.tool('get_document_context', 'Get the relevant section of a large document. Use after search returns a match — this extracts just the part you need instead of the full content, saving tokens. For small documents, returns the full content.', {
393
+ document_id: z.coerce.number().describe('Document ID (from a search result)'),
394
+ matched_chunk_index: z.coerce.number().describe('Chunk index that matched (from search)'),
395
+ context_window: z.coerce.number().default(4000).describe('Max characters to return'),
396
+ neighbor_count: z.coerce.number().default(1).describe('Number of neighboring chunks to include for context'),
397
+ }, async (params) => {
398
+ try {
399
+ const result = await retrieveContext(clients.supabase, {
400
+ document_id: params.document_id,
401
+ matched_chunk_index: params.matched_chunk_index,
402
+ context_window: params.context_window,
403
+ neighbor_count: params.neighbor_count,
404
+ });
405
+ if (!result) {
406
+ return textResponse(`No context found for document ${params.document_id}, chunk ${params.matched_chunk_index}.`);
407
+ }
408
+ return textResponse([
409
+ `Document: ${result.document_name} (id: ${result.document_id})`,
410
+ `Retrieval mode: ${result.retrieval_mode}`,
411
+ `---`,
412
+ result.content,
413
+ ].join('\n'));
414
+ }
415
+ catch (error) {
416
+ return errorResponse(error.message);
417
+ }
418
+ });
419
+ // =============================================================================
420
+ // Deprecated tools — *_notes (redirect to new implementations)
421
+ // These will be removed in a future version.
422
+ // =============================================================================
423
+ server.tool('search_notes', '[DEPRECATED — use search_documents] Search memories by meaning using semantic similarity.', {
424
+ query: z.string().describe('What to search for'),
425
+ threshold: z.coerce.number().min(0).max(1).default(0.38).describe('Minimum vector similarity score'),
426
+ limit: z.coerce.number().min(1).max(50).default(10).describe('Max results'),
427
+ type: z.string().optional().describe('Filter by type (maps to document_type)'),
428
+ project: z.string().optional().describe('Filter by project'),
429
+ domain: z.string().optional().describe('Filter by domain'),
430
+ }, async (params) => {
431
+ try {
432
+ const results = await searchHybrid(clients, {
433
+ query: params.query,
434
+ threshold: params.threshold,
435
+ limit: params.limit,
436
+ domain: params.domain,
437
+ document_type: params.type,
438
+ project: params.project,
439
+ });
440
+ if (results.length === 0) {
441
+ return textResponse('No results found.');
442
+ }
443
+ const formatted = results.map((result) => {
444
+ const score = result.score?.toFixed(3) ?? result.similarity?.toFixed(3) ?? 'n/a';
445
+ return `[${result.id}] ${result.name} (score: ${score})\n${result.content}`;
446
+ });
447
+ return textResponse(formatted.join('\n\n---\n\n'));
448
+ }
449
+ catch (error) {
450
+ return errorResponse(error.message);
451
+ }
452
+ });
453
+ server.tool('add_note', '[DEPRECATED — use add_document] Save a new memory/note to the knowledge base.', {
454
+ content: z.string().describe('The note content'),
455
+ type: z.string().describe('Note type (maps to document_type)'),
456
+ agent: z.string().describe('Agent saving this note'),
457
+ metadata: z.record(z.string(), z.unknown()).default({}).describe('Metadata fields: domain, protection, auto_load, project, upsert_key, description, file_path, file_permissions, skill_ref'),
458
+ }, async (params) => {
459
+ try {
460
+ const meta = params.metadata;
461
+ const documentId = await createDocument(clients, {
462
+ name: meta.upsert_key ?? `note-${Date.now()}`,
463
+ domain: meta.domain ?? 'general',
464
+ document_type: params.type,
465
+ content: params.content,
466
+ description: meta.description,
467
+ project: meta.project,
468
+ protection: meta.protection,
469
+ owner_type: meta.owner_type,
470
+ owner_id: meta.owner_id,
471
+ is_auto_load: meta.auto_load,
472
+ source_type: meta.source_type,
473
+ file_path: meta.file_path,
474
+ file_permissions: meta.file_permissions,
475
+ agent: params.agent,
476
+ status: meta.status,
477
+ skill_ref: meta.skill_ref,
478
+ });
479
+ return textResponse(`Note saved (id: ${documentId}). Tip: use add_document instead of add_note.`);
480
+ }
481
+ catch (error) {
482
+ return errorResponse(error.message);
483
+ }
484
+ });
485
+ server.tool('list_notes', '[DEPRECATED — use list_documents] List recent notes from the knowledge base.', {
486
+ limit: z.coerce.number().min(1).max(100).default(20).describe('Number of notes to return'),
487
+ type: z.string().optional().describe('Filter by note type'),
488
+ project: z.string().optional().describe('Filter by project'),
489
+ domain: z.string().optional().describe('Filter by domain'),
490
+ }, async (params) => {
491
+ try {
492
+ const documents = await listDocuments(clients.supabase, {
493
+ domain: params.domain,
494
+ document_type: params.type,
495
+ project: params.project,
496
+ limit: params.limit,
497
+ });
498
+ if (documents.length === 0) {
499
+ return textResponse('No notes found.');
500
+ }
501
+ const formatted = documents.map((document) => {
502
+ return `[${document.id}] ${document.created_at}\n${document.content}`;
503
+ });
504
+ return textResponse(formatted.join('\n\n---\n\n'));
505
+ }
506
+ catch (error) {
507
+ return errorResponse(error.message);
508
+ }
509
+ });
510
+ server.tool('update_note', '[DEPRECATED — use update_document] Update an existing note by ID.', {
511
+ id: z.coerce.number().describe('Note ID to update'),
512
+ content: z.string().describe('New content'),
513
+ confirmed: z.boolean().default(false).describe('Required for protected notes'),
514
+ }, async (params) => {
515
+ try {
516
+ const blocked = await checkProtection(params.id, params.confirmed, 'update');
517
+ if (blocked)
518
+ return blocked;
519
+ await updateDocument(clients, {
520
+ id: params.id,
521
+ content: params.content,
522
+ });
523
+ return textResponse(`Note ${params.id} updated. Tip: use update_document instead of update_note.`);
524
+ }
525
+ catch (error) {
526
+ return errorResponse(error.message);
527
+ }
528
+ });
529
+ server.tool('update_metadata', '[DEPRECATED — use update_document_fields] Update metadata fields on an existing note.', {
530
+ id: z.coerce.number().describe('Note ID to update'),
531
+ metadata: z.record(z.string(), z.unknown()).describe('Metadata fields to update'),
532
+ confirmed: z.boolean().default(false).describe('Required for protected notes'),
533
+ }, async (params) => {
534
+ try {
535
+ const blocked = await checkProtection(params.id, params.confirmed, 'update');
536
+ if (blocked)
537
+ return blocked;
538
+ const meta = params.metadata;
539
+ await updateDocumentFields(clients, {
540
+ id: params.id,
541
+ name: meta.name,
542
+ domain: meta.domain,
543
+ document_type: meta.document_type,
544
+ project: meta.project,
545
+ protection: meta.protection,
546
+ owner_type: meta.owner_type,
547
+ owner_id: meta.owner_id,
548
+ is_auto_load: meta.auto_load,
549
+ description: meta.description,
550
+ source_type: meta.source_type,
551
+ source_url: meta.source_url,
552
+ file_path: meta.file_path,
553
+ file_permissions: meta.file_permissions,
554
+ status: meta.status,
555
+ skill_ref: meta.skill_ref,
556
+ });
557
+ return textResponse(`Note ${params.id} metadata updated. Tip: use update_document_fields instead of update_metadata.`);
558
+ }
559
+ catch (error) {
560
+ return errorResponse(error.message);
561
+ }
562
+ });
563
+ server.tool('delete_note', '[DEPRECATED — use delete_document] Delete a note by ID.', {
564
+ id: z.coerce.number().describe('Note ID to delete'),
565
+ confirmed: z.boolean().default(false).describe('Required for protected notes and deletion confirmation'),
566
+ }, async (params) => {
567
+ try {
568
+ const blocked = await checkProtection(params.id, params.confirmed, 'delete');
569
+ if (blocked)
570
+ return blocked;
571
+ await deleteDocument(clients, params.id, 'unknown');
572
+ return textResponse(`Note ${params.id} deleted. Tip: use delete_document instead of delete_note.`);
573
+ }
574
+ catch (error) {
575
+ return errorResponse(error.message);
576
+ }
577
+ });
578
+ // =============================================================================
579
+ // Start
580
+ // =============================================================================
94
581
  const transport = new StdioServerTransport();
95
582
  await server.connect(transport);