@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/README.md +86 -4
- package/bin/open-knowledge-mcp.js +14452 -0
- package/bin/open-knowledge.js +227 -4
- package/docs/architecture/ai-native-knowledge-base.md +191 -0
- package/docs/architecture/hybrid-semantic-search.md +135 -0
- package/package.json +20 -4
- package/src/artifact-store.ts +184 -0
- package/src/cli.ts +181 -25
- package/src/knowledge-db.ts +231 -0
- package/src/mcp.js +374 -415
- package/src/source-ref.ts +92 -0
- package/src/store.ts +16 -6
- package/src/wiki-layout.ts +104 -0
- package/src/workspace.ts +123 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -59
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -21
- package/.github/workflows/ci.yml +0 -49
- package/.takumi/settings.local.json +0 -7
- package/CODE_OF_CONDUCT.md +0 -31
- package/CONTRIBUTING.md +0 -83
- package/FUNDING.yml +0 -1
- package/SECURITY.md +0 -39
- package/npmignore +0 -9
- package/tests/cli.test.ts +0 -91
- package/tests/mcp-http.test.ts +0 -97
- package/tsconfig.json +0 -16
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
|
-
|
|
9
|
-
|
|
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
|
|
15
|
-
return
|
|
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
|
|
21
|
-
return
|
|
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
|
|
31
|
-
return
|
|
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
|
|
55
|
-
return
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
85
|
-
return
|
|
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
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
101
|
-
return
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
114
|
-
return
|
|
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
|
|
129
|
-
|
|
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:
|
|
71
|
+
version: pkg.version,
|
|
140
72
|
});
|
|
141
73
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
title: '
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
155
|
-
const
|
|
117
|
+
const id = makeId();
|
|
118
|
+
const entry = {
|
|
156
119
|
id,
|
|
157
|
-
short_id:
|
|
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(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
207
|
+
return item;
|
|
208
|
+
});
|
|
209
|
+
return result ? jsonText({ ok: true, item: result }) : errorText(`Item not found: ${id}`);
|
|
276
210
|
});
|
|
277
211
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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((
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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 (!
|
|
340
|
-
if (!title || !content)
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
const { shortId } = makeId();
|
|
344
|
-
item = {
|
|
273
|
+
if (!entry) {
|
|
274
|
+
if (!title || !content) return null;
|
|
275
|
+
entry = {
|
|
345
276
|
id,
|
|
346
|
-
short_id:
|
|
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(
|
|
355
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
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((
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const items = db.items
|
|
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
|
|
437
|
-
tagCounts[t] = (tagCounts[t] ?? 0) + 1;
|
|
438
|
-
}
|
|
399
|
+
for (const tag of item.tags ?? []) tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
|
|
439
400
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
464
|
-
}
|
|
419
|
+
return jsonText({ ok: true, file: filePath, count: db.items.length });
|
|
420
|
+
});
|
|
465
421
|
});
|
|
466
422
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
442
|
+
return { added, skipped: imported.items.length - added };
|
|
443
|
+
});
|
|
444
|
+
return jsonText({ ok: true, ...result });
|
|
492
445
|
});
|
|
493
446
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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;
|