@hasna/knowledge 0.2.19 → 0.2.21

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:
@@ -180,3 +180,12 @@ Reindexing is driven by source revisions:
180
180
  - Permission filters run before model context assembly.
181
181
  - Retrieval internals can swap from JSON vectors to pgvector or managed vector
182
182
  stores without changing CLI/MCP result contracts.
183
+
184
+ ## Evaluation Fixtures
185
+
186
+ `tests/semantic-evals.test.ts` and `tests/fixtures/semantic-eval-fixtures.ts`
187
+ seed no-network corpora for retrieval quality checks. The fixtures cover
188
+ keyword hits, deterministic fake-vector fallback for synonym-style prompts,
189
+ citation correctness, stale revision filtering, non-read-only provenance
190
+ filtering, rerank ordering, generated wiki-page retrieval, missing-source
191
+ answers, and `knowledge <prompt>` context assembly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/knowledge",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
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,6 +204,9 @@ 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
212
  wiki init Initialize scalable wiki/schema/index/log artifacts
@@ -216,6 +233,13 @@ Global Options:
216
233
  --generate Call AI SDK text generation for ask/build
217
234
  --approve-write Record approval intent for future durable wiki writes
218
235
  --provider <name> Provider override for web search
236
+ --mode local|hosted Configure OSS local or hosted-aware mode
237
+ --api-url <url> Hosted API origin (or KNOWLEDGE_API_URL)
238
+ --api-key <key> Hosted API key for auth login
239
+ --email <email> Hosted account email metadata
240
+ --org <slug> Hosted organization slug metadata
241
+ --org-id <id> Hosted organization id metadata
242
+ --user-id <id> Hosted user id metadata
219
243
  --domain <domain> Restrict provider web search to a domain
220
244
  --file-results File web snippets as web source refs
221
245
  --full Force full embedding index rebuild
@@ -274,6 +298,9 @@ function printCommandHelp(command: string): void {
274
298
  if (command === 'dedupe') { console.log('Usage: open-knowledge dedupe --yes [--json]'); return; }
275
299
  if (command === 'stats') { console.log('Usage: open-knowledge stats [--json]'); return; }
276
300
  if (command === 'paths') { console.log('Usage: open-knowledge paths [--scope local|global|project] [--json]'); return; }
301
+ if (command === 'setup') { console.log('Usage: open-knowledge setup --mode local|hosted [--api-url https://...] [--scope local|global|project] [--json]'); return; }
302
+ 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; }
303
+ if (command === 'remote') { console.log('Usage: open-knowledge remote contracts|status [--scope local|global|project] [--json]'); return; }
277
304
  if (command === 'storage') { console.log('Usage: open-knowledge storage status|validate [--scope local|global|project] [--json]'); return; }
278
305
  if (command === 'db') { console.log('Usage: open-knowledge db init|stats [--scope local|global|project] [--json]'); return; }
279
306
  if (command === 'wiki') { console.log('Usage: open-knowledge wiki init [--scope local|global|project] [--json]'); return; }
@@ -330,11 +357,11 @@ async function run(argv: string[]): Promise<void> {
330
357
  if (flags.completions) {
331
358
  const shell = flags.completions;
332
359
  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`);
360
+ 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
361
  } 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`);
362
+ 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
363
  } 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"`);
364
+ 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
365
  } else {
339
366
  throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
340
367
  }
@@ -365,6 +392,84 @@ async function run(argv: string[]): Promise<void> {
365
392
  return;
366
393
  }
367
394
 
395
+ if (command === 'setup') {
396
+ const result = service.setup({
397
+ mode: flags.mode,
398
+ apiUrl: flags.apiUrl,
399
+ });
400
+ output(result, flags.json);
401
+ return;
402
+ }
403
+
404
+ if (command === 'auth') {
405
+ const action = positional[1] ?? 'whoami';
406
+ if (action === 'whoami' || action === 'status') {
407
+ const result = service.authStatus(process.env);
408
+ output({ ok: true, ...result, message: result.authenticated ? `Authenticated via ${result.source}` : 'Not authenticated' }, flags.json);
409
+ return;
410
+ }
411
+ if (action === 'login') {
412
+ const apiKey = flags.apiKey ?? process.env.KNOWLEDGE_API_KEY ?? process.env.HASNA_KNOWLEDGE_API_KEY;
413
+ if (!apiKey) throw new Error('Usage: open-knowledge auth login --api-key <key> [--email <email>]');
414
+ const auth = service.saveAuth({
415
+ apiKey,
416
+ email: flags.email,
417
+ orgSlug: flags.org,
418
+ orgId: flags.orgId,
419
+ userId: flags.userId,
420
+ apiUrl: flags.apiUrl,
421
+ }, process.env);
422
+ output({
423
+ ok: true,
424
+ authenticated: true,
425
+ email: auth.email ?? null,
426
+ org_slug: auth.org_slug ?? null,
427
+ api_url: auth.api_url ?? service.authStatus(process.env).api_url,
428
+ auth_path: service.authStatus(process.env).auth_path,
429
+ message: `Saved hosted credentials for ${auth.email ?? 'API key'}`,
430
+ }, flags.json);
431
+ return;
432
+ }
433
+ if (action === 'logout') {
434
+ const removed = service.clearAuth(process.env);
435
+ output({ ok: true, removed, message: removed ? 'Removed hosted credentials' : 'No hosted credentials found' }, flags.json);
436
+ return;
437
+ }
438
+ throw new Error("Invalid auth action. Use 'login', 'whoami', or 'logout'.");
439
+ }
440
+
441
+ if (command === 'remote') {
442
+ const action = positional[1] ?? 'status';
443
+ if (action === 'contracts' || action === 'contract') {
444
+ const auth = service.authStatus(process.env);
445
+ output({
446
+ ok: true,
447
+ authenticated: auth.authenticated,
448
+ api_url: auth.api_url,
449
+ contract: service.remoteContract(),
450
+ message: `Remote contract v${service.remoteContract().contract_version}`,
451
+ }, flags.json);
452
+ return;
453
+ }
454
+ if (action === 'status') {
455
+ const auth = service.authStatus(process.env);
456
+ const contract = service.remoteContract();
457
+ output({
458
+ ok: true,
459
+ mode: service.config().mode,
460
+ authenticated: auth.authenticated,
461
+ auth_source: auth.source,
462
+ api_url: auth.api_url,
463
+ client_ready: Boolean(service.remoteClient(process.env)),
464
+ contract_version: contract.contract_version,
465
+ capabilities: contract.capabilities,
466
+ message: auth.authenticated ? `Remote client ready for ${auth.api_url}` : 'Remote client not authenticated',
467
+ }, flags.json);
468
+ return;
469
+ }
470
+ throw new Error("Invalid remote action. Use 'contracts' or 'status'.");
471
+ }
472
+
368
473
  if (command === 'storage') {
369
474
  const action = positional[1] ?? 'status';
370
475
  if (action === 'status') {
@@ -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
+ }