@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.
- package/dist/cli.js +177 -221
- package/dist/commands/add.js +51 -100
- package/dist/commands/backfill.js +55 -0
- package/dist/commands/backup.js +10 -10
- package/dist/commands/check.js +21 -29
- package/dist/commands/config.js +13 -12
- package/dist/commands/delete.js +22 -17
- package/dist/commands/eval-judge.js +11 -0
- package/dist/commands/eval.js +321 -0
- package/dist/commands/export.js +8 -10
- package/dist/commands/get.js +9 -0
- package/dist/commands/hunt.js +206 -0
- package/dist/commands/ingest.js +15 -14
- package/dist/commands/init.js +18 -20
- package/dist/commands/list.js +21 -7
- package/dist/commands/migrate.js +11 -11
- package/dist/commands/onboard.js +2 -2
- package/dist/commands/pull.js +3 -2
- package/dist/commands/push.js +8 -8
- package/dist/commands/restore.js +38 -38
- package/dist/commands/show.js +13 -16
- package/dist/commands/sync.js +58 -19
- package/dist/commands/tag.js +20 -14
- package/dist/commands/update.js +50 -18
- package/dist/commands/wizard.js +3 -3
- package/dist/lib/ai-search.js +163 -0
- package/dist/lib/audit.js +19 -0
- package/dist/lib/backfill.js +60 -0
- package/dist/lib/config.js +19 -2
- package/dist/lib/document-classification.js +5 -0
- package/dist/lib/document-fetching.js +77 -0
- package/dist/lib/document-operations.js +150 -0
- package/dist/lib/documents/classification.js +5 -0
- package/dist/lib/documents/fetching.js +89 -0
- package/dist/lib/documents/operations.js +304 -0
- package/dist/lib/domains.js +116 -0
- package/dist/lib/embeddings.js +190 -0
- package/dist/lib/errors.js +3 -1
- package/dist/lib/eval/eval-advanced.js +289 -0
- package/dist/lib/eval/eval-judge-session.js +233 -0
- package/dist/lib/eval/eval-store.js +105 -0
- package/dist/lib/eval/eval.js +303 -0
- package/dist/lib/file-writer.js +23 -0
- package/dist/lib/generators.js +44 -45
- package/dist/lib/hunter-db.js +235 -0
- package/dist/lib/hunter-rss.js +30 -0
- package/dist/lib/hunter-scoring.js +55 -0
- package/dist/lib/hunter-types.js +36 -0
- package/dist/lib/lint-configs.js +20 -0
- package/dist/lib/migrate.js +2 -2
- package/dist/lib/notes.js +173 -59
- package/dist/lib/observability.js +296 -0
- package/dist/lib/op-add-note-types.test.js +7 -6
- package/dist/lib/prompt.js +8 -8
- package/dist/lib/rate-limiter.js +103 -0
- package/dist/lib/search/ai-search.js +396 -0
- package/dist/lib/search/chunk-context-enrichment.js +155 -0
- package/dist/lib/search/embeddings.js +293 -0
- package/dist/lib/search/reranker.js +120 -0
- package/dist/lib/search/semantic-cache.js +53 -0
- package/dist/lib/type-registry.test.js +6 -6
- package/dist/mcp-server.js +553 -66
- package/dist/migrations/migrations/005-audit-log.sql +22 -0
- package/dist/migrations/migrations/005_opportunities.sql +48 -0
- package/dist/migrations/migrations/006-audited-operations.sql +235 -0
- package/dist/migrations/migrations/006_hunt_analytics.sql +38 -0
- package/dist/migrations/migrations/007-eval-golden-judgments.sql +119 -0
- package/dist/migrations/migrations/008-drop-expected-doc-ids.sql +9 -0
- package/dist/migrations/migrations/008-judge-helpers.sql +21 -0
- package/dist/migrations/migrations/009-semantic-cache.sql +216 -0
- package/dist/scripts/batch-grade.js +344 -0
- package/dist/scripts/benchmark-ingestion.js +376 -0
- package/dist/scripts/convert-judgments-to-graded.js +88 -0
- package/dist/scripts/diagnose-first-result.js +333 -0
- package/dist/scripts/drop-golden-query.js +53 -0
- package/dist/scripts/eval-search.js +115 -0
- package/dist/scripts/grade-unjudged-top1.js +138 -0
- package/dist/scripts/hunter-analytics.js +38 -0
- package/dist/scripts/hunter-cron.js +63 -0
- package/dist/scripts/hunter-purge.js +25 -0
- package/dist/scripts/migrate-v2.js +140 -0
- package/dist/scripts/reindex.js +74 -0
- package/dist/scripts/sync-local-docs.js +153 -0
- package/package.json +7 -1
package/dist/mcp-server.js
CHANGED
|
@@ -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 {
|
|
8
|
-
|
|
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
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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: '
|
|
131
|
+
version: '2.0.0',
|
|
33
132
|
});
|
|
34
|
-
//
|
|
35
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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);
|