@hasna/knowledge 0.2.20 → 0.2.22

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.
@@ -43,6 +43,14 @@ The future hosted/SaaS wrapper owns:
43
43
  The OSS package must stay useful without a hosted account. Hosted mode should be
44
44
  an optional remote client over explicit API contracts.
45
45
 
46
+ The local hosted-aware contract follows the `open-skills` pattern: `mode` is
47
+ `local` by default, `setup --mode hosted` records `hosted.api_url`, env vars
48
+ `KNOWLEDGE_API_URL` and `KNOWLEDGE_API_KEY` can override local config, and
49
+ credentials live outside project state in `~/.hasna/knowledge/auth.json`.
50
+ `remote contracts` publishes the registry/search/ask/build/sync/status/logs and
51
+ artifact endpoints that a SaaS wrapper can implement. Local use, local search,
52
+ and local artifact generation do not require this remote API.
53
+
46
54
  ## Local Workspace
47
55
 
48
56
  Project-local state lives at:
@@ -199,6 +207,14 @@ The database catalog tracks every schema, index shard, log partition, wiki page,
199
207
  source citation, and generated artifact. Markdown remains the readable layer;
200
208
  SQLite/Postgres and object storage carry the scalable catalog.
201
209
 
210
+ The first compile/write loop is local and approval-gated. `wiki compile`
211
+ generates cited pages from derived source chunks, creates concept backlinks,
212
+ updates index rows, records storage objects, and appends dated JSONL logs.
213
+ `wiki file-answer` writes answer notes only with `--approve-write`; otherwise it
214
+ returns the dry-run proposal. `wiki lint` checks missing/stale citations,
215
+ duplicates, orphan pages, unresolved source refs, contradiction markers, and
216
+ new-article candidates.
217
+
202
218
  ## Search Model
203
219
 
204
220
  Search is hybrid:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/knowledge",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "Agent-friendly local knowledge CLI with JSON output, pagination, and safe destructive actions",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auth.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import type { KnowledgeConfig } from './workspace';
5
+
6
+ export interface KnowledgeAuthConfig {
7
+ api_key: string;
8
+ email?: string;
9
+ org_id?: string;
10
+ org_slug?: string;
11
+ user_id?: string;
12
+ api_url?: string;
13
+ created_at: string;
14
+ }
15
+
16
+ export interface KnowledgeAuthStatus {
17
+ authenticated: boolean;
18
+ source: 'env' | 'file' | 'none';
19
+ api_url: string;
20
+ auth_path: string;
21
+ email: string | null;
22
+ org_id: string | null;
23
+ org_slug: string | null;
24
+ user_id: string | null;
25
+ api_key_present: boolean;
26
+ }
27
+
28
+ export const DEFAULT_KNOWLEDGE_API_URL = 'https://knowledge.hasna.xyz';
29
+
30
+ export function normalizeKnowledgeApiOrigin(apiUrl: string): string {
31
+ const url = new URL(apiUrl);
32
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
33
+ throw new Error('Knowledge API URL must use http or https.');
34
+ }
35
+ const pathname = url.pathname.replace(/\/+$/, '');
36
+ if (pathname === '/api' || pathname === '/api/v1') {
37
+ url.pathname = '/';
38
+ } else if (pathname.endsWith('/api/v1')) {
39
+ url.pathname = pathname.slice(0, -'/api/v1'.length) || '/';
40
+ } else if (pathname.endsWith('/api')) {
41
+ url.pathname = pathname.slice(0, -'/api'.length) || '/';
42
+ }
43
+ return url.toString().replace(/\/+$/, '');
44
+ }
45
+
46
+ export function knowledgeAuthPath(env: Record<string, string | undefined> = process.env): string {
47
+ if (env.HASNA_KNOWLEDGE_AUTH_PATH) return env.HASNA_KNOWLEDGE_AUTH_PATH;
48
+ const root = env.HASNA_KNOWLEDGE_AUTH_DIR ?? join(homedir(), '.hasna', 'knowledge');
49
+ return join(root, 'auth.json');
50
+ }
51
+
52
+ export function resolveKnowledgeApiUrl(
53
+ config?: KnowledgeConfig,
54
+ env: Record<string, string | undefined> = process.env,
55
+ ): string {
56
+ return normalizeKnowledgeApiOrigin(env.KNOWLEDGE_API_URL ?? config?.hosted?.api_url ?? DEFAULT_KNOWLEDGE_API_URL);
57
+ }
58
+
59
+ export function getKnowledgeAuth(env: Record<string, string | undefined> = process.env): KnowledgeAuthConfig | null {
60
+ try {
61
+ const path = knowledgeAuthPath(env);
62
+ if (!existsSync(path)) return null;
63
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as KnowledgeAuthConfig;
64
+ return typeof parsed.api_key === 'string' && parsed.api_key.length > 0 ? parsed : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export function saveKnowledgeAuth(
71
+ auth: Omit<KnowledgeAuthConfig, 'created_at'> & { created_at?: string },
72
+ env: Record<string, string | undefined> = process.env,
73
+ ): KnowledgeAuthConfig {
74
+ const path = knowledgeAuthPath(env);
75
+ const stored: KnowledgeAuthConfig = {
76
+ ...auth,
77
+ api_url: auth.api_url ? normalizeKnowledgeApiOrigin(auth.api_url) : undefined,
78
+ created_at: auth.created_at ?? new Date().toISOString(),
79
+ };
80
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
81
+ writeFileSync(path, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
82
+ return stored;
83
+ }
84
+
85
+ export function clearKnowledgeAuth(env: Record<string, string | undefined> = process.env): boolean {
86
+ try {
87
+ unlinkSync(knowledgeAuthPath(env));
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ export function getKnowledgeApiKey(env: Record<string, string | undefined> = process.env): { apiKey: string | null; source: KnowledgeAuthStatus['source'] } {
95
+ if (env.KNOWLEDGE_API_KEY) return { apiKey: env.KNOWLEDGE_API_KEY, source: 'env' };
96
+ if (env.HASNA_KNOWLEDGE_API_KEY) return { apiKey: env.HASNA_KNOWLEDGE_API_KEY, source: 'env' };
97
+ const auth = getKnowledgeAuth(env);
98
+ return auth?.api_key ? { apiKey: auth.api_key, source: 'file' } : { apiKey: null, source: 'none' };
99
+ }
100
+
101
+ export function knowledgeAuthStatus(
102
+ config?: KnowledgeConfig,
103
+ env: Record<string, string | undefined> = process.env,
104
+ ): KnowledgeAuthStatus {
105
+ const auth = getKnowledgeAuth(env);
106
+ const key = getKnowledgeApiKey(env);
107
+ const apiUrl = env.KNOWLEDGE_API_URL
108
+ ? resolveKnowledgeApiUrl(config, env)
109
+ : auth?.api_url
110
+ ? normalizeKnowledgeApiOrigin(auth.api_url)
111
+ : resolveKnowledgeApiUrl(config, env);
112
+ return {
113
+ authenticated: Boolean(key.apiKey),
114
+ source: key.source,
115
+ api_url: apiUrl,
116
+ auth_path: knowledgeAuthPath(env),
117
+ email: key.source === 'file' ? auth?.email ?? null : null,
118
+ org_id: key.source === 'file' ? auth?.org_id ?? null : null,
119
+ org_slug: key.source === 'file' ? auth?.org_slug ?? null : null,
120
+ user_id: key.source === 'file' ? auth?.user_id ?? null : null,
121
+ api_key_present: Boolean(key.apiKey),
122
+ };
123
+ }
package/src/cli.ts CHANGED
@@ -55,6 +55,13 @@ interface Flags {
55
55
  generate?: boolean;
56
56
  approveWrite?: boolean;
57
57
  provider?: string;
58
+ mode?: string;
59
+ apiUrl?: string;
60
+ apiKey?: string;
61
+ email?: string;
62
+ org?: string;
63
+ orgId?: string;
64
+ userId?: string;
58
65
  domain?: string[];
59
66
  fileResults?: boolean;
60
67
  full?: boolean;
@@ -72,7 +79,7 @@ interface ParseResult {
72
79
  flags: Flags;
73
80
  }
74
81
 
75
- const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'storage', 'db', 'wiki', 'source', 'ingest', 'reindex', 'search', 'web', 'ask', 'build', 'embeddings', 'providers', 'safety', 'help'];
82
+ const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'setup', 'auth', 'remote', 'storage', 'db', 'wiki', 'source', 'ingest', 'reindex', 'search', 'web', 'ask', 'build', 'embeddings', 'providers', 'safety', 'help'];
76
83
  const COMMAND_ALIASES: Record<string, string> = {
77
84
  ls: 'list',
78
85
  rm: 'delete',
@@ -116,6 +123,13 @@ function parseArgs(argv: string[]): ParseResult {
116
123
  case '--generate': flags.generate = true; break;
117
124
  case '--approve-write': flags.approveWrite = true; break;
118
125
  case '--provider': flags.provider = argv[i + 1]; i += 1; break;
126
+ case '--mode': flags.mode = argv[i + 1]; i += 1; break;
127
+ case '--api-url': flags.apiUrl = argv[i + 1]; i += 1; break;
128
+ case '--api-key': flags.apiKey = argv[i + 1]; i += 1; break;
129
+ case '--email': flags.email = argv[i + 1]; i += 1; break;
130
+ case '--org': flags.org = argv[i + 1]; i += 1; break;
131
+ case '--org-id': flags.orgId = argv[i + 1]; i += 1; break;
132
+ case '--user-id': flags.userId = argv[i + 1]; i += 1; break;
119
133
  case '--domain': flags.domain = [...(flags.domain ?? []), argv[i + 1]]; i += 1; break;
120
134
  case '--file-results': flags.fileResults = true; break;
121
135
  case '--full': flags.full = true; break;
@@ -190,9 +204,13 @@ Commands:
190
204
  dedupe Remove duplicate items by title+content (requires --yes)
191
205
  stats Show knowledge base statistics
192
206
  paths Show resolved workspace/store paths
207
+ setup Configure local or hosted mode
208
+ auth login|whoami|logout Manage hosted API credentials
209
+ remote contracts|status Inspect hosted client contracts/readiness
193
210
  storage status|validate Inspect local/S3 artifact storage contract
194
211
  db init|stats Initialize or inspect local knowledge.db
195
- wiki init Initialize scalable wiki/schema/index/log artifacts
212
+ wiki init|compile|file-answer|lint
213
+ Initialize, compile, file, or lint wiki artifacts
196
214
  source resolve <source-ref> Resolve read-only source content and citation evidence
197
215
  ingest manifest <file|s3://> Ingest an open-files manifest into knowledge.db
198
216
  ingest source <source-ref> Ingest a read-only source ref into knowledge.db
@@ -216,6 +234,13 @@ Global Options:
216
234
  --generate Call AI SDK text generation for ask/build
217
235
  --approve-write Record approval intent for future durable wiki writes
218
236
  --provider <name> Provider override for web search
237
+ --mode local|hosted Configure OSS local or hosted-aware mode
238
+ --api-url <url> Hosted API origin (or KNOWLEDGE_API_URL)
239
+ --api-key <key> Hosted API key for auth login
240
+ --email <email> Hosted account email metadata
241
+ --org <slug> Hosted organization slug metadata
242
+ --org-id <id> Hosted organization id metadata
243
+ --user-id <id> Hosted user id metadata
219
244
  --domain <domain> Restrict provider web search to a domain
220
245
  --file-results File web snippets as web source refs
221
246
  --full Force full embedding index rebuild
@@ -274,9 +299,12 @@ function printCommandHelp(command: string): void {
274
299
  if (command === 'dedupe') { console.log('Usage: open-knowledge dedupe --yes [--json]'); return; }
275
300
  if (command === 'stats') { console.log('Usage: open-knowledge stats [--json]'); return; }
276
301
  if (command === 'paths') { console.log('Usage: open-knowledge paths [--scope local|global|project] [--json]'); return; }
302
+ if (command === 'setup') { console.log('Usage: open-knowledge setup --mode local|hosted [--api-url https://...] [--scope local|global|project] [--json]'); return; }
303
+ if (command === 'auth') { console.log('Usage: open-knowledge auth login|whoami|logout [--api-key <key>] [--email <email>] [--org <slug>] [--api-url https://...] [--scope local|global|project] [--json]'); return; }
304
+ if (command === 'remote') { console.log('Usage: open-knowledge remote contracts|status [--scope local|global|project] [--json]'); return; }
277
305
  if (command === 'storage') { console.log('Usage: open-knowledge storage status|validate [--scope local|global|project] [--json]'); return; }
278
306
  if (command === 'db') { console.log('Usage: open-knowledge db init|stats [--scope local|global|project] [--json]'); return; }
279
- if (command === 'wiki') { console.log('Usage: open-knowledge wiki init [--scope local|global|project] [--json]'); return; }
307
+ if (command === 'wiki') { console.log('Usage: open-knowledge wiki init|compile|file-answer|lint [query|prompt] [--title <title>] [--content <answer>] [--approve-write] [--limit <n>] [--scope local|global|project] [--json]'); return; }
280
308
  if (command === 'source') { console.log('Usage: open-knowledge source resolve <source-ref> [--purpose knowledge_answer|knowledge_index] [--limit <n>] [--scope local|global|project] [--json]'); return; }
281
309
  if (command === 'ingest') { console.log('Usage: open-knowledge ingest manifest <file|s3://bucket/key> | source <source-ref> [--purpose knowledge_index] [--scope local|global|project] [--json]'); return; }
282
310
  if (command === 'reindex') { console.log('Usage: open-knowledge reindex status|enqueue|embeddings|outbox [file|s3://bucket/key] [--full] [--fake] [--scope local|global|project] [--json]'); return; }
@@ -330,11 +358,11 @@ async function run(argv: string[]): Promise<void> {
330
358
  if (flags.completions) {
331
359
  const shell = flags.completions;
332
360
  if (shell === 'bash') {
333
- 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 storage db wiki source ingest reindex search web ask build embeddings providers safety help ls rm edit unarchive knowledge --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --purpose --model --dimensions --semantic --context --generate --approve-write --provider --domain --file-results --full --fake --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
361
+ 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 setup auth remote storage db wiki source ingest reindex search web ask build embeddings providers safety help ls rm edit unarchive knowledge --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --purpose --model --dimensions --semantic --context --generate --approve-write --provider --mode --api-url --api-key --email --org --org-id --user-id --domain --file-results --full --fake --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
334
362
  } else if (shell === 'zsh') {
335
- 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 storage db wiki source ingest reindex search web ask build embeddings providers safety help ls rm edit unarchive knowledge)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(--archived)--archived" "(--include-archived)--include-archived" "(--semantic)--semantic" "(--context)--context" "(--generate)--generate" "(--approve-write)--approve-write" "(--file-results)--file-results" "(--full)--full" "(--fake)--fake" "(-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):" "(--purpose)--purpose[purpose]:" "(--model)--model[model ref]:" "(--dimensions)--dimensions[embedding dimensions]:number:" "(--provider)--provider[provider]:" "(--domain)--domain[domain]:" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
363
+ 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 setup auth remote storage db wiki source ingest reindex search web ask build embeddings providers safety help ls rm edit unarchive knowledge)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(--archived)--archived" "(--include-archived)--include-archived" "(--semantic)--semantic" "(--context)--context" "(--generate)--generate" "(--approve-write)--approve-write" "(--file-results)--file-results" "(--full)--full" "(--fake)--fake" "(-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):" "(--purpose)--purpose[purpose]:" "(--model)--model[model ref]:" "(--dimensions)--dimensions[embedding dimensions]:number:" "(--provider)--provider[provider]:" "(--mode)--mode"\{local,hosted\}:" "(--api-url)--api-url[hosted API URL]:" "(--api-key)--api-key[hosted API key]:" "(--email)--email[email]:" "(--org)--org[org slug]:" "(--org-id)--org-id[org id]:" "(--user-id)--user-id[user id]:" "(--domain)--domain[domain]:" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
336
364
  } else if (shell === 'fish') {
337
- 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 storage db wiki source ingest reindex search web ask build embeddings providers safety help ls rm edit unarchive knowledge"; 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 -l semantic; complete -c open-knowledge -l context; complete -c open-knowledge -l generate; complete -c open-knowledge -l approve-write; complete -c open-knowledge -l provider; complete -c open-knowledge -l domain; complete -c open-knowledge -l file-results; complete -c open-knowledge -l full; complete -c open-knowledge -l fake; 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 purpose; complete -c open-knowledge -l model; complete -c open-knowledge -l dimensions; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
365
+ 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 setup auth remote storage db wiki source ingest reindex search web ask build embeddings providers safety help ls rm edit unarchive knowledge"; 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 -l semantic; complete -c open-knowledge -l context; complete -c open-knowledge -l generate; complete -c open-knowledge -l approve-write; complete -c open-knowledge -l provider; complete -c open-knowledge -l mode; complete -c open-knowledge -l api-url; complete -c open-knowledge -l api-key; complete -c open-knowledge -l email; complete -c open-knowledge -l org; complete -c open-knowledge -l org-id; complete -c open-knowledge -l user-id; complete -c open-knowledge -l domain; complete -c open-knowledge -l file-results; complete -c open-knowledge -l full; complete -c open-knowledge -l fake; 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 purpose; complete -c open-knowledge -l model; complete -c open-knowledge -l dimensions; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
338
366
  } else {
339
367
  throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
340
368
  }
@@ -365,6 +393,84 @@ async function run(argv: string[]): Promise<void> {
365
393
  return;
366
394
  }
367
395
 
396
+ if (command === 'setup') {
397
+ const result = service.setup({
398
+ mode: flags.mode,
399
+ apiUrl: flags.apiUrl,
400
+ });
401
+ output(result, flags.json);
402
+ return;
403
+ }
404
+
405
+ if (command === 'auth') {
406
+ const action = positional[1] ?? 'whoami';
407
+ if (action === 'whoami' || action === 'status') {
408
+ const result = service.authStatus(process.env);
409
+ output({ ok: true, ...result, message: result.authenticated ? `Authenticated via ${result.source}` : 'Not authenticated' }, flags.json);
410
+ return;
411
+ }
412
+ if (action === 'login') {
413
+ const apiKey = flags.apiKey ?? process.env.KNOWLEDGE_API_KEY ?? process.env.HASNA_KNOWLEDGE_API_KEY;
414
+ if (!apiKey) throw new Error('Usage: open-knowledge auth login --api-key <key> [--email <email>]');
415
+ const auth = service.saveAuth({
416
+ apiKey,
417
+ email: flags.email,
418
+ orgSlug: flags.org,
419
+ orgId: flags.orgId,
420
+ userId: flags.userId,
421
+ apiUrl: flags.apiUrl,
422
+ }, process.env);
423
+ output({
424
+ ok: true,
425
+ authenticated: true,
426
+ email: auth.email ?? null,
427
+ org_slug: auth.org_slug ?? null,
428
+ api_url: auth.api_url ?? service.authStatus(process.env).api_url,
429
+ auth_path: service.authStatus(process.env).auth_path,
430
+ message: `Saved hosted credentials for ${auth.email ?? 'API key'}`,
431
+ }, flags.json);
432
+ return;
433
+ }
434
+ if (action === 'logout') {
435
+ const removed = service.clearAuth(process.env);
436
+ output({ ok: true, removed, message: removed ? 'Removed hosted credentials' : 'No hosted credentials found' }, flags.json);
437
+ return;
438
+ }
439
+ throw new Error("Invalid auth action. Use 'login', 'whoami', or 'logout'.");
440
+ }
441
+
442
+ if (command === 'remote') {
443
+ const action = positional[1] ?? 'status';
444
+ if (action === 'contracts' || action === 'contract') {
445
+ const auth = service.authStatus(process.env);
446
+ output({
447
+ ok: true,
448
+ authenticated: auth.authenticated,
449
+ api_url: auth.api_url,
450
+ contract: service.remoteContract(),
451
+ message: `Remote contract v${service.remoteContract().contract_version}`,
452
+ }, flags.json);
453
+ return;
454
+ }
455
+ if (action === 'status') {
456
+ const auth = service.authStatus(process.env);
457
+ const contract = service.remoteContract();
458
+ output({
459
+ ok: true,
460
+ mode: service.config().mode,
461
+ authenticated: auth.authenticated,
462
+ auth_source: auth.source,
463
+ api_url: auth.api_url,
464
+ client_ready: Boolean(service.remoteClient(process.env)),
465
+ contract_version: contract.contract_version,
466
+ capabilities: contract.capabilities,
467
+ message: auth.authenticated ? `Remote client ready for ${auth.api_url}` : 'Remote client not authenticated',
468
+ }, flags.json);
469
+ return;
470
+ }
471
+ throw new Error("Invalid remote action. Use 'contracts' or 'status'.");
472
+ }
473
+
368
474
  if (command === 'storage') {
369
475
  const action = positional[1] ?? 'status';
370
476
  if (action === 'status') {
@@ -407,10 +513,47 @@ async function run(argv: string[]): Promise<void> {
407
513
 
408
514
  if (command === 'wiki') {
409
515
  const action = positional[1] ?? 'init';
410
- if (action !== 'init') throw new Error("Invalid wiki action. Use 'init'.");
411
- const result = await service.initWiki();
412
- output({ ok: true, ...result, message: `Initialized wiki layout in ${service.workspace.home}` }, flags.json);
413
- return;
516
+ if (action === 'init') {
517
+ const result = await service.initWiki();
518
+ output({ ok: true, ...result, message: `Initialized wiki layout in ${service.workspace.home}` }, flags.json);
519
+ return;
520
+ }
521
+ if (action === 'compile') {
522
+ const args = positional.slice(2);
523
+ const sourceRefs = args.filter((arg) => /^(open-files|file|s3|https?):\/\//.test(arg));
524
+ const query = args.filter((arg) => !/^(open-files|file|s3|https?):\/\//.test(arg)).join(' ');
525
+ const result = await service.compileWiki({
526
+ title: flags.title,
527
+ query: query || flags.search,
528
+ sourceRefs: sourceRefs.length > 0 ? sourceRefs : undefined,
529
+ limit: flags.limit,
530
+ });
531
+ output({ ok: true, ...result, message: `Compiled wiki page ${result.path}` }, flags.json);
532
+ return;
533
+ }
534
+ if (action === 'file-answer' || action === 'answer') {
535
+ const prompt = positional.slice(2).join(' ');
536
+ if (!prompt) throw new Error('Usage: open-knowledge wiki file-answer <prompt> --content <answer> --approve-write');
537
+ if (!flags.content) throw new Error('Missing --content <answer> for wiki file-answer.');
538
+ const result = await service.fileAnswer({
539
+ prompt,
540
+ answer: flags.content,
541
+ approveWrite: flags.approveWrite,
542
+ limit: flags.limit,
543
+ semantic: flags.semantic,
544
+ modelRef: flags.model,
545
+ dimensions: flags.dimensions,
546
+ fake: flags.fake,
547
+ });
548
+ output({ ok: true, ...result }, flags.json);
549
+ return;
550
+ }
551
+ if (action === 'lint') {
552
+ const result = service.lintWiki();
553
+ output({ ok: result.ok, ...result, message: result.ok ? 'Wiki lint passed' : `Wiki lint found ${result.issue_count} issue(s)` }, flags.json);
554
+ return;
555
+ }
556
+ throw new Error("Invalid wiki action. Use 'init', 'compile', 'file-answer', or 'lint'.");
414
557
  }
415
558
 
416
559
  if (command === 'safety') {
@@ -0,0 +1,268 @@
1
+ import { getKnowledgeApiKey, resolveKnowledgeApiUrl } from './auth';
2
+ import type { KnowledgeConfig } from './workspace';
3
+
4
+ export const REMOTE_KNOWLEDGE_CONTRACT_VERSION = 1 as const;
5
+
6
+ export type RemoteKnowledgeRunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'canceled';
7
+
8
+ export interface RemoteKnowledgeSourceContract {
9
+ owner: 'open-files';
10
+ preferred_ref: 'open-files';
11
+ allowed_schemes: string[];
12
+ raw_source_bytes_stored_in_open_knowledge: false;
13
+ }
14
+
15
+ export interface RemoteKnowledgeArtifactContract {
16
+ storage_type: 'local' | 's3' | 'managed';
17
+ uri_prefix: string | null;
18
+ generated_only: true;
19
+ }
20
+
21
+ export interface RemoteKnowledgeRegistryContract {
22
+ contract_version: typeof REMOTE_KNOWLEDGE_CONTRACT_VERSION;
23
+ service: 'open-knowledge';
24
+ mode: 'local' | 'hosted';
25
+ capabilities: string[];
26
+ endpoints: {
27
+ registry: string;
28
+ search: string;
29
+ ask: string;
30
+ build: string;
31
+ sync: string;
32
+ run_status: string;
33
+ run_logs: string;
34
+ run_artifacts: string;
35
+ };
36
+ source_contract: RemoteKnowledgeSourceContract;
37
+ artifact_contract: RemoteKnowledgeArtifactContract;
38
+ }
39
+
40
+ export interface RemoteKnowledgeRunContract {
41
+ contract_version: typeof REMOTE_KNOWLEDGE_CONTRACT_VERSION;
42
+ id?: string;
43
+ type?: 'search' | 'ask' | 'build' | 'sync' | 'artifact' | 'status';
44
+ status?: RemoteKnowledgeRunStatus | string;
45
+ query?: string;
46
+ prompt?: string;
47
+ output_preview?: unknown;
48
+ citations?: unknown[];
49
+ artifacts?: unknown[];
50
+ usage?: Record<string, unknown>;
51
+ created_at?: string;
52
+ started_at?: string;
53
+ completed_at?: string;
54
+ duration_ms?: number;
55
+ error_code?: string;
56
+ error_message?: string;
57
+ error?: string;
58
+ details?: unknown;
59
+ }
60
+
61
+ export interface RemoteKnowledgeSearchRequest {
62
+ query: string;
63
+ limit?: number;
64
+ semantic?: boolean;
65
+ source_refs?: string[];
66
+ }
67
+
68
+ export interface RemoteKnowledgePromptRequest extends RemoteKnowledgeSearchRequest {
69
+ prompt: string;
70
+ generate?: boolean;
71
+ approve_write?: boolean;
72
+ }
73
+
74
+ export interface RemoteKnowledgeSyncRequest {
75
+ source_refs?: string[];
76
+ artifact_prefix?: string;
77
+ mode?: 'pull' | 'push' | 'both';
78
+ }
79
+
80
+ export interface RemoteKnowledgeLogEntry {
81
+ id?: string;
82
+ run_id?: string;
83
+ level?: string;
84
+ event?: string;
85
+ metadata?: Record<string, unknown>;
86
+ created_at?: string;
87
+ }
88
+
89
+ export interface RemoteKnowledgeArtifact {
90
+ id?: string;
91
+ uri?: string;
92
+ key?: string;
93
+ kind?: string;
94
+ content_type?: string;
95
+ hash?: string;
96
+ size_bytes?: number;
97
+ metadata?: Record<string, unknown>;
98
+ }
99
+
100
+ function isRecord(value: unknown): value is Record<string, unknown> {
101
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
102
+ }
103
+
104
+ function stringValue(record: Record<string, unknown>, key: string): string | undefined {
105
+ const value = record[key];
106
+ return typeof value === 'string' ? value : undefined;
107
+ }
108
+
109
+ function numberValue(record: Record<string, unknown>, key: string): number | undefined {
110
+ const value = record[key];
111
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
112
+ }
113
+
114
+ function arrayValue(record: Record<string, unknown>, key: string): unknown[] | undefined {
115
+ const value = record[key];
116
+ return Array.isArray(value) ? value : undefined;
117
+ }
118
+
119
+ export function normalizeRemoteKnowledgeRunContract(payload: unknown, fallback?: Partial<RemoteKnowledgeRunContract>): RemoteKnowledgeRunContract {
120
+ const record = isRecord(payload) ? payload : {};
121
+ return {
122
+ contract_version: REMOTE_KNOWLEDGE_CONTRACT_VERSION,
123
+ id: stringValue(record, 'id') ?? fallback?.id,
124
+ type: (stringValue(record, 'type') as RemoteKnowledgeRunContract['type'] | undefined) ?? fallback?.type,
125
+ status: stringValue(record, 'status') ?? fallback?.status,
126
+ query: stringValue(record, 'query') ?? fallback?.query,
127
+ prompt: stringValue(record, 'prompt') ?? fallback?.prompt,
128
+ output_preview: Object.prototype.hasOwnProperty.call(record, 'output_preview') ? record.output_preview : fallback?.output_preview,
129
+ citations: arrayValue(record, 'citations') ?? fallback?.citations,
130
+ artifacts: arrayValue(record, 'artifacts') ?? fallback?.artifacts,
131
+ usage: isRecord(record.usage) ? record.usage : fallback?.usage,
132
+ created_at: stringValue(record, 'created_at') ?? fallback?.created_at,
133
+ started_at: stringValue(record, 'started_at') ?? fallback?.started_at,
134
+ completed_at: stringValue(record, 'completed_at') ?? fallback?.completed_at,
135
+ duration_ms: numberValue(record, 'duration_ms') ?? fallback?.duration_ms,
136
+ error_code: stringValue(record, 'error_code') ?? fallback?.error_code,
137
+ error_message: stringValue(record, 'error_message') ?? fallback?.error_message,
138
+ error: stringValue(record, 'error') ?? fallback?.error,
139
+ details: Object.prototype.hasOwnProperty.call(record, 'details') ? record.details : fallback?.details,
140
+ };
141
+ }
142
+
143
+ export function knowledgeRegistryContract(input: {
144
+ mode: 'local' | 'hosted';
145
+ sourceSchemes: string[];
146
+ storageType: 'local' | 's3' | 'managed';
147
+ artifactUriPrefix: string | null;
148
+ }): RemoteKnowledgeRegistryContract {
149
+ return {
150
+ contract_version: REMOTE_KNOWLEDGE_CONTRACT_VERSION,
151
+ service: 'open-knowledge',
152
+ mode: input.mode,
153
+ capabilities: [
154
+ 'registry',
155
+ 'search',
156
+ 'ask',
157
+ 'build',
158
+ 'sync',
159
+ 'status',
160
+ 'logs',
161
+ 'artifacts',
162
+ 'open-files-source-refs',
163
+ 's3-generated-artifacts',
164
+ ],
165
+ endpoints: {
166
+ registry: '/api/v1/knowledge/registry',
167
+ search: '/api/v1/knowledge/search',
168
+ ask: '/api/v1/knowledge/ask',
169
+ build: '/api/v1/knowledge/build',
170
+ sync: '/api/v1/knowledge/sync',
171
+ run_status: '/api/v1/knowledge/runs/{run_id}',
172
+ run_logs: '/api/v1/knowledge/runs/{run_id}/logs',
173
+ run_artifacts: '/api/v1/knowledge/runs/{run_id}/artifacts',
174
+ },
175
+ source_contract: {
176
+ owner: 'open-files',
177
+ preferred_ref: 'open-files',
178
+ allowed_schemes: input.sourceSchemes,
179
+ raw_source_bytes_stored_in_open_knowledge: false,
180
+ },
181
+ artifact_contract: {
182
+ storage_type: input.storageType,
183
+ uri_prefix: input.artifactUriPrefix,
184
+ generated_only: true,
185
+ },
186
+ };
187
+ }
188
+
189
+ export class RemoteKnowledgeClient {
190
+ constructor(
191
+ private readonly apiKey: string,
192
+ private readonly apiUrl: string,
193
+ ) {}
194
+
195
+ static fromConfig(config?: KnowledgeConfig, env: Record<string, string | undefined> = process.env): RemoteKnowledgeClient | null {
196
+ const key = getKnowledgeApiKey(env);
197
+ if (!key.apiKey) return null;
198
+ return new RemoteKnowledgeClient(key.apiKey, resolveKnowledgeApiUrl(config, env));
199
+ }
200
+
201
+ private async request(path: string, options: RequestInit = {}): Promise<Response> {
202
+ return fetch(`${this.apiUrl}${path}`, {
203
+ ...options,
204
+ headers: {
205
+ Authorization: `Bearer ${this.apiKey}`,
206
+ 'Content-Type': 'application/json',
207
+ ...options.headers,
208
+ },
209
+ });
210
+ }
211
+
212
+ async registry(): Promise<RemoteKnowledgeRegistryContract> {
213
+ const response = await this.request('/api/v1/knowledge/registry');
214
+ return response.json() as Promise<RemoteKnowledgeRegistryContract>;
215
+ }
216
+
217
+ async search(request: RemoteKnowledgeSearchRequest): Promise<RemoteKnowledgeRunContract> {
218
+ const response = await this.request('/api/v1/knowledge/search', {
219
+ method: 'POST',
220
+ body: JSON.stringify(request),
221
+ });
222
+ return normalizeRemoteKnowledgeRunContract(await response.json(), { type: 'search', query: request.query });
223
+ }
224
+
225
+ async ask(request: RemoteKnowledgePromptRequest): Promise<RemoteKnowledgeRunContract> {
226
+ const response = await this.request('/api/v1/knowledge/ask', {
227
+ method: 'POST',
228
+ body: JSON.stringify(request),
229
+ });
230
+ return normalizeRemoteKnowledgeRunContract(await response.json(), { type: 'ask', prompt: request.prompt });
231
+ }
232
+
233
+ async build(request: RemoteKnowledgePromptRequest): Promise<RemoteKnowledgeRunContract> {
234
+ const response = await this.request('/api/v1/knowledge/build', {
235
+ method: 'POST',
236
+ body: JSON.stringify(request),
237
+ });
238
+ return normalizeRemoteKnowledgeRunContract(await response.json(), { type: 'build', prompt: request.prompt });
239
+ }
240
+
241
+ async sync(request: RemoteKnowledgeSyncRequest = {}): Promise<RemoteKnowledgeRunContract> {
242
+ const response = await this.request('/api/v1/knowledge/sync', {
243
+ method: 'POST',
244
+ body: JSON.stringify(request),
245
+ });
246
+ return normalizeRemoteKnowledgeRunContract(await response.json(), { type: 'sync' });
247
+ }
248
+
249
+ async runStatus(runId: string): Promise<RemoteKnowledgeRunContract | null> {
250
+ const response = await this.request(`/api/v1/knowledge/runs/${encodeURIComponent(runId)}`);
251
+ if (!response.ok) return null;
252
+ return normalizeRemoteKnowledgeRunContract(await response.json(), { id: runId, type: 'status' });
253
+ }
254
+
255
+ async runLogs(runId: string): Promise<RemoteKnowledgeLogEntry[]> {
256
+ const response = await this.request(`/api/v1/knowledge/runs/${encodeURIComponent(runId)}/logs`);
257
+ if (!response.ok) return [];
258
+ const payload = await response.json();
259
+ return Array.isArray(payload) ? payload as RemoteKnowledgeLogEntry[] : [];
260
+ }
261
+
262
+ async runArtifacts(runId: string): Promise<RemoteKnowledgeArtifact[]> {
263
+ const response = await this.request(`/api/v1/knowledge/runs/${encodeURIComponent(runId)}/artifacts`);
264
+ if (!response.ok) return [];
265
+ const payload = await response.json();
266
+ return Array.isArray(payload) ? payload as RemoteKnowledgeArtifact[] : [];
267
+ }
268
+ }