@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/cli.ts
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* Copyright 2026 Hasna Inc.
|
|
5
5
|
* Licensed under the Apache License, Version 2.0
|
|
6
6
|
*/
|
|
7
|
-
import { defaultStorePath, loadStore, saveStore, withLock, makeId, ensureStore, type KnowledgeItem } from './store';
|
|
7
|
+
import { defaultStorePath, loadStore, saveStore, withLock, makeId, makeShortId, ensureStore, type KnowledgeItem } from './store';
|
|
8
|
+
import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace';
|
|
9
|
+
import { getKnowledgeDbStats, migrateKnowledgeDb } from './knowledge-db';
|
|
10
|
+
import { createArtifactStore } from './artifact-store';
|
|
11
|
+
import { initializeWikiLayout } from './wiki-layout';
|
|
8
12
|
import pkg from '../package.json' with { type: 'json' };
|
|
9
13
|
|
|
10
14
|
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
@@ -46,6 +50,8 @@ interface Flags {
|
|
|
46
50
|
scope?: string;
|
|
47
51
|
olderThan?: number;
|
|
48
52
|
empty?: boolean;
|
|
53
|
+
archived?: boolean;
|
|
54
|
+
includeArchived?: boolean;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
interface ParseResult {
|
|
@@ -53,11 +59,12 @@ interface ParseResult {
|
|
|
53
59
|
flags: Flags;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'export', 'prune', 'dedupe', 'stats', 'help'];
|
|
62
|
+
const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'help'];
|
|
57
63
|
const COMMAND_ALIASES: Record<string, string> = {
|
|
58
64
|
ls: 'list',
|
|
59
65
|
rm: 'delete',
|
|
60
66
|
edit: 'update',
|
|
67
|
+
unarchive: 'restore',
|
|
61
68
|
};
|
|
62
69
|
|
|
63
70
|
function parseArgs(argv: string[]): ParseResult {
|
|
@@ -91,6 +98,8 @@ function parseArgs(argv: string[]): ParseResult {
|
|
|
91
98
|
case '--scope': flags.scope = argv[i + 1]; i += 1; break;
|
|
92
99
|
case '--older-than': flags.olderThan = Number(argv[i + 1]); i += 1; break;
|
|
93
100
|
case '--empty': flags.empty = true; break;
|
|
101
|
+
case '--archived': flags.archived = true; break;
|
|
102
|
+
case '--include-archived': flags.includeArchived = true; break;
|
|
94
103
|
default: throw new Error(`Unknown flag: ${token}. Run 'open-knowledge --help' for valid options.`);
|
|
95
104
|
}
|
|
96
105
|
}
|
|
@@ -141,17 +150,24 @@ Commands:
|
|
|
141
150
|
list (alias: ls) List items (supports pagination/search/sort/tag)
|
|
142
151
|
get --id <id> Get one item
|
|
143
152
|
update --id <id> Update an item (--title, --content, --url, --tag)
|
|
153
|
+
archive --id <id> Archive an item
|
|
154
|
+
restore --id <id> Restore an archived item
|
|
155
|
+
upsert [title] [content] Create or update an item by --id
|
|
156
|
+
untag --id <id> -t <tag> Remove a tag from an item
|
|
144
157
|
delete (alias: rm) --id <id> Delete item (requires --yes)
|
|
145
158
|
export Export all items (--format jsonl)
|
|
146
159
|
prune Remove old/empty items (requires --yes)
|
|
147
160
|
dedupe Remove duplicate items by title+content (requires --yes)
|
|
148
161
|
stats Show knowledge base statistics
|
|
162
|
+
paths Show resolved workspace/store paths
|
|
163
|
+
db init|stats Initialize or inspect local knowledge.db
|
|
164
|
+
wiki init Initialize scalable wiki/schema/index/log artifacts
|
|
149
165
|
help [command] Show help
|
|
150
166
|
|
|
151
167
|
Global Options:
|
|
152
168
|
--json Output JSON
|
|
153
169
|
--store <path> Override store path
|
|
154
|
-
--scope local|global|project Store scope (default: global ~/.
|
|
170
|
+
--scope local|global|project Store scope (default: global ~/.hasna/apps/knowledge/)
|
|
155
171
|
--no-color Disable color output
|
|
156
172
|
--completions <shell> Output completions for bash|zsh|fish
|
|
157
173
|
-v, --version Show version
|
|
@@ -165,6 +181,8 @@ List Options:
|
|
|
165
181
|
-t, --tag <tag> Filter by tag
|
|
166
182
|
--sort <created|title> Sort field (default: created)
|
|
167
183
|
--desc Sort descending
|
|
184
|
+
--archived Show only archived items
|
|
185
|
+
--include-archived Include archived items
|
|
168
186
|
|
|
169
187
|
Add/Update Options:
|
|
170
188
|
--url <url> Attach source URL
|
|
@@ -193,11 +211,18 @@ function printCommandHelp(command: string): void {
|
|
|
193
211
|
if (command === 'list' || command === 'ls') { console.log('Usage: open-knowledge list|ls [--format table|json] [-p <page>] [-l <limit>] [-s <search>] [-t <tag>] [--sort created|title] [--desc] [--json]'); return; }
|
|
194
212
|
if (command === 'get') { console.log('Usage: open-knowledge get --id <id> [--json]'); return; }
|
|
195
213
|
if (command === 'update' || command === 'edit') { console.log('Usage: open-knowledge update|edit --id <id> [--title <title>] [--content <content>] [--url <url>] [-t <tag>] [--json]'); return; }
|
|
214
|
+
if (command === 'archive') { console.log('Usage: open-knowledge archive --id <id> [--json]'); return; }
|
|
215
|
+
if (command === 'restore' || command === 'unarchive') { console.log('Usage: open-knowledge restore|unarchive --id <id> [--json]'); return; }
|
|
216
|
+
if (command === 'upsert') { console.log('Usage: open-knowledge upsert [title] [content] [--id <id>] [--title <title>] [--content <content>] [--url <url>] [-t <tag>] [--json]'); return; }
|
|
217
|
+
if (command === 'untag') { console.log('Usage: open-knowledge untag --id <id> -t <tag> [--json]'); return; }
|
|
196
218
|
if (command === 'delete' || command === 'rm') { console.log('Usage: open-knowledge delete|rm --id <id> -y [--json]'); return; }
|
|
197
219
|
if (command === 'export') { console.log('Usage: open-knowledge export [--format jsonl] [--json]'); return; }
|
|
198
220
|
if (command === 'prune') { console.log('Usage: open-knowledge prune --yes [--older-than <days>] [--empty] [--json]'); return; }
|
|
199
221
|
if (command === 'dedupe') { console.log('Usage: open-knowledge dedupe --yes [--json]'); return; }
|
|
200
222
|
if (command === 'stats') { console.log('Usage: open-knowledge stats [--json]'); return; }
|
|
223
|
+
if (command === 'paths') { console.log('Usage: open-knowledge paths [--scope local|global|project] [--json]'); return; }
|
|
224
|
+
if (command === 'db') { console.log('Usage: open-knowledge db init|stats [--scope local|global|project] [--json]'); return; }
|
|
225
|
+
if (command === 'wiki') { console.log('Usage: open-knowledge wiki init [--scope local|global|project] [--json]'); return; }
|
|
201
226
|
printGlobalHelp();
|
|
202
227
|
}
|
|
203
228
|
|
|
@@ -230,20 +255,23 @@ function sortItems(items: KnowledgeItem[], flags: Flags): { sorted: KnowledgeIte
|
|
|
230
255
|
return { sorted, sort, direction: flags.desc ? 'desc' : 'asc' };
|
|
231
256
|
}
|
|
232
257
|
|
|
233
|
-
function run(argv: string[]): void {
|
|
258
|
+
async function run(argv: string[]): Promise<void> {
|
|
234
259
|
const { positional, flags } = parseArgs(argv);
|
|
235
260
|
log('debug', 'CLI invoked', { command: positional[0], flags: { json: flags.json, store: flags.store } });
|
|
236
261
|
|
|
237
|
-
if (flags.version) {
|
|
262
|
+
if (flags.version) {
|
|
263
|
+
console.log(flags.json ? JSON.stringify({ name: pkg.name, version: pkg.version }, null, 2) : `${pkg.name} ${pkg.version}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
238
266
|
|
|
239
267
|
if (flags.completions) {
|
|
240
268
|
const shell = flags.completions;
|
|
241
269
|
if (shell === 'bash') {
|
|
242
|
-
console.log(`_open_knowledge() { local cur; cur="${"$"}{COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update delete export help ls rm edit --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --no-color --scope" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
|
|
270
|
+
console.log(`_open_knowledge() { local cur; cur="${"$"}{COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki help ls rm edit unarchive --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
|
|
243
271
|
} else if (shell === 'zsh') {
|
|
244
|
-
console.log(`#compdef open-knowledge\n_open_knowledge() { _arguments -C "1: :(add list get update delete export help ls rm edit)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(-p --page)"{-p,--page}"[page number]:number:" "(-l --limit)"{-l,--limit}"[items per page]:number:" "(-s --search)"{-s,--search}"[search text]:text:" "(--sort)--sort"\{created,title\}:" "(--id)--id[item id]:id:" "(--store)--store[store path]:path:" "(--title)--title[new title]:" "(--content)--content[new content]:" "(--url)--url[source url]:" "(-t --tag)"{-t,--tag}"[tag]:tag:" "(--format)--format[json|jsonl]:" "(--completions)--completions[output completions]:shell:(bash zsh fish):" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
|
|
272
|
+
console.log(`#compdef open-knowledge\n_open_knowledge() { _arguments -C "1: :(add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki help ls rm edit unarchive)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(--archived)--archived" "(--include-archived)--include-archived" "(-p --page)"{-p,--page}"[page number]:number:" "(-l --limit)"{-l,--limit}"[items per page]:number:" "(-s --search)"{-s,--search}"[search text]:text:" "(--sort)--sort"\{created,title\}:" "(--id)--id[item id]:id:" "(--store)--store[store path]:path:" "(--title)--title[new title]:" "(--content)--content[new content]:" "(--url)--url[source url]:" "(-t --tag)"{-t,--tag}"[tag]:tag:" "(--format)--format[json|jsonl]:" "(--completions)--completions[output completions]:shell:(bash zsh fish):" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
|
|
245
273
|
} else if (shell === 'fish') {
|
|
246
|
-
console.log(`complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update delete export help ls rm edit"; complete -c open-knowledge -l json; complete -c open-knowledge -l yes -s y; complete -c open-knowledge -l help -s h; complete -c open-knowledge -l version -s v; complete -c open-knowledge -l desc; complete -c open-knowledge -s p -l page; complete -c open-knowledge -s l -l limit; complete -c open-knowledge -s s -l search; complete -c open-knowledge -l sort; complete -c open-knowledge -l id; complete -c open-knowledge -l store; complete -c open-knowledge -l title; complete -c open-knowledge -l content; complete -c open-knowledge -l url; complete -c open-knowledge -s t -l tag; complete -c open-knowledge -l format; complete -c open-knowledge -l completions; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
|
|
274
|
+
console.log(`complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki help ls rm edit unarchive"; complete -c open-knowledge -l json; complete -c open-knowledge -l yes -s y; complete -c open-knowledge -l help -s h; complete -c open-knowledge -l version -s v; complete -c open-knowledge -l desc; complete -c open-knowledge -l archived; complete -c open-knowledge -l include-archived; complete -c open-knowledge -s p -l page; complete -c open-knowledge -s l -l limit; complete -c open-knowledge -s s -l search; complete -c open-knowledge -l sort; complete -c open-knowledge -l id; complete -c open-knowledge -l store; complete -c open-knowledge -l title; complete -c open-knowledge -l content; complete -c open-knowledge -l url; complete -c open-knowledge -s t -l tag; complete -c open-knowledge -l format; complete -c open-knowledge -l completions; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
|
|
247
275
|
} else {
|
|
248
276
|
throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
|
|
249
277
|
}
|
|
@@ -254,16 +282,64 @@ function run(argv: string[]): void {
|
|
|
254
282
|
|
|
255
283
|
if (!command || flags.help || command === 'help') { printCommandHelp(positional[1]); return; }
|
|
256
284
|
|
|
285
|
+
const workspace = resolveScopedWorkspace(flags.scope);
|
|
257
286
|
let storePath = flags.store;
|
|
258
287
|
if (!storePath) {
|
|
259
|
-
if (flags.scope === 'project') {
|
|
260
|
-
storePath =
|
|
288
|
+
if (flags.scope === 'project' || flags.scope === 'local') {
|
|
289
|
+
storePath = ensureKnowledgeWorkspace(workspace.home).jsonStorePath;
|
|
261
290
|
} else {
|
|
262
|
-
// local (default) and global both use the global store for now
|
|
263
|
-
// project scope uses a project-local store
|
|
264
291
|
storePath = defaultStorePath();
|
|
265
292
|
}
|
|
266
293
|
}
|
|
294
|
+
|
|
295
|
+
if (command === 'paths') {
|
|
296
|
+
const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
|
|
297
|
+
output({
|
|
298
|
+
ok: true,
|
|
299
|
+
scope: flags.scope ?? 'global',
|
|
300
|
+
home: resolvedWorkspace.home,
|
|
301
|
+
config_path: resolvedWorkspace.configPath,
|
|
302
|
+
json_store_path: resolvedWorkspace.jsonStorePath,
|
|
303
|
+
knowledge_db_path: resolvedWorkspace.knowledgeDbPath,
|
|
304
|
+
artifacts_dir: resolvedWorkspace.artifactsDir,
|
|
305
|
+
indexes_dir: resolvedWorkspace.indexesDir,
|
|
306
|
+
logs_dir: resolvedWorkspace.logsDir,
|
|
307
|
+
runs_dir: resolvedWorkspace.runsDir,
|
|
308
|
+
schemas_dir: resolvedWorkspace.schemasDir,
|
|
309
|
+
wiki_dir: resolvedWorkspace.wikiDir,
|
|
310
|
+
config: readKnowledgeConfig(resolvedWorkspace.configPath),
|
|
311
|
+
message: resolvedWorkspace.home,
|
|
312
|
+
}, flags.json);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (command === 'db') {
|
|
317
|
+
const action = positional[1] ?? 'init';
|
|
318
|
+
const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
|
|
319
|
+
if (action !== 'init' && action !== 'stats') {
|
|
320
|
+
throw new Error("Invalid db action. Use 'init' or 'stats'.");
|
|
321
|
+
}
|
|
322
|
+
if (action === 'init') {
|
|
323
|
+
const result = migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
|
|
324
|
+
output({ ok: true, ...result, message: `Initialized ${result.path}` }, flags.json);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
|
|
328
|
+
const stats = getKnowledgeDbStats(resolvedWorkspace.knowledgeDbPath);
|
|
329
|
+
output({ ok: true, path: resolvedWorkspace.knowledgeDbPath, ...stats, message: `knowledge.db schema v${stats.schema_version}` }, flags.json);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (command === 'wiki') {
|
|
334
|
+
const action = positional[1] ?? 'init';
|
|
335
|
+
if (action !== 'init') throw new Error("Invalid wiki action. Use 'init'.");
|
|
336
|
+
const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
|
|
337
|
+
const config = readKnowledgeConfig(resolvedWorkspace.configPath);
|
|
338
|
+
const artifactStore = createArtifactStore(config, resolvedWorkspace);
|
|
339
|
+
const result = await initializeWikiLayout(artifactStore);
|
|
340
|
+
output({ ok: true, ...result, message: `Initialized wiki layout in ${resolvedWorkspace.home}` }, flags.json);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
267
343
|
ensureStore(storePath);
|
|
268
344
|
|
|
269
345
|
if (command === 'add') {
|
|
@@ -303,6 +379,8 @@ function run(argv: string[]): void {
|
|
|
303
379
|
const useJson = flags.json || flags.format === 'json';
|
|
304
380
|
|
|
305
381
|
let filtered = db.items;
|
|
382
|
+
if (flags.archived) filtered = filtered.filter((x) => x.archived === true);
|
|
383
|
+
else if (!flags.includeArchived) filtered = filtered.filter((x) => !x.archived);
|
|
306
384
|
if (search) filtered = filtered.filter((x) => x.title.toLowerCase().includes(search) || x.content.toLowerCase().includes(search));
|
|
307
385
|
if (tag) filtered = filtered.filter((x) => x.tags && x.tags.map((t) => t.toLowerCase()).includes(tag));
|
|
308
386
|
|
|
@@ -335,7 +413,7 @@ function run(argv: string[]): void {
|
|
|
335
413
|
requireId(flags);
|
|
336
414
|
withLock(storePath, () => {
|
|
337
415
|
const db = loadStore(storePath);
|
|
338
|
-
const item = db.items.find((x) => x.id === flags.id);
|
|
416
|
+
const item = db.items.find((x) => x.id === flags.id || x.short_id === flags.id);
|
|
339
417
|
if (!item) throw new Error(`Item not found: ${flags.id}`);
|
|
340
418
|
output({ ok: true, item, message: `${item.id}: ${item.title}` }, flags.json);
|
|
341
419
|
});
|
|
@@ -346,7 +424,7 @@ function run(argv: string[]): void {
|
|
|
346
424
|
requireId(flags);
|
|
347
425
|
withLock(storePath, () => {
|
|
348
426
|
const db = loadStore(storePath);
|
|
349
|
-
const idx = db.items.findIndex((x) => x.id === flags.id);
|
|
427
|
+
const idx = db.items.findIndex((x) => x.id === flags.id || x.short_id === flags.id);
|
|
350
428
|
if (idx === -1) throw new Error(`Item not found: ${flags.id}`);
|
|
351
429
|
const item = db.items[idx];
|
|
352
430
|
if (flags.title !== undefined) item.title = flags.title;
|
|
@@ -366,13 +444,90 @@ function run(argv: string[]): void {
|
|
|
366
444
|
return;
|
|
367
445
|
}
|
|
368
446
|
|
|
447
|
+
if (command === 'archive' || command === 'restore') {
|
|
448
|
+
requireId(flags);
|
|
449
|
+
withLock(storePath, () => {
|
|
450
|
+
const db = loadStore(storePath);
|
|
451
|
+
const idx = db.items.findIndex((x) => x.id === flags.id || x.short_id === flags.id);
|
|
452
|
+
if (idx === -1) throw new Error(`Item not found: ${flags.id}`);
|
|
453
|
+
const item = db.items[idx];
|
|
454
|
+
item.archived = command === 'archive';
|
|
455
|
+
item.updated_at = new Date().toISOString();
|
|
456
|
+
db.items[idx] = item;
|
|
457
|
+
saveStore(storePath, db);
|
|
458
|
+
output({ ok: true, item, message: `${command === 'archive' ? 'Archived' : 'Restored'} ${item.id}` }, flags.json);
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (command === 'untag') {
|
|
464
|
+
requireId(flags);
|
|
465
|
+
if (!flags.tag) throw new Error('Missing required --tag. Example: open-knowledge untag --id <id> -t <tag>');
|
|
466
|
+
withLock(storePath, () => {
|
|
467
|
+
const db = loadStore(storePath);
|
|
468
|
+
const idx = db.items.findIndex((x) => x.id === flags.id || x.short_id === flags.id);
|
|
469
|
+
if (idx === -1) throw new Error(`Item not found: ${flags.id}`);
|
|
470
|
+
const item = db.items[idx];
|
|
471
|
+
const before = item.tags?.length ?? 0;
|
|
472
|
+
item.tags = (item.tags ?? []).filter((tag) => tag.toLowerCase() !== flags.tag!.toLowerCase());
|
|
473
|
+
item.updated_at = new Date().toISOString();
|
|
474
|
+
db.items[idx] = item;
|
|
475
|
+
saveStore(storePath, db);
|
|
476
|
+
output({ ok: true, item, removed: before - item.tags.length, message: `Removed tag from ${item.id}` }, flags.json);
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (command === 'upsert') {
|
|
482
|
+
const title = flags.title ?? positional[1];
|
|
483
|
+
const content = flags.content ?? positional[2];
|
|
484
|
+
withLock(storePath, () => {
|
|
485
|
+
const db = loadStore(storePath);
|
|
486
|
+
const idx = flags.id ? db.items.findIndex((x) => x.id === flags.id || x.short_id === flags.id) : -1;
|
|
487
|
+
const now = new Date().toISOString();
|
|
488
|
+
if (idx === -1) {
|
|
489
|
+
if (!title || !content) throw new Error('New item requires title and content. Example: open-knowledge upsert <title> <content> [--id <id>]');
|
|
490
|
+
const id = flags.id ?? makeId();
|
|
491
|
+
const item: KnowledgeItem = {
|
|
492
|
+
id,
|
|
493
|
+
short_id: makeShortId(id),
|
|
494
|
+
title,
|
|
495
|
+
content,
|
|
496
|
+
url: flags.url ?? null,
|
|
497
|
+
tags: flags.tag ? [flags.tag] : [],
|
|
498
|
+
metadata: {},
|
|
499
|
+
archived: false,
|
|
500
|
+
created_at: now,
|
|
501
|
+
updated_at: now,
|
|
502
|
+
};
|
|
503
|
+
db.items.push(item);
|
|
504
|
+
saveStore(storePath, db);
|
|
505
|
+
output({ ok: true, created: true, item, message: `Upserted ${item.id}` }, flags.json);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const item = db.items[idx];
|
|
509
|
+
if (title !== undefined) item.title = title;
|
|
510
|
+
if (content !== undefined) item.content = content;
|
|
511
|
+
if (flags.url !== undefined) item.url = flags.url;
|
|
512
|
+
if (flags.tag !== undefined) {
|
|
513
|
+
item.tags = item.tags || [];
|
|
514
|
+
if (!item.tags.map((tag) => tag.toLowerCase()).includes(flags.tag.toLowerCase())) item.tags.push(flags.tag);
|
|
515
|
+
}
|
|
516
|
+
item.updated_at = now;
|
|
517
|
+
db.items[idx] = item;
|
|
518
|
+
saveStore(storePath, db);
|
|
519
|
+
output({ ok: true, created: false, item, message: `Upserted ${item.id}` }, flags.json);
|
|
520
|
+
});
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
369
524
|
if (command === 'delete') {
|
|
370
525
|
requireId(flags);
|
|
371
526
|
if (!flags.yes) throw new Error('Refusing delete without --yes. Re-run with: open-knowledge delete --id <id> --yes');
|
|
372
527
|
withLock(storePath, () => {
|
|
373
528
|
const db = loadStore(storePath);
|
|
374
529
|
const before = db.items.length;
|
|
375
|
-
db.items = db.items.filter((x) => x.id !== flags.id);
|
|
530
|
+
db.items = db.items.filter((x) => x.id !== flags.id && x.short_id !== flags.id);
|
|
376
531
|
const deleted = before !== db.items.length;
|
|
377
532
|
saveStore(storePath, db);
|
|
378
533
|
if (!deleted) throw new Error(`Item not found: ${flags.id}`);
|
|
@@ -440,13 +595,15 @@ function run(argv: string[]): void {
|
|
|
440
595
|
if (command === 'stats') {
|
|
441
596
|
withLock(storePath, () => {
|
|
442
597
|
const db = loadStore(storePath);
|
|
443
|
-
const
|
|
444
|
-
const
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
const
|
|
598
|
+
const activeItems = db.items.filter((x) => !x.archived);
|
|
599
|
+
const total = activeItems.length;
|
|
600
|
+
const archived = db.items.length - total;
|
|
601
|
+
const withUrl = activeItems.filter((x) => x.url).length;
|
|
602
|
+
const withTags = activeItems.filter((x) => x.tags && x.tags.length > 0).length;
|
|
603
|
+
const oldest = total > 0 ? activeItems.map((x) => x.created_at).sort()[0] : null;
|
|
604
|
+
const newest = total > 0 ? activeItems.map((x) => x.created_at).sort()[total - 1] : null;
|
|
448
605
|
const tagCounts: Record<string, number> = {};
|
|
449
|
-
for (const item of
|
|
606
|
+
for (const item of activeItems) {
|
|
450
607
|
for (const tag of item.tags || []) {
|
|
451
608
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
452
609
|
}
|
|
@@ -455,6 +612,7 @@ function run(argv: string[]): void {
|
|
|
455
612
|
output({
|
|
456
613
|
ok: true,
|
|
457
614
|
total,
|
|
615
|
+
archived,
|
|
458
616
|
with_url: withUrl,
|
|
459
617
|
with_tags: withTags,
|
|
460
618
|
oldest,
|
|
@@ -473,14 +631,12 @@ function run(argv: string[]): void {
|
|
|
473
631
|
}
|
|
474
632
|
|
|
475
633
|
if (import.meta.main) {
|
|
476
|
-
|
|
477
|
-
run(process.argv.slice(2));
|
|
478
|
-
} catch (error) {
|
|
634
|
+
run(process.argv.slice(2)).catch((error) => {
|
|
479
635
|
const message = error instanceof Error ? error.message : String(error);
|
|
480
636
|
log('error', 'CLI error', { message, stack: error instanceof Error ? error.stack : undefined });
|
|
481
637
|
console.error(`Error: ${message}`);
|
|
482
638
|
process.exitCode = 1;
|
|
483
|
-
}
|
|
639
|
+
});
|
|
484
640
|
}
|
|
485
641
|
|
|
486
642
|
export { run, parseArgs, suggestCommand, sortItems };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { ensureParentDir } from './workspace';
|
|
3
|
+
|
|
4
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export interface KnowledgeDbStats {
|
|
7
|
+
schema_version: number;
|
|
8
|
+
sources: number;
|
|
9
|
+
source_revisions: number;
|
|
10
|
+
chunks: number;
|
|
11
|
+
wiki_pages: number;
|
|
12
|
+
citations: number;
|
|
13
|
+
indexes: number;
|
|
14
|
+
runs: number;
|
|
15
|
+
run_events: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MIGRATION_1 = `
|
|
19
|
+
PRAGMA journal_mode = WAL;
|
|
20
|
+
PRAGMA foreign_keys = ON;
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS schema_versions (
|
|
23
|
+
version INTEGER PRIMARY KEY,
|
|
24
|
+
applied_at TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
uri TEXT NOT NULL UNIQUE,
|
|
30
|
+
kind TEXT NOT NULL,
|
|
31
|
+
title TEXT,
|
|
32
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
33
|
+
acl_json TEXT NOT NULL DEFAULT '{}',
|
|
34
|
+
created_at TEXT NOT NULL,
|
|
35
|
+
updated_at TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS source_revisions (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
|
41
|
+
revision TEXT NOT NULL,
|
|
42
|
+
hash TEXT,
|
|
43
|
+
extracted_text_uri TEXT,
|
|
44
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
45
|
+
created_at TEXT NOT NULL,
|
|
46
|
+
UNIQUE(source_id, revision)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
source_revision_id TEXT REFERENCES source_revisions(id) ON DELETE CASCADE,
|
|
52
|
+
wiki_page_id TEXT,
|
|
53
|
+
kind TEXT NOT NULL,
|
|
54
|
+
ordinal INTEGER NOT NULL,
|
|
55
|
+
text TEXT NOT NULL,
|
|
56
|
+
token_count INTEGER,
|
|
57
|
+
start_offset INTEGER,
|
|
58
|
+
end_offset INTEGER,
|
|
59
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
60
|
+
created_at TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS chunk_embeddings (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
chunk_id TEXT NOT NULL REFERENCES chunks(id) ON DELETE CASCADE,
|
|
66
|
+
provider TEXT NOT NULL,
|
|
67
|
+
model TEXT NOT NULL,
|
|
68
|
+
dimensions INTEGER NOT NULL,
|
|
69
|
+
vector_json TEXT NOT NULL,
|
|
70
|
+
created_at TEXT NOT NULL,
|
|
71
|
+
UNIQUE(chunk_id, provider, model)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS wiki_pages (
|
|
75
|
+
id TEXT PRIMARY KEY,
|
|
76
|
+
path TEXT NOT NULL UNIQUE,
|
|
77
|
+
title TEXT NOT NULL,
|
|
78
|
+
artifact_uri TEXT,
|
|
79
|
+
content_hash TEXT,
|
|
80
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
81
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
82
|
+
created_at TEXT NOT NULL,
|
|
83
|
+
updated_at TEXT NOT NULL
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE TABLE IF NOT EXISTS wiki_backlinks (
|
|
87
|
+
from_page_id TEXT NOT NULL REFERENCES wiki_pages(id) ON DELETE CASCADE,
|
|
88
|
+
to_page_id TEXT NOT NULL REFERENCES wiki_pages(id) ON DELETE CASCADE,
|
|
89
|
+
label TEXT,
|
|
90
|
+
created_at TEXT NOT NULL,
|
|
91
|
+
PRIMARY KEY(from_page_id, to_page_id)
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
CREATE TABLE IF NOT EXISTS citations (
|
|
95
|
+
id TEXT PRIMARY KEY,
|
|
96
|
+
wiki_page_id TEXT REFERENCES wiki_pages(id) ON DELETE CASCADE,
|
|
97
|
+
chunk_id TEXT REFERENCES chunks(id) ON DELETE SET NULL,
|
|
98
|
+
source_uri TEXT NOT NULL,
|
|
99
|
+
quote TEXT,
|
|
100
|
+
start_offset INTEGER,
|
|
101
|
+
end_offset INTEGER,
|
|
102
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
103
|
+
created_at TEXT NOT NULL
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
CREATE TABLE IF NOT EXISTS knowledge_indexes (
|
|
107
|
+
id TEXT PRIMARY KEY,
|
|
108
|
+
kind TEXT NOT NULL,
|
|
109
|
+
name TEXT NOT NULL,
|
|
110
|
+
artifact_uri TEXT,
|
|
111
|
+
shard_key TEXT,
|
|
112
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
113
|
+
created_at TEXT NOT NULL,
|
|
114
|
+
updated_at TEXT NOT NULL,
|
|
115
|
+
UNIQUE(kind, name, shard_key)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
119
|
+
id TEXT PRIMARY KEY,
|
|
120
|
+
type TEXT NOT NULL,
|
|
121
|
+
prompt TEXT,
|
|
122
|
+
status TEXT NOT NULL,
|
|
123
|
+
provider TEXT,
|
|
124
|
+
model TEXT,
|
|
125
|
+
cost_tokens INTEGER NOT NULL DEFAULT 0,
|
|
126
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
127
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
128
|
+
created_at TEXT NOT NULL,
|
|
129
|
+
updated_at TEXT NOT NULL
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
CREATE TABLE IF NOT EXISTS run_events (
|
|
133
|
+
id TEXT PRIMARY KEY,
|
|
134
|
+
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
135
|
+
level TEXT NOT NULL,
|
|
136
|
+
event TEXT NOT NULL,
|
|
137
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
138
|
+
created_at TEXT NOT NULL
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
CREATE TABLE IF NOT EXISTS provider_usage (
|
|
142
|
+
id TEXT PRIMARY KEY,
|
|
143
|
+
run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
|
|
144
|
+
provider TEXT NOT NULL,
|
|
145
|
+
model TEXT NOT NULL,
|
|
146
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
147
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
148
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
149
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
150
|
+
created_at TEXT NOT NULL
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
CREATE TABLE IF NOT EXISTS redaction_findings (
|
|
154
|
+
id TEXT PRIMARY KEY,
|
|
155
|
+
source_uri TEXT,
|
|
156
|
+
run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
|
|
157
|
+
severity TEXT NOT NULL,
|
|
158
|
+
finding_type TEXT NOT NULL,
|
|
159
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
160
|
+
created_at TEXT NOT NULL
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
CREATE TABLE IF NOT EXISTS storage_objects (
|
|
164
|
+
id TEXT PRIMARY KEY,
|
|
165
|
+
artifact_uri TEXT NOT NULL UNIQUE,
|
|
166
|
+
kind TEXT NOT NULL,
|
|
167
|
+
content_type TEXT,
|
|
168
|
+
hash TEXT,
|
|
169
|
+
size_bytes INTEGER,
|
|
170
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
171
|
+
created_at TEXT NOT NULL,
|
|
172
|
+
updated_at TEXT NOT NULL
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
176
|
+
text,
|
|
177
|
+
title,
|
|
178
|
+
source_uri,
|
|
179
|
+
content='',
|
|
180
|
+
tokenize='porter unicode61'
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
INSERT OR IGNORE INTO schema_versions(version, applied_at)
|
|
184
|
+
VALUES (1, datetime('now'));
|
|
185
|
+
`;
|
|
186
|
+
|
|
187
|
+
export function openKnowledgeDb(path: string): Database {
|
|
188
|
+
ensureParentDir(path);
|
|
189
|
+
const db = new Database(path);
|
|
190
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
191
|
+
return db;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function migrateKnowledgeDb(path: string): { path: string; schema_version: number } {
|
|
195
|
+
const db = openKnowledgeDb(path);
|
|
196
|
+
try {
|
|
197
|
+
db.exec(MIGRATION_1);
|
|
198
|
+
return { path, schema_version: getSchemaVersion(db) };
|
|
199
|
+
} finally {
|
|
200
|
+
db.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function getSchemaVersion(db: Database): number {
|
|
205
|
+
const row = db.query<{ version: number }, []>('SELECT MAX(version) AS version FROM schema_versions').get();
|
|
206
|
+
return row?.version ?? 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function count(db: Database, table: string): number {
|
|
210
|
+
const row = db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get();
|
|
211
|
+
return row?.n ?? 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function getKnowledgeDbStats(path: string): KnowledgeDbStats {
|
|
215
|
+
const db = openKnowledgeDb(path);
|
|
216
|
+
try {
|
|
217
|
+
return {
|
|
218
|
+
schema_version: getSchemaVersion(db),
|
|
219
|
+
sources: count(db, 'sources'),
|
|
220
|
+
source_revisions: count(db, 'source_revisions'),
|
|
221
|
+
chunks: count(db, 'chunks'),
|
|
222
|
+
wiki_pages: count(db, 'wiki_pages'),
|
|
223
|
+
citations: count(db, 'citations'),
|
|
224
|
+
indexes: count(db, 'knowledge_indexes'),
|
|
225
|
+
runs: count(db, 'runs'),
|
|
226
|
+
run_events: count(db, 'run_events'),
|
|
227
|
+
};
|
|
228
|
+
} finally {
|
|
229
|
+
db.close();
|
|
230
|
+
}
|
|
231
|
+
}
|