@hasna/knowledge 0.2.2 → 0.2.4

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/src/mcp.js CHANGED
@@ -2,531 +2,490 @@
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
- import { defaultStorePath, loadStore, saveStore, makeId } from './store.ts';
6
5
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import pkg from '../package.json' with { type: 'json' };
7
+ import { defaultStorePath, loadStore, saveStore, makeId, withLock } from './store.ts';
8
+ import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace.ts';
9
+ import { parseSourceRef } from './source-ref.ts';
7
10
 
8
- function createStoreSchema() {
9
- return z.object({
10
- store_path: z.string().optional().describe('Path to the store file (default: ~/.open-knowledge/db.json)'),
11
- });
12
- }
11
+ const storePathField = z.string().optional().describe('Path to the JSON store file');
12
+ const scopeField = z.enum(['local', 'global', 'project']).optional().describe('Workspace scope');
13
13
 
14
- function createItemSchema() {
15
- return z.object({
16
- store_path: z.string().optional().describe('Path to the store file'),
17
- });
14
+ function jsonText(data) {
15
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
18
16
  }
19
17
 
20
- function createAddSchema() {
21
- return z.object({
22
- title: z.string().describe('Item title'),
23
- content: z.string().describe('Item content/body'),
24
- tags: z.array(z.string()).optional().describe('Tags to attach'),
25
- metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata key-value pairs'),
26
- store_path: z.string().optional().describe('Path to the store file'),
27
- });
18
+ function errorText(message) {
19
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
28
20
  }
29
21
 
30
- function createIdSchema() {
31
- return z.object({
32
- id: z.string().describe('Item ID or short ID'),
33
- store_path: z.string().optional().describe('Path to the store file'),
34
- });
35
- }
36
-
37
- function createListSchema() {
38
- return z.object({
39
- search: z.string().optional().describe('Search text for title/content'),
40
- fuzzy: z.boolean().optional().describe('Use fuzzy matching for search'),
41
- tag: z.array(z.string()).optional().describe('Filter by tags (must match all)'),
42
- archived: z.boolean().optional().describe('Show only archived items'),
43
- include_archived: z.boolean().optional().describe('Include archived items in results'),
44
- page: z.number().optional().describe('Page number (default: 1)'),
45
- limit: z.number().optional().describe('Items per page (default: 20)'),
46
- sort: z.enum(['created', 'title']).optional().describe('Sort field'),
47
- desc: z.boolean().optional().describe('Sort descending'),
48
- after: z.string().optional().describe('Filter items created after ISO date'),
49
- before: z.string().optional().describe('Filter items created before ISO date'),
50
- store_path: z.string().optional().describe('Path to the store file'),
51
- });
22
+ function shortIdFor(id) {
23
+ return id.replace(/^k_/, '').slice(0, 12);
52
24
  }
53
25
 
54
- function createUpdateSchema() {
55
- return z.object({
56
- id: z.string().describe('Item ID or short ID'),
57
- title: z.string().optional().describe('New title'),
58
- content: z.string().optional().describe('New content'),
59
- tags: z.array(z.string()).optional().describe('Tags to add'),
60
- metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata to merge'),
61
- store_path: z.string().optional().describe('Path to the store file'),
62
- });
63
- }
64
-
65
- function createDeleteSchema() {
66
- return z.object({
67
- id: z.string().describe('Item ID or short ID'),
68
- confirm: z.boolean().describe('Must be true to confirm deletion'),
69
- store_path: z.string().optional().describe('Path to the store file'),
70
- });
71
- }
72
-
73
- function createUpsertSchema() {
74
- return z.object({
75
- id: z.string().describe('Item ID (used as id for new items)'),
76
- title: z.string().optional().describe('Item title'),
77
- content: z.string().optional().describe('Item content'),
78
- tags: z.array(z.string()).optional().describe('Tags'),
79
- metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata'),
80
- store_path: z.string().optional().describe('Path to the store file'),
81
- });
26
+ function resolveStorePath(storePath, scope) {
27
+ if (storePath) return storePath;
28
+ if (scope === 'project' || scope === 'local') {
29
+ return ensureKnowledgeWorkspace(resolveScopedWorkspace(scope).home).jsonStorePath;
30
+ }
31
+ return defaultStorePath();
82
32
  }
83
33
 
84
- function createBulkDeleteSchema() {
85
- return z.object({
86
- tag: z.array(z.string()).optional().describe('Delete items with these tags'),
87
- search: z.string().optional().describe('Delete items matching search in title/content'),
88
- confirm: z.boolean().describe('Must be true to confirm deletion'),
89
- store_path: z.string().optional().describe('Path to the store file'),
90
- });
34
+ function readStoreLocked(storePath, fn) {
35
+ return withLock(storePath, () => fn(loadStore(storePath)));
91
36
  }
92
37
 
93
- function createExportSchema() {
94
- return z.object({
95
- file: z.string().optional().describe('Output file path (default: ./knowledge-export.json)'),
96
- store_path: z.string().optional().describe('Path to the store file'),
38
+ function writeStoreLocked(storePath, fn) {
39
+ return withLock(storePath, () => {
40
+ const db = loadStore(storePath);
41
+ const result = fn(db);
42
+ saveStore(storePath, db);
43
+ return result;
97
44
  });
98
45
  }
99
46
 
100
- function createImportSchema() {
101
- return z.object({
102
- file: z.string().describe('Path to exported JSON file'),
103
- store_path: z.string().optional().describe('Path to the store file'),
104
- });
47
+ function findItem(db, id) {
48
+ return db.items.find((item) => item.id === id || item.short_id === id);
105
49
  }
106
50
 
107
- function createStatsSchema() {
108
- return z.object({
109
- store_path: z.string().optional().describe('Path to the store file'),
51
+ function sortItems(items, sort = 'created', desc = false) {
52
+ const sorted = [...items].sort((a, b) => {
53
+ if (sort === 'title') return a.title.localeCompare(b.title);
54
+ return a.created_at.localeCompare(b.created_at);
110
55
  });
56
+ if (desc) sorted.reverse();
57
+ return sorted;
111
58
  }
112
59
 
113
- function createBatchSchema() {
114
- return z.object({
115
- items: z.array(z.object({
116
- id: z.string().optional(),
117
- title: z.string(),
118
- content: z.string(),
119
- tags: z.array(z.string()).optional(),
120
- metadata: z.record(z.string(), z.unknown()).optional(),
121
- created_at: z.string().optional(),
122
- updated_at: z.string().optional(),
123
- })).describe('Array of items to import'),
124
- store_path: z.string().optional().describe('Path to the store file'),
125
- });
60
+ function activeItems(items, includeArchived) {
61
+ return includeArchived ? items : items.filter((item) => !item.archived);
126
62
  }
127
63
 
128
- function createUntagSchema() {
129
- return z.object({
130
- id: z.string().describe('Item ID or short ID'),
131
- tags: z.array(z.string()).describe('Tags to remove'),
132
- store_path: z.string().optional().describe('Path to the store file'),
133
- });
64
+ function registerTool(server, name, title, description, inputSchema, handler) {
65
+ server.registerTool(name, { title, description, inputSchema }, handler);
134
66
  }
135
67
 
136
68
  export function buildServer() {
137
69
  const server = new McpServer({
138
70
  name: 'open-knowledge',
139
- version: '0.1.0',
71
+ version: pkg.version,
140
72
  });
141
73
 
142
- // Helper to resolve store path
143
- function resolveStore(path) {
144
- return path || defaultStorePath();
145
- }
74
+ registerTool(server, 'ok_paths', 'Knowledge workspace paths', 'Show resolved workspace and store paths', {
75
+ scope: scopeField,
76
+ }, async ({ scope }) => {
77
+ const workspace = ensureKnowledgeWorkspace(resolveScopedWorkspace(scope).home);
78
+ return jsonText({
79
+ ok: true,
80
+ scope: scope ?? 'global',
81
+ home: workspace.home,
82
+ config_path: workspace.configPath,
83
+ json_store_path: workspace.jsonStorePath,
84
+ knowledge_db_path: workspace.knowledgeDbPath,
85
+ artifacts_dir: workspace.artifactsDir,
86
+ indexes_dir: workspace.indexesDir,
87
+ logs_dir: workspace.logsDir,
88
+ runs_dir: workspace.runsDir,
89
+ schemas_dir: workspace.schemasDir,
90
+ wiki_dir: workspace.wikiDir,
91
+ config: readKnowledgeConfig(workspace.configPath),
92
+ });
93
+ });
94
+
95
+ registerTool(server, 'ok_parse_source_ref', 'Parse source reference', 'Parse and validate an open-files, S3, file, or web source ref', {
96
+ uri: z.string().describe('Source reference URI'),
97
+ }, async ({ uri }) => {
98
+ try {
99
+ return jsonText({ ok: true, source_ref: parseSourceRef(uri) });
100
+ } catch (error) {
101
+ return errorText(error instanceof Error ? error.message : String(error));
102
+ }
103
+ });
146
104
 
147
- server.registerTool('ok_add', {
148
- title: 'Add a knowledge item',
149
- description: 'Add a new item to the knowledge store with title, content, optional tags and metadata',
150
- inputSchema: createAddSchema(),
151
- handler: async ({ title, content, tags, metadata, store_path }) => {
152
- const db = loadStore(resolveStore(store_path));
105
+ registerTool(server, 'ok_add', 'Add a knowledge item', 'Add a new item to the knowledge store', {
106
+ title: z.string().describe('Item title'),
107
+ content: z.string().describe('Item content/body'),
108
+ tags: z.array(z.string()).optional().describe('Tags to attach'),
109
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata key-value pairs'),
110
+ url: z.string().optional().describe('Source URL or URI'),
111
+ store_path: storePathField,
112
+ scope: scopeField,
113
+ }, async ({ title, content, tags, metadata, url, store_path, scope }) => {
114
+ const storePath = resolveStorePath(store_path, scope);
115
+ const item = writeStoreLocked(storePath, (db) => {
153
116
  const now = new Date().toISOString();
154
- const { id, shortId } = makeId();
155
- const item = {
117
+ const id = makeId();
118
+ const entry = {
156
119
  id,
157
- short_id: shortId,
120
+ short_id: shortIdFor(id),
158
121
  title,
159
122
  content,
123
+ url: url ?? null,
160
124
  tags: tags ?? [],
161
125
  metadata: metadata ?? {},
126
+ archived: false,
162
127
  created_at: now,
163
128
  updated_at: now,
164
129
  };
165
- db.items.push(item);
166
- saveStore(resolveStore(store_path), db);
167
- return {
168
- content: [{ type: 'text', text: JSON.stringify({ ok: true, item, message: `Added ${item.id}` }, null, 2) }],
169
- };
170
- },
130
+ db.items.push(entry);
131
+ return entry;
132
+ });
133
+ return jsonText({ ok: true, item, message: `Added ${item.id}` });
171
134
  });
172
135
 
173
- server.registerTool('ok_list', {
174
- title: 'List knowledge items',
175
- description: 'List items with pagination, search, tag filter, date filter, and sorting',
176
- inputSchema: createListSchema(),
177
- handler: async ({ search, fuzzy, tag, archived, include_archived, page, limit, sort, desc, after, before, store_path }) => {
178
- const db = loadStore(resolveStore(store_path));
179
- let items = db.items;
180
-
181
- if (search) {
182
- const q = search.toLowerCase();
183
- if (fuzzy) {
184
- const levenshtein = (a, b) => {
185
- const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
186
- for (let i = 0; i <= a.length; i += 1) dp[i][0] = i;
187
- for (let j = 0; j <= b.length; j += 1) dp[0][j] = j;
188
- for (let i = 1; i <= a.length; i += 1) {
189
- for (let j = 1; j <= b.length; j += 1) {
190
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
191
- dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
192
- }
193
- }
194
- return dp[a.length][b.length];
195
- };
196
- const scored = items.map((x) => {
197
- const titleScore = levenshtein(q, x.title.toLowerCase());
198
- const contentScore = Math.min(levenshtein(q, x.content.slice(0, 200).toLowerCase()), 20);
199
- return { ...x, _fuzzyScore: Math.min(titleScore, contentScore) };
200
- }).filter((x) => x._fuzzyScore <= 5);
201
- scored.sort((a, b) => a._fuzzyScore - b._fuzzyScore);
202
- items = scored;
203
- } else {
204
- items = items.filter((x) => x.title.toLowerCase().includes(q) || x.content.toLowerCase().includes(q));
205
- }
206
- }
207
-
208
- if (tag && tag.length > 0) {
209
- items = items.filter((x) => {
210
- const itemTags = (x.tags ?? []).map((t) => t.toLowerCase());
211
- return tag.every((t) => itemTags.includes(t.toLowerCase()));
136
+ registerTool(server, 'ok_list', 'List knowledge items', 'List items with pagination, search, tag filtering, and sorting', {
137
+ search: z.string().optional().describe('Search text for title/content'),
138
+ tag: z.array(z.string()).optional().describe('Filter by tags; item must match all tags'),
139
+ include_archived: z.boolean().optional().describe('Include archived items'),
140
+ page: z.number().optional().describe('Page number'),
141
+ limit: z.number().optional().describe('Items per page'),
142
+ sort: z.enum(['created', 'title']).optional().describe('Sort field'),
143
+ desc: z.boolean().optional().describe('Sort descending'),
144
+ store_path: storePathField,
145
+ scope: scopeField,
146
+ }, async ({ search, tag, include_archived, page, limit, sort, desc, store_path, scope }) => {
147
+ const storePath = resolveStorePath(store_path, scope);
148
+ return readStoreLocked(storePath, (db) => {
149
+ const q = search ? search.toLowerCase() : '';
150
+ const requiredTags = (tag ?? []).map((entry) => entry.toLowerCase());
151
+ let items = activeItems(db.items, include_archived);
152
+ if (q) items = items.filter((item) => item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q));
153
+ if (requiredTags.length > 0) {
154
+ items = items.filter((item) => {
155
+ const itemTags = (item.tags ?? []).map((entry) => entry.toLowerCase());
156
+ return requiredTags.every((entry) => itemTags.includes(entry));
212
157
  });
213
158
  }
214
-
215
- if (archived) {
216
- items = items.filter((x) => x.archived === true);
217
- } else if (!include_archived) {
218
- items = items.filter((x) => !x.archived);
219
- }
220
-
221
- if (after) {
222
- items = items.filter((x) => x.created_at > after);
223
- }
224
- if (before) {
225
- items = items.filter((x) => x.created_at < before);
226
- }
227
-
228
- const p = page ?? 1;
229
- const l = limit ?? 20;
159
+ const p = page && page > 0 ? page : 1;
160
+ const l = limit && limit > 0 ? limit : 20;
161
+ const sorted = sortItems(items, sort ?? 'created', desc ?? false);
230
162
  const start = (p - 1) * l;
231
- const totalPages = Math.max(1, Math.ceil(items.length / l));
232
- const rows = items.slice(start, start + l);
233
-
234
- return {
235
- content: [{ type: 'text', text: JSON.stringify({ page: p, limit: l, total: items.length, total_pages: totalPages, items: rows }, null, 2) }],
236
- };
237
- },
163
+ const rows = sorted.slice(start, start + l);
164
+ return jsonText({
165
+ ok: true,
166
+ page: p,
167
+ limit: l,
168
+ total: sorted.length,
169
+ total_pages: Math.max(1, Math.ceil(sorted.length / l)),
170
+ items: rows,
171
+ });
172
+ });
238
173
  });
239
174
 
240
- server.registerTool('ok_get', {
241
- title: 'Get a knowledge item',
242
- description: 'Retrieve a single item by its ID or short ID',
243
- inputSchema: createIdSchema(),
244
- handler: async ({ id, store_path }) => {
245
- const db = loadStore(resolveStore(store_path));
246
- const item = db.items.find((x) => x.id === id || x.short_id === id);
247
- if (!item) {
248
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
249
- }
250
- return { content: [{ type: 'text', text: JSON.stringify({ item }, null, 2) }] };
251
- },
175
+ registerTool(server, 'ok_get', 'Get a knowledge item', 'Retrieve a single item by ID or short ID', {
176
+ id: z.string().describe('Item ID or short ID'),
177
+ store_path: storePathField,
178
+ scope: scopeField,
179
+ }, async ({ id, store_path, scope }) => {
180
+ const storePath = resolveStorePath(store_path, scope);
181
+ return readStoreLocked(storePath, (db) => {
182
+ const item = findItem(db, id);
183
+ return item ? jsonText({ ok: true, item }) : errorText(`Item not found: ${id}`);
184
+ });
252
185
  });
253
186
 
254
- server.registerTool('ok_update', {
255
- title: 'Update a knowledge item',
256
- description: 'Update title, content, tags, or metadata of an existing item',
257
- inputSchema: createUpdateSchema(),
258
- handler: async ({ id, title, content, tags, metadata, store_path }) => {
259
- const db = loadStore(resolveStore(store_path));
260
- const item = db.items.find((x) => x.id === id || x.short_id === id);
261
- if (!item) {
262
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
263
- }
264
- if (title) item.title = title;
265
- if (content) item.content = content;
266
- if (tags) {
267
- item.tags = [...new Set([...(item.tags ?? []), ...tags])];
268
- }
269
- if (metadata) {
270
- item.metadata = { ...(item.metadata ?? {}), ...metadata };
271
- }
187
+ registerTool(server, 'ok_update', 'Update a knowledge item', 'Update title, content, URL, tags, or metadata', {
188
+ id: z.string().describe('Item ID or short ID'),
189
+ title: z.string().optional(),
190
+ content: z.string().optional(),
191
+ url: z.string().optional(),
192
+ tags: z.array(z.string()).optional(),
193
+ metadata: z.record(z.string(), z.unknown()).optional(),
194
+ store_path: storePathField,
195
+ scope: scopeField,
196
+ }, async ({ id, title, content, url, tags, metadata, store_path, scope }) => {
197
+ const storePath = resolveStorePath(store_path, scope);
198
+ const result = writeStoreLocked(storePath, (db) => {
199
+ const item = findItem(db, id);
200
+ if (!item) return null;
201
+ if (title !== undefined) item.title = title;
202
+ if (content !== undefined) item.content = content;
203
+ if (url !== undefined) item.url = url;
204
+ if (tags) item.tags = [...new Set([...(item.tags ?? []), ...tags])];
205
+ if (metadata) item.metadata = { ...(item.metadata ?? {}), ...metadata };
272
206
  item.updated_at = new Date().toISOString();
273
- saveStore(resolveStore(store_path), db);
274
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
275
- },
207
+ return item;
208
+ });
209
+ return result ? jsonText({ ok: true, item: result }) : errorText(`Item not found: ${id}`);
276
210
  });
277
211
 
278
- server.registerTool('ok_delete', {
279
- title: 'Delete a knowledge item',
280
- description: 'Permanently delete an item by ID. Requires confirm=true to prevent accidental deletion.',
281
- inputSchema: createDeleteSchema(),
282
- handler: async ({ id, confirm, store_path }) => {
283
- if (!confirm) {
284
- return { content: [{ type: 'text', text: 'Error: Refusing delete without confirm=true. Re-run with confirm: true.' }] };
285
- }
286
- const db = loadStore(resolveStore(store_path));
212
+ registerTool(server, 'ok_delete', 'Delete a knowledge item', 'Permanently delete an item by ID. Requires confirm=true.', {
213
+ id: z.string().describe('Item ID or short ID'),
214
+ confirm: z.boolean().describe('Must be true to confirm deletion'),
215
+ store_path: storePathField,
216
+ scope: scopeField,
217
+ }, async ({ id, confirm, store_path, scope }) => {
218
+ if (!confirm) return errorText('Refusing delete without confirm=true.');
219
+ const storePath = resolveStorePath(store_path, scope);
220
+ const deleted = writeStoreLocked(storePath, (db) => {
287
221
  const before = db.items.length;
288
- db.items = db.items.filter((x) => x.id !== id && x.short_id !== id);
289
- if (db.items.length === before) {
290
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
291
- }
292
- saveStore(resolveStore(store_path), db);
293
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, deleted_id: id }, null, 2) }] };
294
- },
222
+ db.items = db.items.filter((item) => item.id !== id && item.short_id !== id);
223
+ return before !== db.items.length;
224
+ });
225
+ return deleted ? jsonText({ ok: true, deleted_id: id }) : errorText(`Item not found: ${id}`);
295
226
  });
296
227
 
297
- server.registerTool('ok_archive', {
298
- title: 'Archive a knowledge item',
299
- description: 'Soft-delete an item by setting its archived flag to true',
300
- inputSchema: createIdSchema(),
301
- handler: async ({ id, store_path }) => {
302
- const db = loadStore(resolveStore(store_path));
303
- const item = db.items.find((x) => x.id === id || x.short_id === id);
304
- if (!item) {
305
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
306
- }
307
- item.archived = true;
308
- item.updated_at = new Date().toISOString();
309
- saveStore(resolveStore(store_path), db);
310
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
311
- },
228
+ registerTool(server, 'ok_archive', 'Archive a knowledge item', 'Soft-delete an item by setting archived=true', {
229
+ id: z.string(),
230
+ store_path: storePathField,
231
+ scope: scopeField,
232
+ }, async ({ id, store_path, scope }) => {
233
+ const storePath = resolveStorePath(store_path, scope);
234
+ const item = writeStoreLocked(storePath, (db) => {
235
+ const entry = findItem(db, id);
236
+ if (!entry) return null;
237
+ entry.archived = true;
238
+ entry.updated_at = new Date().toISOString();
239
+ return entry;
240
+ });
241
+ return item ? jsonText({ ok: true, item }) : errorText(`Item not found: ${id}`);
312
242
  });
313
243
 
314
- server.registerTool('ok_restore', {
315
- title: 'Restore a knowledge item',
316
- description: 'Un-archive an item by setting its archived flag back to false',
317
- inputSchema: createIdSchema(),
318
- handler: async ({ id, store_path }) => {
319
- const db = loadStore(resolveStore(store_path));
320
- const item = db.items.find((x) => x.id === id || x.short_id === id);
321
- if (!item) {
322
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
323
- }
324
- item.archived = false;
325
- item.updated_at = new Date().toISOString();
326
- saveStore(resolveStore(store_path), db);
327
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
328
- },
244
+ registerTool(server, 'ok_restore', 'Restore a knowledge item', 'Restore an archived item', {
245
+ id: z.string(),
246
+ store_path: storePathField,
247
+ scope: scopeField,
248
+ }, async ({ id, store_path, scope }) => {
249
+ const storePath = resolveStorePath(store_path, scope);
250
+ const item = writeStoreLocked(storePath, (db) => {
251
+ const entry = findItem(db, id);
252
+ if (!entry) return null;
253
+ entry.archived = false;
254
+ entry.updated_at = new Date().toISOString();
255
+ return entry;
256
+ });
257
+ return item ? jsonText({ ok: true, item }) : errorText(`Item not found: ${id}`);
329
258
  });
330
259
 
331
- server.registerTool('ok_upsert', {
332
- title: 'Upsert a knowledge item',
333
- description: 'Create or update an item by ID. Creates new if ID does not exist, updates if it does.',
334
- inputSchema: createUpsertSchema(),
335
- handler: async ({ id, title, content, tags, metadata, store_path }) => {
336
- const db = loadStore(resolveStore(store_path));
337
- let item = db.items.find((x) => x.id === id || x.short_id === id);
260
+ registerTool(server, 'ok_upsert', 'Upsert a knowledge item', 'Create or update an item by ID', {
261
+ id: z.string(),
262
+ title: z.string().optional(),
263
+ content: z.string().optional(),
264
+ tags: z.array(z.string()).optional(),
265
+ metadata: z.record(z.string(), z.unknown()).optional(),
266
+ store_path: storePathField,
267
+ scope: scopeField,
268
+ }, async ({ id, title, content, tags, metadata, store_path, scope }) => {
269
+ const storePath = resolveStorePath(store_path, scope);
270
+ const item = writeStoreLocked(storePath, (db) => {
271
+ let entry = findItem(db, id);
338
272
  const now = new Date().toISOString();
339
- if (!item) {
340
- if (!title || !content) {
341
- return { content: [{ type: 'text', text: 'Error: New item requires both title and content.' }] };
342
- }
343
- const { shortId } = makeId();
344
- item = {
273
+ if (!entry) {
274
+ if (!title || !content) return null;
275
+ entry = {
345
276
  id,
346
- short_id: shortId,
277
+ short_id: shortIdFor(id),
347
278
  title,
348
279
  content,
349
280
  tags: tags ?? [],
350
281
  metadata: metadata ?? {},
282
+ archived: false,
351
283
  created_at: now,
352
284
  updated_at: now,
353
285
  };
354
- db.items.push(item);
355
- } else {
356
- if (title) item.title = title;
357
- if (content) item.content = content;
358
- if (tags) {
359
- item.tags = [...new Set([...(item.tags ?? []), ...tags])];
360
- }
361
- if (metadata) {
362
- item.metadata = { ...(item.metadata ?? {}), ...metadata };
363
- }
364
- item.updated_at = now;
286
+ db.items.push(entry);
287
+ return entry;
365
288
  }
366
- saveStore(resolveStore(store_path), db);
367
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
368
- },
289
+ if (title !== undefined) entry.title = title;
290
+ if (content !== undefined) entry.content = content;
291
+ if (tags) entry.tags = [...new Set([...(entry.tags ?? []), ...tags])];
292
+ if (metadata) entry.metadata = { ...(entry.metadata ?? {}), ...metadata };
293
+ entry.updated_at = now;
294
+ return entry;
295
+ });
296
+ return item ? jsonText({ ok: true, item }) : errorText('New item requires both title and content.');
369
297
  });
370
298
 
371
- server.registerTool('ok_untag', {
372
- title: 'Remove tags from a knowledge item',
373
- description: 'Remove specific tags from an item',
374
- inputSchema: createUntagSchema(),
375
- handler: async ({ id, tags, store_path }) => {
376
- const db = loadStore(resolveStore(store_path));
377
- const item = db.items.find((x) => x.id === id || x.short_id === id);
378
- if (!item) {
379
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
380
- }
381
- const removeTags = new Set(tags.map((t) => t.toLowerCase()));
299
+ registerTool(server, 'ok_untag', 'Remove tags from a knowledge item', 'Remove specific tags from an item', {
300
+ id: z.string(),
301
+ tags: z.array(z.string()),
302
+ store_path: storePathField,
303
+ scope: scopeField,
304
+ }, async ({ id, tags, store_path, scope }) => {
305
+ const storePath = resolveStorePath(store_path, scope);
306
+ const result = writeStoreLocked(storePath, (db) => {
307
+ const item = findItem(db, id);
308
+ if (!item) return null;
309
+ const remove = new Set(tags.map((tag) => tag.toLowerCase()));
382
310
  const before = (item.tags ?? []).length;
383
- item.tags = (item.tags ?? []).filter((t) => !removeTags.has(t.toLowerCase()));
384
- const removed = before - item.tags.length;
311
+ item.tags = (item.tags ?? []).filter((tag) => !remove.has(tag.toLowerCase()));
385
312
  item.updated_at = new Date().toISOString();
386
- saveStore(resolveStore(store_path), db);
387
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item, removed }, null, 2) }] };
388
- },
313
+ return { item, removed: before - item.tags.length };
314
+ });
315
+ return result ? jsonText({ ok: true, ...result }) : errorText(`Item not found: ${id}`);
389
316
  });
390
317
 
391
- server.registerTool('ok_bulk_delete', {
392
- title: 'Bulk delete knowledge items',
393
- description: 'Delete multiple items by tag or search pattern. Requires confirm=true.',
394
- inputSchema: createBulkDeleteSchema(),
395
- handler: async ({ tag, search, confirm, store_path }) => {
396
- if (!confirm) {
397
- return { content: [{ type: 'text', text: 'Error: Refusing bulk delete without confirm=true.' }] };
398
- }
399
- if (!tag && !search) {
400
- return { content: [{ type: 'text', text: 'Error: Missing filter. Use tag or search to specify items.' }] };
401
- }
402
- const db = loadStore(resolveStore(store_path));
403
- const before = db.items.length;
404
- let items = db.items;
405
-
406
- if (tag && tag.length > 0) {
407
- items = items.filter((x) => {
408
- const itemTags = (x.tags ?? []).map((t) => t.toLowerCase());
409
- return tag.some((t) => itemTags.includes(t.toLowerCase()));
410
- });
411
- }
318
+ registerTool(server, 'ok_bulk_delete', 'Bulk delete knowledge items', 'Delete multiple items by tag or search. Requires confirm=true.', {
319
+ tag: z.array(z.string()).optional(),
320
+ search: z.string().optional(),
321
+ confirm: z.boolean(),
322
+ store_path: storePathField,
323
+ scope: scopeField,
324
+ }, async ({ tag, search, confirm, store_path, scope }) => {
325
+ if (!confirm) return errorText('Refusing bulk delete without confirm=true.');
326
+ if (!tag && !search) return errorText('Missing filter. Use tag or search.');
327
+ const storePath = resolveStorePath(store_path, scope);
328
+ const deleted = writeStoreLocked(storePath, (db) => {
329
+ const q = search ? search.toLowerCase() : '';
330
+ const tags = (tag ?? []).map((entry) => entry.toLowerCase());
331
+ const deleteIds = new Set(db.items.filter((item) => {
332
+ const matchesSearch = q ? item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q) : false;
333
+ const itemTags = (item.tags ?? []).map((entry) => entry.toLowerCase());
334
+ const matchesTag = tags.length > 0 ? tags.some((entry) => itemTags.includes(entry)) : false;
335
+ return matchesSearch || matchesTag;
336
+ }).map((item) => item.id));
337
+ db.items = db.items.filter((item) => !deleteIds.has(item.id));
338
+ return deleteIds.size;
339
+ });
340
+ return jsonText({ ok: true, deleted });
341
+ });
412
342
 
413
- if (search) {
414
- const q = search.toLowerCase();
415
- items = items.filter((x) => x.title.toLowerCase().includes(q) || x.content.toLowerCase().includes(q));
343
+ registerTool(server, 'ok_prune', 'Prune knowledge items', 'Remove old and/or empty knowledge items. Requires confirm=true.', {
344
+ older_than_days: z.number().optional().describe('Remove items older than N days'),
345
+ empty: z.boolean().optional().describe('Remove items with empty content'),
346
+ confirm: z.boolean(),
347
+ store_path: storePathField,
348
+ scope: scopeField,
349
+ }, async ({ older_than_days, empty, confirm, store_path, scope }) => {
350
+ if (!confirm) return errorText('Refusing prune without confirm=true.');
351
+ const storePath = resolveStorePath(store_path, scope);
352
+ const pruned = writeStoreLocked(storePath, (db) => {
353
+ const before = db.items.length;
354
+ let cutoff = null;
355
+ if (older_than_days !== undefined) {
356
+ cutoff = new Date();
357
+ cutoff.setDate(cutoff.getDate() - older_than_days);
416
358
  }
359
+ db.items = db.items.filter((item) => {
360
+ if (cutoff && new Date(item.created_at) < cutoff) return false;
361
+ if (empty && item.content.trim().length === 0) return false;
362
+ return true;
363
+ });
364
+ return before - db.items.length;
365
+ });
366
+ return jsonText({ ok: true, pruned });
367
+ });
417
368
 
418
- const deleteIds = new Set(items.map((x) => x.id));
419
- db.items = db.items.filter((x) => !deleteIds.has(x.id));
420
- const deleted = before - db.items.length;
421
- saveStore(resolveStore(store_path), db);
422
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, deleted }, null, 2) }] };
423
- },
369
+ registerTool(server, 'ok_dedupe', 'Dedupe knowledge items', 'Remove duplicate items by title and content. Requires confirm=true.', {
370
+ confirm: z.boolean(),
371
+ store_path: storePathField,
372
+ scope: scopeField,
373
+ }, async ({ confirm, store_path, scope }) => {
374
+ if (!confirm) return errorText('Refusing dedupe without confirm=true.');
375
+ const storePath = resolveStorePath(store_path, scope);
376
+ const removed = writeStoreLocked(storePath, (db) => {
377
+ const seen = new Set();
378
+ const before = db.items.length;
379
+ db.items = db.items.filter((item) => {
380
+ const key = `${item.title}\u0000${item.content}`;
381
+ if (seen.has(key)) return false;
382
+ seen.add(key);
383
+ return true;
384
+ });
385
+ return before - db.items.length;
386
+ });
387
+ return jsonText({ ok: true, removed });
424
388
  });
425
389
 
426
- server.registerTool('ok_stats', {
427
- title: 'Knowledge store statistics',
428
- description: 'Get stats about the knowledge store: total items, tags, recent activity',
429
- inputSchema: createStatsSchema(),
430
- handler: async ({ store_path }) => {
431
- const db = loadStore(resolveStore(store_path));
432
- const items = db.items.filter((x) => !x.archived);
433
- const total = items.length;
390
+ registerTool(server, 'ok_stats', 'Knowledge store statistics', 'Get aggregate stats about the knowledge store', {
391
+ store_path: storePathField,
392
+ scope: scopeField,
393
+ }, async ({ store_path, scope }) => {
394
+ const storePath = resolveStorePath(store_path, scope);
395
+ return readStoreLocked(storePath, (db) => {
396
+ const items = activeItems(db.items, false);
434
397
  const tagCounts = {};
435
398
  for (const item of items) {
436
- for (const t of (item.tags ?? [])) {
437
- tagCounts[t] = (tagCounts[t] ?? 0) + 1;
438
- }
399
+ for (const tag of item.tags ?? []) tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
439
400
  }
440
- const now = new Date();
441
- const today = now.toISOString().slice(0, 10);
442
- const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
443
- return {
444
- content: [{ type: 'text', text: JSON.stringify({
445
- total,
446
- created_today: items.filter((x) => x.created_at.slice(0, 10) === today).length,
447
- created_week: items.filter((x) => x.created_at > weekAgo).length,
448
- updated_week: items.filter((x) => x.updated_at && x.updated_at > weekAgo).length,
449
- tags: Object.fromEntries(Object.entries(tagCounts).sort((a, b) => b[1] - a[1])),
450
- }, null, 2) }],
451
- };
452
- },
401
+ return jsonText({
402
+ ok: true,
403
+ total: items.length,
404
+ archived: db.items.length - items.length,
405
+ tags: Object.fromEntries(Object.entries(tagCounts).sort((a, b) => b[1] - a[1])),
406
+ });
407
+ });
453
408
  });
454
409
 
455
- server.registerTool('ok_export', {
456
- title: 'Export knowledge items',
457
- description: 'Export all items to a JSON file',
458
- inputSchema: createExportSchema(),
459
- handler: async ({ file, store_path }) => {
460
- const db = loadStore(resolveStore(store_path));
410
+ registerTool(server, 'ok_export', 'Export knowledge items', 'Export all items to a JSON file', {
411
+ file: z.string().optional().describe('Output file path'),
412
+ store_path: storePathField,
413
+ scope: scopeField,
414
+ }, async ({ file, store_path, scope }) => {
415
+ const storePath = resolveStorePath(store_path, scope);
416
+ return readStoreLocked(storePath, (db) => {
461
417
  const filePath = file || './knowledge-export.json';
462
418
  writeFileSync(filePath, JSON.stringify(db, null, 2));
463
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, file: filePath, count: db.items.length }, null, 2) }] };
464
- },
419
+ return jsonText({ ok: true, file: filePath, count: db.items.length });
420
+ });
465
421
  });
466
422
 
467
- server.registerTool('ok_import', {
468
- title: 'Import knowledge items',
469
- description: 'Import items from an exported JSON file, skipping duplicates',
470
- inputSchema: createImportSchema(),
471
- handler: async ({ file, store_path }) => {
472
- if (!existsSync(file)) {
473
- return { content: [{ type: 'text', text: `Error: File not found: ${file}` }] };
474
- }
475
- const raw = readFileSync(file, 'utf8');
476
- const imported = JSON.parse(raw);
477
- if (!imported || !Array.isArray(imported.items)) {
478
- return { content: [{ type: 'text', text: 'Error: Invalid import file: expected {"items": [...]}' }] };
479
- }
480
- const db = loadStore(resolveStore(store_path));
481
- const existingIds = new Set(db.items.map((x) => x.id));
423
+ registerTool(server, 'ok_import', 'Import knowledge items', 'Import items from an exported JSON file, skipping duplicate IDs', {
424
+ file: z.string().describe('Path to exported JSON file'),
425
+ store_path: storePathField,
426
+ scope: scopeField,
427
+ }, async ({ file, store_path, scope }) => {
428
+ if (!existsSync(file)) return errorText(`File not found: ${file}`);
429
+ const imported = JSON.parse(readFileSync(file, 'utf8'));
430
+ if (!imported || !Array.isArray(imported.items)) return errorText('Invalid import file: expected {"items": [...]}');
431
+ const storePath = resolveStorePath(store_path, scope);
432
+ const result = writeStoreLocked(storePath, (db) => {
433
+ const existingIds = new Set(db.items.map((item) => item.id));
482
434
  let added = 0;
483
435
  for (const item of imported.items) {
484
436
  if (!existingIds.has(item.id)) {
485
437
  db.items.push(item);
438
+ existingIds.add(item.id);
486
439
  added += 1;
487
440
  }
488
441
  }
489
- saveStore(resolveStore(store_path), db);
490
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, added, skipped: imported.items.length - added }, null, 2) }] };
491
- },
442
+ return { added, skipped: imported.items.length - added };
443
+ });
444
+ return jsonText({ ok: true, ...result });
492
445
  });
493
446
 
494
- server.registerTool('ok_batch', {
495
- title: 'Batch add knowledge items',
496
- description: 'Add multiple items at once from an array of item objects',
497
- inputSchema: createBatchSchema(),
498
- handler: async ({ items, store_path }) => {
499
- const db = loadStore(resolveStore(store_path));
500
- const now = new Date().toISOString();
501
- const existingIds = new Set(db.items.map((x) => x.id));
447
+ registerTool(server, 'ok_batch', 'Batch add knowledge items', 'Add multiple items at once', {
448
+ items: z.array(z.object({
449
+ id: z.string().optional(),
450
+ title: z.string(),
451
+ content: z.string(),
452
+ tags: z.array(z.string()).optional(),
453
+ metadata: z.record(z.string(), z.unknown()).optional(),
454
+ created_at: z.string().optional(),
455
+ updated_at: z.string().optional(),
456
+ })),
457
+ store_path: storePathField,
458
+ scope: scopeField,
459
+ }, async ({ items, store_path, scope }) => {
460
+ const storePath = resolveStorePath(store_path, scope);
461
+ const result = writeStoreLocked(storePath, (db) => {
462
+ const existingIds = new Set(db.items.map((item) => item.id));
502
463
  let added = 0;
503
464
  let skipped = 0;
465
+ const now = new Date().toISOString();
504
466
  for (const entry of items) {
505
467
  if (entry.id && existingIds.has(entry.id)) {
506
468
  skipped += 1;
507
469
  continue;
508
470
  }
509
- if (!entry.title || !entry.content) {
510
- skipped += 1;
511
- continue;
512
- }
513
- const ids = entry.id ? { id: entry.id, short_id: entry.short_id || null } : makeId();
514
- const item = {
515
- id: ids.id,
516
- short_id: ids.short_id,
471
+ const id = entry.id ?? makeId();
472
+ db.items.push({
473
+ id,
474
+ short_id: shortIdFor(id),
517
475
  title: entry.title,
518
476
  content: entry.content,
519
477
  tags: entry.tags ?? [],
520
478
  metadata: entry.metadata ?? {},
521
- created_at: entry.created_at || now,
522
- updated_at: entry.updated_at || now,
523
- };
524
- db.items.push(item);
479
+ archived: false,
480
+ created_at: entry.created_at ?? now,
481
+ updated_at: entry.updated_at ?? now,
482
+ });
483
+ existingIds.add(id);
525
484
  added += 1;
526
485
  }
527
- saveStore(resolveStore(store_path), db);
528
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, added, skipped }, null, 2) }] };
529
- },
486
+ return { added, skipped };
487
+ });
488
+ return jsonText({ ok: true, ...result });
530
489
  });
531
490
 
532
491
  return server;
@@ -543,7 +502,7 @@ Options:
543
502
  -h, --help Show this help text`);
544
503
  }
545
504
 
546
- async function main() {
505
+ export async function main() {
547
506
  if (process.argv.includes('-h') || process.argv.includes('--help')) {
548
507
  printHelp();
549
508
  return;