@hasna/knowledge 0.2.3 → 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 +1067 -1569
- 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 +9 -4
- package/src/artifact-store.ts +184 -0
- package/src/cli.ts +642 -0
- package/src/knowledge-db.ts +231 -0
- package/src/mcp.js +533 -0
- package/src/schema.js +25 -0
- 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/src/cli.ts
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* @hasna/knowledge
|
|
4
|
+
* Copyright 2026 Hasna Inc.
|
|
5
|
+
* Licensed under the Apache License, Version 2.0
|
|
6
|
+
*/
|
|
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';
|
|
12
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
13
|
+
|
|
14
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
15
|
+
const LOG_LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
16
|
+
const currentLevel = (): LogLevel => {
|
|
17
|
+
if (process.env.DEBUG) return 'debug';
|
|
18
|
+
if (process.env.LOG_LEVEL === 'debug') return 'debug';
|
|
19
|
+
if (process.env.LOG_LEVEL === 'warn') return 'warn';
|
|
20
|
+
if (process.env.LOG_LEVEL === 'error') return 'error';
|
|
21
|
+
return 'info';
|
|
22
|
+
};
|
|
23
|
+
function log(level: LogLevel, msg: string, data?: Record<string, unknown>): void {
|
|
24
|
+
if (LOG_LEVELS[level] < LOG_LEVELS[currentLevel()]) return;
|
|
25
|
+
const prefix = { debug: '[DEBUG]', info: '[INFO]', warn: '[WARN]', error: '[ERROR]' }[level];
|
|
26
|
+
const entry = data ? `${prefix} ${msg} ${JSON.stringify(data)}` : `${prefix} ${msg}`;
|
|
27
|
+
if (level === 'error') console.error(entry);
|
|
28
|
+
else console.error(entry);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Flags {
|
|
32
|
+
json?: boolean;
|
|
33
|
+
yes?: boolean;
|
|
34
|
+
help?: boolean;
|
|
35
|
+
version?: boolean;
|
|
36
|
+
desc?: boolean;
|
|
37
|
+
page?: number;
|
|
38
|
+
limit?: number;
|
|
39
|
+
search?: string;
|
|
40
|
+
sort?: string;
|
|
41
|
+
id?: string;
|
|
42
|
+
store?: string;
|
|
43
|
+
title?: string;
|
|
44
|
+
content?: string;
|
|
45
|
+
url?: string;
|
|
46
|
+
tag?: string;
|
|
47
|
+
format?: string;
|
|
48
|
+
completions?: string;
|
|
49
|
+
noColor?: boolean;
|
|
50
|
+
scope?: string;
|
|
51
|
+
olderThan?: number;
|
|
52
|
+
empty?: boolean;
|
|
53
|
+
archived?: boolean;
|
|
54
|
+
includeArchived?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ParseResult {
|
|
58
|
+
positional: string[];
|
|
59
|
+
flags: Flags;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'help'];
|
|
63
|
+
const COMMAND_ALIASES: Record<string, string> = {
|
|
64
|
+
ls: 'list',
|
|
65
|
+
rm: 'delete',
|
|
66
|
+
edit: 'update',
|
|
67
|
+
unarchive: 'restore',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function parseArgs(argv: string[]): ParseResult {
|
|
71
|
+
const positional: string[] = [];
|
|
72
|
+
const flags: Flags = {};
|
|
73
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
74
|
+
const token = argv[i];
|
|
75
|
+
if (!token.startsWith('-')) {
|
|
76
|
+
positional.push(token);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
switch (token) {
|
|
80
|
+
case '--json': flags.json = true; break;
|
|
81
|
+
case '--yes': case '-y': flags.yes = true; break;
|
|
82
|
+
case '--help': case '-h': flags.help = true; break;
|
|
83
|
+
case '--version': case '-v': flags.version = true; break;
|
|
84
|
+
case '--desc': flags.desc = true; break;
|
|
85
|
+
case '--page': case '-p': flags.page = Number(argv[i + 1]); i += 1; break;
|
|
86
|
+
case '--limit': case '-l': flags.limit = Number(argv[i + 1]); i += 1; break;
|
|
87
|
+
case '--search': case '-s': flags.search = argv[i + 1]; i += 1; break;
|
|
88
|
+
case '--sort': flags.sort = argv[i + 1]; i += 1; break;
|
|
89
|
+
case '--id': flags.id = argv[i + 1]; i += 1; break;
|
|
90
|
+
case '--store': flags.store = argv[i + 1]; i += 1; break;
|
|
91
|
+
case '--title': flags.title = argv[i + 1]; i += 1; break;
|
|
92
|
+
case '--content': flags.content = argv[i + 1]; i += 1; break;
|
|
93
|
+
case '--url': flags.url = argv[i + 1]; i += 1; break;
|
|
94
|
+
case '--tag': case '-t': flags.tag = argv[i + 1]; i += 1; break;
|
|
95
|
+
case '--format': flags.format = argv[i + 1]; i += 1; break;
|
|
96
|
+
case '--completions': flags.completions = argv[i + 1]; i += 1; break;
|
|
97
|
+
case '--no-color': flags.noColor = true; break;
|
|
98
|
+
case '--scope': flags.scope = argv[i + 1]; i += 1; break;
|
|
99
|
+
case '--older-than': flags.olderThan = Number(argv[i + 1]); i += 1; break;
|
|
100
|
+
case '--empty': flags.empty = true; break;
|
|
101
|
+
case '--archived': flags.archived = true; break;
|
|
102
|
+
case '--include-archived': flags.includeArchived = true; break;
|
|
103
|
+
default: throw new Error(`Unknown flag: ${token}. Run 'open-knowledge --help' for valid options.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { positional, flags };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveCommand(raw: string): string {
|
|
110
|
+
if (!raw) return '';
|
|
111
|
+
return COMMAND_ALIASES[raw] ?? raw;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function levenshtein(a: string, b: string): number {
|
|
115
|
+
const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
|
|
116
|
+
for (let i = 0; i <= a.length; i += 1) dp[i][0] = i;
|
|
117
|
+
for (let j = 0; j <= b.length; j += 1) dp[0][j] = j;
|
|
118
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
119
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
120
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
121
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return dp[a.length][b.length];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function suggestCommand(input: string): string {
|
|
128
|
+
if (!input) return '';
|
|
129
|
+
const all = [...COMMANDS, ...Object.keys(COMMAND_ALIASES)];
|
|
130
|
+
let best = '';
|
|
131
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
132
|
+
for (const candidate of all) {
|
|
133
|
+
const score = levenshtein(input, candidate);
|
|
134
|
+
if (score < bestScore) {
|
|
135
|
+
bestScore = score;
|
|
136
|
+
best = candidate;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return bestScore <= 3 ? best : '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function printGlobalHelp(): void {
|
|
143
|
+
console.log(`open-knowledge - local agent knowledge store
|
|
144
|
+
|
|
145
|
+
Usage:
|
|
146
|
+
open-knowledge <command> [options]
|
|
147
|
+
|
|
148
|
+
Commands:
|
|
149
|
+
add <title> <content> Add an item
|
|
150
|
+
list (alias: ls) List items (supports pagination/search/sort/tag)
|
|
151
|
+
get --id <id> Get one item
|
|
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
|
|
157
|
+
delete (alias: rm) --id <id> Delete item (requires --yes)
|
|
158
|
+
export Export all items (--format jsonl)
|
|
159
|
+
prune Remove old/empty items (requires --yes)
|
|
160
|
+
dedupe Remove duplicate items by title+content (requires --yes)
|
|
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
|
|
165
|
+
help [command] Show help
|
|
166
|
+
|
|
167
|
+
Global Options:
|
|
168
|
+
--json Output JSON
|
|
169
|
+
--store <path> Override store path
|
|
170
|
+
--scope local|global|project Store scope (default: global ~/.hasna/apps/knowledge/)
|
|
171
|
+
--no-color Disable color output
|
|
172
|
+
--completions <shell> Output completions for bash|zsh|fish
|
|
173
|
+
-v, --version Show version
|
|
174
|
+
-h, --help Show help
|
|
175
|
+
|
|
176
|
+
List Options:
|
|
177
|
+
--format table|json Output format (default: table if TTY, json otherwise)
|
|
178
|
+
-p, --page <n> Page number (default: 1)
|
|
179
|
+
-l, --limit <n> Items per page (default: 20)
|
|
180
|
+
-s, --search <text> Filter by title/content
|
|
181
|
+
-t, --tag <tag> Filter by tag
|
|
182
|
+
--sort <created|title> Sort field (default: created)
|
|
183
|
+
--desc Sort descending
|
|
184
|
+
--archived Show only archived items
|
|
185
|
+
--include-archived Include archived items
|
|
186
|
+
|
|
187
|
+
Add/Update Options:
|
|
188
|
+
--url <url> Attach source URL
|
|
189
|
+
|
|
190
|
+
Update Options:
|
|
191
|
+
--id <id> Item id
|
|
192
|
+
--title <title> New title
|
|
193
|
+
--content <content> New content
|
|
194
|
+
--url <url> New source URL
|
|
195
|
+
-t, --tag <tag> Add a tag
|
|
196
|
+
|
|
197
|
+
Delete Options:
|
|
198
|
+
--id <id> Item id
|
|
199
|
+
-y, --yes Confirm destructive action
|
|
200
|
+
|
|
201
|
+
Export Options:
|
|
202
|
+
--format jsonl Export as newline-delimited JSON (default: JSON array)
|
|
203
|
+
|
|
204
|
+
Prune Options:
|
|
205
|
+
--older-than <days> Remove items older than N days
|
|
206
|
+
--empty Remove items with empty content`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function printCommandHelp(command: string): void {
|
|
210
|
+
if (command === 'add') { console.log('Usage: open-knowledge add <title> <content> [--url <url>] [-t <tag>] [--json]'); return; }
|
|
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; }
|
|
212
|
+
if (command === 'get') { console.log('Usage: open-knowledge get --id <id> [--json]'); return; }
|
|
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; }
|
|
218
|
+
if (command === 'delete' || command === 'rm') { console.log('Usage: open-knowledge delete|rm --id <id> -y [--json]'); return; }
|
|
219
|
+
if (command === 'export') { console.log('Usage: open-knowledge export [--format jsonl] [--json]'); return; }
|
|
220
|
+
if (command === 'prune') { console.log('Usage: open-knowledge prune --yes [--older-than <days>] [--empty] [--json]'); return; }
|
|
221
|
+
if (command === 'dedupe') { console.log('Usage: open-knowledge dedupe --yes [--json]'); return; }
|
|
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; }
|
|
226
|
+
printGlobalHelp();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function useColor(flags: Flags): boolean {
|
|
230
|
+
if (flags.noColor || process.env.NO_COLOR) return false;
|
|
231
|
+
if (process.env.FORCE_COLOR) return true;
|
|
232
|
+
return process.stdout.isTTY === true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function output(data: unknown, asJson?: boolean, _flags?: Flags): void {
|
|
236
|
+
if (asJson) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
237
|
+
if (typeof data === 'string') { console.log(data); return; }
|
|
238
|
+
console.log((data as { message?: string }).message ?? JSON.stringify(data, null, 2));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function requireId(flags: Flags): asserts flags is Flags & { id: string } {
|
|
242
|
+
if (!flags.id) throw new Error('Missing required --id. Example: open-knowledge get --id <id>');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function sortItems(items: KnowledgeItem[], flags: Flags): { sorted: KnowledgeItem[]; sort: string; direction: string } {
|
|
246
|
+
const sort = flags.sort ?? 'created';
|
|
247
|
+
if (sort !== 'created' && sort !== 'title') {
|
|
248
|
+
throw new Error("Invalid --sort value. Use 'created' or 'title'.");
|
|
249
|
+
}
|
|
250
|
+
const sorted = [...items].sort((a, b) => {
|
|
251
|
+
if (sort === 'title') return a.title.localeCompare(b.title);
|
|
252
|
+
return a.created_at.localeCompare(b.created_at);
|
|
253
|
+
});
|
|
254
|
+
if (flags.desc) sorted.reverse();
|
|
255
|
+
return { sorted, sort, direction: flags.desc ? 'desc' : 'asc' };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function run(argv: string[]): Promise<void> {
|
|
259
|
+
const { positional, flags } = parseArgs(argv);
|
|
260
|
+
log('debug', 'CLI invoked', { command: positional[0], flags: { json: flags.json, store: flags.store } });
|
|
261
|
+
|
|
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
|
+
}
|
|
266
|
+
|
|
267
|
+
if (flags.completions) {
|
|
268
|
+
const shell = flags.completions;
|
|
269
|
+
if (shell === 'bash') {
|
|
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`);
|
|
271
|
+
} else if (shell === 'zsh') {
|
|
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`);
|
|
273
|
+
} else if (shell === 'fish') {
|
|
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"`);
|
|
275
|
+
} else {
|
|
276
|
+
throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const command = resolveCommand(positional[0]);
|
|
282
|
+
|
|
283
|
+
if (!command || flags.help || command === 'help') { printCommandHelp(positional[1]); return; }
|
|
284
|
+
|
|
285
|
+
const workspace = resolveScopedWorkspace(flags.scope);
|
|
286
|
+
let storePath = flags.store;
|
|
287
|
+
if (!storePath) {
|
|
288
|
+
if (flags.scope === 'project' || flags.scope === 'local') {
|
|
289
|
+
storePath = ensureKnowledgeWorkspace(workspace.home).jsonStorePath;
|
|
290
|
+
} else {
|
|
291
|
+
storePath = defaultStorePath();
|
|
292
|
+
}
|
|
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
|
+
}
|
|
343
|
+
ensureStore(storePath);
|
|
344
|
+
|
|
345
|
+
if (command === 'add') {
|
|
346
|
+
const title = positional[1];
|
|
347
|
+
const content = positional[2];
|
|
348
|
+
if (!title || !content) throw new Error('Usage: open-knowledge add <title> <content>');
|
|
349
|
+
withLock(storePath, () => {
|
|
350
|
+
const db = loadStore(storePath);
|
|
351
|
+
const item: KnowledgeItem = {
|
|
352
|
+
id: makeId(),
|
|
353
|
+
title,
|
|
354
|
+
content,
|
|
355
|
+
url: flags.url ?? null,
|
|
356
|
+
tags: flags.tag ? [flags.tag] : [],
|
|
357
|
+
created_at: new Date().toISOString(),
|
|
358
|
+
updated_at: new Date().toISOString(),
|
|
359
|
+
};
|
|
360
|
+
db.items.push(item);
|
|
361
|
+
saveStore(storePath, db);
|
|
362
|
+
log('info', 'Item added', { id: item.id, title: item.title });
|
|
363
|
+
output({ ok: true, item, message: `Added ${item.id}` }, flags.json);
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (command === 'list') {
|
|
369
|
+
if (flags.format !== undefined && flags.format !== 'table' && flags.format !== 'json') {
|
|
370
|
+
throw new Error("Invalid --format value for list. Use 'table' or 'json'.");
|
|
371
|
+
}
|
|
372
|
+
withLock(storePath, () => {
|
|
373
|
+
const db = loadStore(storePath);
|
|
374
|
+
const page = Number.isFinite(flags.page) && (flags.page as number) > 0 ? flags.page as number : 1;
|
|
375
|
+
const limit = Number.isFinite(flags.limit) && (flags.limit as number) > 0 ? flags.limit as number : 20;
|
|
376
|
+
const search = flags.search ? String(flags.search).toLowerCase() : '';
|
|
377
|
+
const tag = flags.tag ? String(flags.tag).toLowerCase() : '';
|
|
378
|
+
const useTable = flags.format === 'table' || (!flags.json && !flags.format && useColor(flags));
|
|
379
|
+
const useJson = flags.json || flags.format === 'json';
|
|
380
|
+
|
|
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);
|
|
384
|
+
if (search) filtered = filtered.filter((x) => x.title.toLowerCase().includes(search) || x.content.toLowerCase().includes(search));
|
|
385
|
+
if (tag) filtered = filtered.filter((x) => x.tags && x.tags.map((t) => t.toLowerCase()).includes(tag));
|
|
386
|
+
|
|
387
|
+
const { sorted, sort, direction } = sortItems(filtered, flags);
|
|
388
|
+
const start = (page - 1) * limit;
|
|
389
|
+
const rows = sorted.slice(start, start + limit);
|
|
390
|
+
const totalPages = Math.max(1, Math.ceil(sorted.length / limit));
|
|
391
|
+
|
|
392
|
+
if (useJson) { output({ ok: true, page, limit, total: sorted.length, total_pages: totalPages, sort, direction, items: rows }, true); return; }
|
|
393
|
+
if (rows.length === 0) { output(`No items found (search=${search || 'none'}, tag=${tag || 'none'})`, false); return; }
|
|
394
|
+
if (useTable) {
|
|
395
|
+
const col = (v: string) => v;
|
|
396
|
+
const header = `${col('ID')}\t${col('TITLE')}\t${col('CREATED')}\t${col('URL')}\t${col('TAGS')}`;
|
|
397
|
+
console.log(header);
|
|
398
|
+
for (const row of rows) {
|
|
399
|
+
console.log(`${row.id}\t${col(row.title)}\t${row.created_at}\t${row.url ? col(row.url) : ''}\t${row.tags?.length ? col(`[${row.tags.join(', ')}]`) : ''}`);
|
|
400
|
+
}
|
|
401
|
+
console.log(`Page ${page}/${totalPages} | showing ${rows.length} of ${sorted.length} | sort=${sort} ${direction} | search=${search || 'none'} | tag=${tag || 'none'}`);
|
|
402
|
+
} else {
|
|
403
|
+
for (const row of rows) {
|
|
404
|
+
console.log(`${row.id}\t${row.title}\t${row.created_at}${row.url ? `\t${row.url}` : ''}${row.tags?.length ? `\t[${row.tags.join(', ')}]` : ''}`);
|
|
405
|
+
}
|
|
406
|
+
console.log(`Page ${page}/${totalPages} | showing ${rows.length} of ${sorted.length} | sort=${sort} ${direction} | search=${search || 'none'} | tag=${tag || 'none'}`);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (command === 'get') {
|
|
413
|
+
requireId(flags);
|
|
414
|
+
withLock(storePath, () => {
|
|
415
|
+
const db = loadStore(storePath);
|
|
416
|
+
const item = db.items.find((x) => x.id === flags.id || x.short_id === flags.id);
|
|
417
|
+
if (!item) throw new Error(`Item not found: ${flags.id}`);
|
|
418
|
+
output({ ok: true, item, message: `${item.id}: ${item.title}` }, flags.json);
|
|
419
|
+
});
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (command === 'update') {
|
|
424
|
+
requireId(flags);
|
|
425
|
+
withLock(storePath, () => {
|
|
426
|
+
const db = loadStore(storePath);
|
|
427
|
+
const idx = db.items.findIndex((x) => x.id === flags.id || x.short_id === flags.id);
|
|
428
|
+
if (idx === -1) throw new Error(`Item not found: ${flags.id}`);
|
|
429
|
+
const item = db.items[idx];
|
|
430
|
+
if (flags.title !== undefined) item.title = flags.title;
|
|
431
|
+
if (flags.content !== undefined) item.content = flags.content;
|
|
432
|
+
if (flags.url !== undefined) item.url = flags.url;
|
|
433
|
+
if (flags.tag !== undefined) {
|
|
434
|
+
item.tags = item.tags || [];
|
|
435
|
+
if (!item.tags.map((t) => t.toLowerCase()).includes(flags.tag!.toLowerCase())) {
|
|
436
|
+
item.tags.push(flags.tag!);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
item.updated_at = new Date().toISOString();
|
|
440
|
+
db.items[idx] = item;
|
|
441
|
+
saveStore(storePath, db);
|
|
442
|
+
output({ ok: true, item, message: `Updated ${item.id}` }, flags.json);
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
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
|
+
|
|
524
|
+
if (command === 'delete') {
|
|
525
|
+
requireId(flags);
|
|
526
|
+
if (!flags.yes) throw new Error('Refusing delete without --yes. Re-run with: open-knowledge delete --id <id> --yes');
|
|
527
|
+
withLock(storePath, () => {
|
|
528
|
+
const db = loadStore(storePath);
|
|
529
|
+
const before = db.items.length;
|
|
530
|
+
db.items = db.items.filter((x) => x.id !== flags.id && x.short_id !== flags.id);
|
|
531
|
+
const deleted = before !== db.items.length;
|
|
532
|
+
saveStore(storePath, db);
|
|
533
|
+
if (!deleted) throw new Error(`Item not found: ${flags.id}`);
|
|
534
|
+
log('info', 'Item deleted', { id: flags.id });
|
|
535
|
+
output({ ok: true, deleted_id: flags.id, message: `Deleted ${flags.id}` }, flags.json);
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (command === 'export') {
|
|
541
|
+
const format = flags.format ?? 'json';
|
|
542
|
+
if (format !== 'json' && format !== 'jsonl') throw new Error("Invalid --format. Use 'json' or 'jsonl'.");
|
|
543
|
+
withLock(storePath, () => {
|
|
544
|
+
const db = loadStore(storePath);
|
|
545
|
+
if (format === 'jsonl') {
|
|
546
|
+
for (const item of db.items) console.log(JSON.stringify(item));
|
|
547
|
+
} else {
|
|
548
|
+
output({ ok: true, items: db.items }, flags.json);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (command === 'prune') {
|
|
555
|
+
if (!flags.yes) throw new Error('Refusing prune without --yes. Re-run with: open-knowledge prune --yes [--older-than <days>] [--empty]');
|
|
556
|
+
withLock(storePath, () => {
|
|
557
|
+
const db = loadStore(storePath);
|
|
558
|
+
const before = db.items.length;
|
|
559
|
+
if (flags.olderThan !== undefined) {
|
|
560
|
+
const cutoff = new Date();
|
|
561
|
+
cutoff.setDate(cutoff.getDate() - flags.olderThan);
|
|
562
|
+
db.items = db.items.filter((x) => new Date(x.created_at) >= cutoff);
|
|
563
|
+
}
|
|
564
|
+
if (flags.empty) {
|
|
565
|
+
db.items = db.items.filter((x) => x.content.trim().length > 0);
|
|
566
|
+
}
|
|
567
|
+
const pruned = before - db.items.length;
|
|
568
|
+
saveStore(storePath, db);
|
|
569
|
+
log('info', 'Prune completed', { pruned, remaining: db.items.length });
|
|
570
|
+
output({ ok: true, pruned, remaining: db.items.length, message: `Pruned ${pruned} item(s)` }, flags.json);
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (command === 'dedupe') {
|
|
576
|
+
if (!flags.yes) throw new Error('Refusing dedupe without --yes. Re-run with: open-knowledge dedupe --yes [--json]');
|
|
577
|
+
withLock(storePath, () => {
|
|
578
|
+
const db = loadStore(storePath);
|
|
579
|
+
const seen = new Set<string>();
|
|
580
|
+
const before = db.items.length;
|
|
581
|
+
db.items = db.items.filter((x) => {
|
|
582
|
+
const key = `${x.title}\u0000${x.content}`;
|
|
583
|
+
if (seen.has(key)) return false;
|
|
584
|
+
seen.add(key);
|
|
585
|
+
return true;
|
|
586
|
+
});
|
|
587
|
+
const removed = before - db.items.length;
|
|
588
|
+
saveStore(storePath, db);
|
|
589
|
+
log('info', 'Dedupe completed', { removed, remaining: db.items.length });
|
|
590
|
+
output({ ok: true, removed, remaining: db.items.length, message: `Dedupe removed ${removed} duplicate(s)` }, flags.json);
|
|
591
|
+
});
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (command === 'stats') {
|
|
596
|
+
withLock(storePath, () => {
|
|
597
|
+
const db = loadStore(storePath);
|
|
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;
|
|
605
|
+
const tagCounts: Record<string, number> = {};
|
|
606
|
+
for (const item of activeItems) {
|
|
607
|
+
for (const tag of item.tags || []) {
|
|
608
|
+
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const topTags = Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([tag, count]) => ({ tag, count }));
|
|
612
|
+
output({
|
|
613
|
+
ok: true,
|
|
614
|
+
total,
|
|
615
|
+
archived,
|
|
616
|
+
with_url: withUrl,
|
|
617
|
+
with_tags: withTags,
|
|
618
|
+
oldest,
|
|
619
|
+
newest,
|
|
620
|
+
top_tags: topTags,
|
|
621
|
+
message: `${total} items | ${withUrl} with URL | ${withTags} with tags`,
|
|
622
|
+
}, flags.json);
|
|
623
|
+
});
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const suggestion = suggestCommand(positional[0]);
|
|
628
|
+
const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
|
|
629
|
+
log('warn', 'Unknown command', { input: positional[0], suggestion });
|
|
630
|
+
throw new Error(`Unknown command: ${positional[0]}.${hint} Run 'open-knowledge --help' for available commands.`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (import.meta.main) {
|
|
634
|
+
run(process.argv.slice(2)).catch((error) => {
|
|
635
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
636
|
+
log('error', 'CLI error', { message, stack: error instanceof Error ? error.stack : undefined });
|
|
637
|
+
console.error(`Error: ${message}`);
|
|
638
|
+
process.exitCode = 1;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export { run, parseArgs, suggestCommand, sortItems };
|