@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/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 ~/.open-knowledge/)
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) { console.log(flags.json ? JSON.stringify({ name: pkg.name, version: pkg.version }, null, 2) : pkg.version); return; }
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 = './.open-knowledge/db.json';
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 total = db.items.length;
444
- const withUrl = db.items.filter((x) => x.url).length;
445
- const withTags = db.items.filter((x) => x.tags && x.tags.length > 0).length;
446
- const oldest = total > 0 ? db.items.map((x) => x.created_at).sort()[0] : null;
447
- const newest = total > 0 ? db.items.map((x) => x.created_at).sort()[total - 1] : null;
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 db.items) {
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
- try {
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
+ }