@hasna/knowledge 0.2.6 → 0.2.8
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 +39 -1
- package/bin/open-knowledge-mcp.js +637 -5
- package/bin/open-knowledge.js +75 -21
- package/docs/architecture/ai-native-knowledge-base.md +18 -0
- package/package.json +1 -1
- package/src/cli.ts +169 -5
- package/src/knowledge-db.ts +41 -1
- package/src/manifest-ingest.ts +58 -9
- package/src/mcp.js +25 -0
- package/src/outbox-consume.ts +33 -4
- package/src/safety.ts +265 -0
- package/src/source-ref.ts +12 -0
- package/src/source-resolver.ts +418 -0
- package/src/workspace.ts +26 -0
package/src/cli.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { defaultStorePath, loadStore, saveStore, withLock, makeId, makeShortId, ensureStore, type KnowledgeItem } from './store';
|
|
8
8
|
import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace';
|
|
9
|
-
import { getKnowledgeDbStats, migrateKnowledgeDb } from './knowledge-db';
|
|
9
|
+
import { getKnowledgeDbStats, migrateKnowledgeDb, openKnowledgeDb } from './knowledge-db';
|
|
10
10
|
import { createArtifactStore } from './artifact-store';
|
|
11
11
|
import { initializeWikiLayout } from './wiki-layout';
|
|
12
12
|
import { ingestOpenFilesManifest } from './manifest-ingest';
|
|
13
13
|
import { consumeOpenFilesOutbox } from './outbox-consume';
|
|
14
|
+
import { resolveOpenFilesSource } from './source-resolver';
|
|
15
|
+
import { approvalStatus, assertS3ReadAllowed, assertWebSearchAllowed, createApprovalGate, recordAuditEvent, recordRedactionFindings, redactSecrets, resolveSafetyPolicy } from './safety';
|
|
14
16
|
import pkg from '../package.json' with { type: 'json' };
|
|
15
17
|
|
|
16
18
|
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
@@ -48,6 +50,7 @@ interface Flags {
|
|
|
48
50
|
tag?: string;
|
|
49
51
|
format?: string;
|
|
50
52
|
completions?: string;
|
|
53
|
+
purpose?: string;
|
|
51
54
|
noColor?: boolean;
|
|
52
55
|
scope?: string;
|
|
53
56
|
olderThan?: number;
|
|
@@ -61,7 +64,7 @@ interface ParseResult {
|
|
|
61
64
|
flags: Flags;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'ingest', 'reindex', 'help'];
|
|
67
|
+
const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'source', 'ingest', 'reindex', 'safety', 'help'];
|
|
65
68
|
const COMMAND_ALIASES: Record<string, string> = {
|
|
66
69
|
ls: 'list',
|
|
67
70
|
rm: 'delete',
|
|
@@ -96,6 +99,7 @@ function parseArgs(argv: string[]): ParseResult {
|
|
|
96
99
|
case '--tag': case '-t': flags.tag = argv[i + 1]; i += 1; break;
|
|
97
100
|
case '--format': flags.format = argv[i + 1]; i += 1; break;
|
|
98
101
|
case '--completions': flags.completions = argv[i + 1]; i += 1; break;
|
|
102
|
+
case '--purpose': flags.purpose = argv[i + 1]; i += 1; break;
|
|
99
103
|
case '--no-color': flags.noColor = true; break;
|
|
100
104
|
case '--scope': flags.scope = argv[i + 1]; i += 1; break;
|
|
101
105
|
case '--older-than': flags.olderThan = Number(argv[i + 1]); i += 1; break;
|
|
@@ -164,13 +168,16 @@ Commands:
|
|
|
164
168
|
paths Show resolved workspace/store paths
|
|
165
169
|
db init|stats Initialize or inspect local knowledge.db
|
|
166
170
|
wiki init Initialize scalable wiki/schema/index/log artifacts
|
|
171
|
+
source resolve <source-ref> Resolve read-only source content and citation evidence
|
|
167
172
|
ingest manifest <file|s3://> Ingest an open-files manifest into knowledge.db
|
|
168
173
|
reindex outbox <file|s3://> Consume open-files change events and invalidate chunks
|
|
174
|
+
safety status|check|approve|audit|redact
|
|
169
175
|
help [command] Show help
|
|
170
176
|
|
|
171
177
|
Global Options:
|
|
172
178
|
--json Output JSON
|
|
173
179
|
--store <path> Override store path
|
|
180
|
+
--purpose <name> Read-only source purpose (default: knowledge_answer)
|
|
174
181
|
--scope local|global|project Store scope (default: global ~/.hasna/apps/knowledge/)
|
|
175
182
|
--no-color Disable color output
|
|
176
183
|
--completions <shell> Output completions for bash|zsh|fish
|
|
@@ -227,8 +234,10 @@ function printCommandHelp(command: string): void {
|
|
|
227
234
|
if (command === 'paths') { console.log('Usage: open-knowledge paths [--scope local|global|project] [--json]'); return; }
|
|
228
235
|
if (command === 'db') { console.log('Usage: open-knowledge db init|stats [--scope local|global|project] [--json]'); return; }
|
|
229
236
|
if (command === 'wiki') { console.log('Usage: open-knowledge wiki init [--scope local|global|project] [--json]'); return; }
|
|
237
|
+
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; }
|
|
230
238
|
if (command === 'ingest') { console.log('Usage: open-knowledge ingest manifest <file|s3://bucket/key> [--scope local|global|project] [--json]'); return; }
|
|
231
239
|
if (command === 'reindex') { console.log('Usage: open-knowledge reindex outbox <file|s3://bucket/key> [--scope local|global|project] [--json]'); return; }
|
|
240
|
+
if (command === 'safety') { console.log('Usage: open-knowledge safety status|check|approve|audit|redact [args] [--scope local|global|project] [--json]'); return; }
|
|
232
241
|
printGlobalHelp();
|
|
233
242
|
}
|
|
234
243
|
|
|
@@ -273,11 +282,11 @@ async function run(argv: string[]): Promise<void> {
|
|
|
273
282
|
if (flags.completions) {
|
|
274
283
|
const shell = flags.completions;
|
|
275
284
|
if (shell === 'bash') {
|
|
276
|
-
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 ingest reindex 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`);
|
|
285
|
+
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 source ingest reindex safety help ls rm edit unarchive --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --purpose --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
|
|
277
286
|
} else if (shell === 'zsh') {
|
|
278
|
-
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 ingest reindex 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`);
|
|
287
|
+
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 source ingest reindex safety 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):" "(--purpose)--purpose[purpose]:" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
|
|
279
288
|
} else if (shell === 'fish') {
|
|
280
|
-
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 ingest reindex 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"`);
|
|
289
|
+
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 source ingest reindex safety 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 purpose; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
|
|
281
290
|
} else {
|
|
282
291
|
throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
|
|
283
292
|
}
|
|
@@ -347,6 +356,157 @@ async function run(argv: string[]): Promise<void> {
|
|
|
347
356
|
return;
|
|
348
357
|
}
|
|
349
358
|
|
|
359
|
+
if (command === 'safety') {
|
|
360
|
+
const action = positional[1] ?? 'status';
|
|
361
|
+
const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
|
|
362
|
+
const config = readKnowledgeConfig(resolvedWorkspace.configPath);
|
|
363
|
+
const policy = resolveSafetyPolicy(config, resolvedWorkspace);
|
|
364
|
+
migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
|
|
365
|
+
const db = openKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
|
|
366
|
+
try {
|
|
367
|
+
if (action === 'status') {
|
|
368
|
+
output({
|
|
369
|
+
ok: true,
|
|
370
|
+
mode: policy.mode,
|
|
371
|
+
workspace: resolvedWorkspace.home,
|
|
372
|
+
allow_write_roots: policy.allowWriteRoots,
|
|
373
|
+
read_only_source_access: policy.readOnlySourceAccess,
|
|
374
|
+
network: policy.network,
|
|
375
|
+
redaction: policy.redaction,
|
|
376
|
+
approvals: policy.approvals,
|
|
377
|
+
message: `Safety policy: ${policy.mode}`,
|
|
378
|
+
}, flags.json);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (action === 'check') {
|
|
382
|
+
const checkAction = positional[2] ?? 'generated_write';
|
|
383
|
+
const target = positional[3] ?? null;
|
|
384
|
+
let decision: ReturnType<typeof approvalStatus> | { action: string; target_uri: string | null; approval_required: false; approved: boolean; decision: string };
|
|
385
|
+
try {
|
|
386
|
+
if (checkAction === 'web_search') {
|
|
387
|
+
assertWebSearchAllowed(policy);
|
|
388
|
+
decision = { action: checkAction, target_uri: target, approval_required: false, approved: true, decision: 'allow' };
|
|
389
|
+
} else if (checkAction === 's3_read') {
|
|
390
|
+
if (!target) throw new Error('safety check s3_read requires an s3:// target.');
|
|
391
|
+
assertS3ReadAllowed(target, policy);
|
|
392
|
+
decision = { action: checkAction, target_uri: target, approval_required: false, approved: true, decision: 'allow' };
|
|
393
|
+
} else {
|
|
394
|
+
decision = approvalStatus(db, policy, checkAction, target);
|
|
395
|
+
}
|
|
396
|
+
recordAuditEvent(db, {
|
|
397
|
+
event_type: 'safety_check',
|
|
398
|
+
action: checkAction,
|
|
399
|
+
target_uri: target,
|
|
400
|
+
decision: decision.decision === 'allow' ? 'allow' : 'requires_approval',
|
|
401
|
+
metadata: decision,
|
|
402
|
+
});
|
|
403
|
+
output({ ok: true, ...decision, message: `Safety check ${decision.decision}` }, flags.json);
|
|
404
|
+
return;
|
|
405
|
+
} catch (error) {
|
|
406
|
+
recordAuditEvent(db, {
|
|
407
|
+
event_type: 'safety_check',
|
|
408
|
+
action: checkAction,
|
|
409
|
+
target_uri: target,
|
|
410
|
+
decision: 'deny',
|
|
411
|
+
metadata: { error: error instanceof Error ? error.message : String(error) },
|
|
412
|
+
});
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (action === 'approve') {
|
|
417
|
+
const approveAction = positional[2] ?? 'generated_write';
|
|
418
|
+
const target = positional[3] ?? null;
|
|
419
|
+
const approval = createApprovalGate(db, {
|
|
420
|
+
action: approveAction,
|
|
421
|
+
target_uri: target,
|
|
422
|
+
reason: 'local-cli approval',
|
|
423
|
+
metadata: { scope: flags.scope ?? 'global' },
|
|
424
|
+
});
|
|
425
|
+
recordAuditEvent(db, {
|
|
426
|
+
event_type: 'approval',
|
|
427
|
+
action: approveAction,
|
|
428
|
+
target_uri: target,
|
|
429
|
+
decision: 'allow',
|
|
430
|
+
metadata: { approval_id: approval.id },
|
|
431
|
+
});
|
|
432
|
+
output({ ok: true, ...approval, action: approveAction, target_uri: target, message: `Approved ${approveAction}` }, flags.json);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (action === 'audit') {
|
|
436
|
+
const rows = db.query<{
|
|
437
|
+
id: string;
|
|
438
|
+
event_type: string;
|
|
439
|
+
action: string;
|
|
440
|
+
target_uri: string | null;
|
|
441
|
+
decision: string;
|
|
442
|
+
metadata_json: string;
|
|
443
|
+
created_at: string;
|
|
444
|
+
}, []>(
|
|
445
|
+
'SELECT id, event_type, action, target_uri, decision, metadata_json, created_at FROM audit_events ORDER BY created_at DESC LIMIT 50',
|
|
446
|
+
).all().map((row) => ({
|
|
447
|
+
id: row.id,
|
|
448
|
+
event_type: row.event_type,
|
|
449
|
+
action: row.action,
|
|
450
|
+
target_uri: row.target_uri,
|
|
451
|
+
decision: row.decision,
|
|
452
|
+
metadata: JSON.parse(row.metadata_json),
|
|
453
|
+
created_at: row.created_at,
|
|
454
|
+
}));
|
|
455
|
+
output({ ok: true, events: rows, message: `${rows.length} audit event(s)` }, flags.json);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (action === 'redact') {
|
|
459
|
+
const text = positional.slice(2).join(' ');
|
|
460
|
+
if (!text) throw new Error('Usage: open-knowledge safety redact <text>');
|
|
461
|
+
const result = redactSecrets(text, policy);
|
|
462
|
+
if (result.findings.length > 0) {
|
|
463
|
+
recordRedactionFindings(db, {
|
|
464
|
+
source_uri: 'safety://redact',
|
|
465
|
+
findings: result.findings,
|
|
466
|
+
metadata: { command: 'safety redact' },
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
recordAuditEvent(db, {
|
|
470
|
+
event_type: 'redaction',
|
|
471
|
+
action: 'safety_redact',
|
|
472
|
+
target_uri: 'safety://redact',
|
|
473
|
+
decision: result.findings.length > 0 ? 'redacted' : 'allow',
|
|
474
|
+
metadata: { findings: result.findings.length },
|
|
475
|
+
});
|
|
476
|
+
output({ ok: true, text: result.text, findings: result.findings, message: `Redacted ${result.findings.length} finding(s)` }, flags.json);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
throw new Error("Invalid safety action. Use 'status', 'check', 'approve', 'audit', or 'redact'.");
|
|
480
|
+
} finally {
|
|
481
|
+
db.close();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (command === 'source') {
|
|
486
|
+
const action = positional[1] ?? '';
|
|
487
|
+
if (action !== 'resolve') throw new Error("Invalid source action. Use 'resolve'.");
|
|
488
|
+
const sourceRef = positional[2];
|
|
489
|
+
if (!sourceRef) throw new Error('Usage: open-knowledge source resolve <source-ref>');
|
|
490
|
+
const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
|
|
491
|
+
const config = readKnowledgeConfig(resolvedWorkspace.configPath);
|
|
492
|
+
const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
|
|
493
|
+
const result = await resolveOpenFilesSource({
|
|
494
|
+
dbPath: resolvedWorkspace.knowledgeDbPath,
|
|
495
|
+
sourceRef,
|
|
496
|
+
purpose: flags.purpose,
|
|
497
|
+
limit: flags.limit,
|
|
498
|
+
safetyPolicy,
|
|
499
|
+
});
|
|
500
|
+
output({
|
|
501
|
+
ok: true,
|
|
502
|
+
...result,
|
|
503
|
+
message: result.resolved
|
|
504
|
+
? `Resolved ${result.source_ref} (${result.content.chunks_returned}/${result.content.chunks_total} chunks)`
|
|
505
|
+
: `Source not indexed: ${sourceRef}`,
|
|
506
|
+
}, flags.json);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
350
510
|
if (command === 'ingest') {
|
|
351
511
|
const action = positional[1] ?? '';
|
|
352
512
|
if (action !== 'manifest') throw new Error("Invalid ingest action. Use 'manifest'.");
|
|
@@ -354,10 +514,12 @@ async function run(argv: string[]): Promise<void> {
|
|
|
354
514
|
if (!input) throw new Error('Usage: open-knowledge ingest manifest <file|s3://bucket/key>');
|
|
355
515
|
const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
|
|
356
516
|
const config = readKnowledgeConfig(resolvedWorkspace.configPath);
|
|
517
|
+
const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
|
|
357
518
|
const result = await ingestOpenFilesManifest({
|
|
358
519
|
dbPath: resolvedWorkspace.knowledgeDbPath,
|
|
359
520
|
input,
|
|
360
521
|
config,
|
|
522
|
+
safetyPolicy,
|
|
361
523
|
});
|
|
362
524
|
output({ ok: true, ...result, message: `Ingested ${result.items_seen} manifest item(s)` }, flags.json);
|
|
363
525
|
return;
|
|
@@ -370,10 +532,12 @@ async function run(argv: string[]): Promise<void> {
|
|
|
370
532
|
if (!input) throw new Error('Usage: open-knowledge reindex outbox <file|s3://bucket/key>');
|
|
371
533
|
const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
|
|
372
534
|
const config = readKnowledgeConfig(resolvedWorkspace.configPath);
|
|
535
|
+
const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
|
|
373
536
|
const result = await consumeOpenFilesOutbox({
|
|
374
537
|
dbPath: resolvedWorkspace.knowledgeDbPath,
|
|
375
538
|
input,
|
|
376
539
|
config,
|
|
540
|
+
safetyPolicy,
|
|
377
541
|
});
|
|
378
542
|
output({ ok: true, ...result, message: `Consumed ${result.events_seen} outbox event(s)` }, flags.json);
|
|
379
543
|
return;
|
package/src/knowledge-db.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite';
|
|
2
2
|
import { ensureParentDir } from './workspace';
|
|
3
3
|
|
|
4
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
4
|
+
export const CURRENT_SCHEMA_VERSION = 3;
|
|
5
5
|
|
|
6
6
|
export interface KnowledgeDbStats {
|
|
7
7
|
schema_version: number;
|
|
@@ -13,6 +13,9 @@ export interface KnowledgeDbStats {
|
|
|
13
13
|
indexes: number;
|
|
14
14
|
runs: number;
|
|
15
15
|
run_events: number;
|
|
16
|
+
redaction_findings: number;
|
|
17
|
+
audit_events: number;
|
|
18
|
+
approval_gates: number;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
const MIGRATION_1 = `
|
|
@@ -199,6 +202,39 @@ INSERT OR IGNORE INTO schema_versions(version, applied_at)
|
|
|
199
202
|
VALUES (2, datetime('now'));
|
|
200
203
|
`;
|
|
201
204
|
|
|
205
|
+
const MIGRATION_3 = `
|
|
206
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
207
|
+
id TEXT PRIMARY KEY,
|
|
208
|
+
event_type TEXT NOT NULL,
|
|
209
|
+
action TEXT NOT NULL,
|
|
210
|
+
target_uri TEXT,
|
|
211
|
+
decision TEXT NOT NULL,
|
|
212
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
213
|
+
created_at TEXT NOT NULL
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
CREATE TABLE IF NOT EXISTS approval_gates (
|
|
217
|
+
id TEXT PRIMARY KEY,
|
|
218
|
+
action TEXT NOT NULL,
|
|
219
|
+
target_uri TEXT,
|
|
220
|
+
status TEXT NOT NULL,
|
|
221
|
+
reason TEXT,
|
|
222
|
+
approved_by TEXT,
|
|
223
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
224
|
+
created_at TEXT NOT NULL,
|
|
225
|
+
updated_at TEXT NOT NULL
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action);
|
|
229
|
+
CREATE INDEX IF NOT EXISTS idx_audit_events_target ON audit_events(target_uri);
|
|
230
|
+
CREATE INDEX IF NOT EXISTS idx_audit_events_created ON audit_events(created_at);
|
|
231
|
+
CREATE INDEX IF NOT EXISTS idx_approval_gates_action ON approval_gates(action);
|
|
232
|
+
CREATE INDEX IF NOT EXISTS idx_approval_gates_status ON approval_gates(status);
|
|
233
|
+
|
|
234
|
+
INSERT OR IGNORE INTO schema_versions(version, applied_at)
|
|
235
|
+
VALUES (3, datetime('now'));
|
|
236
|
+
`;
|
|
237
|
+
|
|
202
238
|
export function openKnowledgeDb(path: string): Database {
|
|
203
239
|
ensureParentDir(path);
|
|
204
240
|
const db = new Database(path);
|
|
@@ -211,6 +247,7 @@ export function migrateKnowledgeDb(path: string): { path: string; schema_version
|
|
|
211
247
|
try {
|
|
212
248
|
db.exec(MIGRATION_1);
|
|
213
249
|
if (getSchemaVersion(db) < 2) db.exec(MIGRATION_2);
|
|
250
|
+
if (getSchemaVersion(db) < 3) db.exec(MIGRATION_3);
|
|
214
251
|
return { path, schema_version: getSchemaVersion(db) };
|
|
215
252
|
} finally {
|
|
216
253
|
db.close();
|
|
@@ -240,6 +277,9 @@ export function getKnowledgeDbStats(path: string): KnowledgeDbStats {
|
|
|
240
277
|
indexes: count(db, 'knowledge_indexes'),
|
|
241
278
|
runs: count(db, 'runs'),
|
|
242
279
|
run_events: count(db, 'run_events'),
|
|
280
|
+
redaction_findings: count(db, 'redaction_findings'),
|
|
281
|
+
audit_events: count(db, 'audit_events'),
|
|
282
|
+
approval_gates: count(db, 'approval_gates'),
|
|
243
283
|
};
|
|
244
284
|
} finally {
|
|
245
285
|
db.close();
|
package/src/manifest-ingest.ts
CHANGED
|
@@ -5,11 +5,20 @@ import type { Database } from 'bun:sqlite';
|
|
|
5
5
|
import { migrateKnowledgeDb, openKnowledgeDb } from './knowledge-db';
|
|
6
6
|
import { parseSourceRef, type SourceRef } from './source-ref';
|
|
7
7
|
import type { KnowledgeConfig } from './workspace';
|
|
8
|
+
import {
|
|
9
|
+
assertS3ReadAllowed,
|
|
10
|
+
assertWriteAllowed,
|
|
11
|
+
recordAuditEvent,
|
|
12
|
+
recordRedactionFindings,
|
|
13
|
+
redactSecrets,
|
|
14
|
+
type SafetyPolicy,
|
|
15
|
+
} from './safety';
|
|
8
16
|
|
|
9
17
|
export interface ManifestIngestOptions {
|
|
10
18
|
dbPath: string;
|
|
11
19
|
input: string;
|
|
12
20
|
config?: KnowledgeConfig;
|
|
21
|
+
safetyPolicy?: SafetyPolicy;
|
|
13
22
|
now?: Date;
|
|
14
23
|
maxChunkChars?: number;
|
|
15
24
|
chunkOverlapChars?: number;
|
|
@@ -23,6 +32,7 @@ export interface ManifestIngestResult {
|
|
|
23
32
|
revisions_upserted: number;
|
|
24
33
|
chunks_inserted: number;
|
|
25
34
|
chunks_deleted: number;
|
|
35
|
+
redactions: number;
|
|
26
36
|
skipped: number;
|
|
27
37
|
}
|
|
28
38
|
|
|
@@ -209,11 +219,12 @@ function parseManifestText(text: string): ManifestObject[] {
|
|
|
209
219
|
});
|
|
210
220
|
}
|
|
211
221
|
|
|
212
|
-
async function readS3Text(uri: string, config?: KnowledgeConfig): Promise<string> {
|
|
222
|
+
async function readS3Text(uri: string, config?: KnowledgeConfig, safetyPolicy?: SafetyPolicy): Promise<string> {
|
|
213
223
|
const parsed = new URL(uri);
|
|
214
224
|
const bucket = parsed.hostname;
|
|
215
225
|
const key = decodeURIComponent(parsed.pathname.replace(/^\/+/, ''));
|
|
216
226
|
if (!bucket || !key) throw new Error(`Invalid S3 manifest URI: ${uri}`);
|
|
227
|
+
if (safetyPolicy) assertS3ReadAllowed(uri, safetyPolicy);
|
|
217
228
|
const [{ S3Client, GetObjectCommand }, { fromIni }] = await Promise.all([
|
|
218
229
|
import('@aws-sdk/client-s3'),
|
|
219
230
|
import('@aws-sdk/credential-providers'),
|
|
@@ -229,8 +240,8 @@ async function readS3Text(uri: string, config?: KnowledgeConfig): Promise<string
|
|
|
229
240
|
return await response.Body.transformToString();
|
|
230
241
|
}
|
|
231
242
|
|
|
232
|
-
async function readManifestInput(input: string, config?: KnowledgeConfig): Promise<string> {
|
|
233
|
-
if (input.startsWith('s3://')) return readS3Text(input, config);
|
|
243
|
+
async function readManifestInput(input: string, config?: KnowledgeConfig, safetyPolicy?: SafetyPolicy): Promise<string> {
|
|
244
|
+
if (input.startsWith('s3://')) return readS3Text(input, config, safetyPolicy);
|
|
234
245
|
if (!existsSync(input)) throw new Error(`Manifest not found: ${input}`);
|
|
235
246
|
return readFileSync(input, 'utf8');
|
|
236
247
|
}
|
|
@@ -338,9 +349,26 @@ function upsertRevision(db: Database, sourceId: string, item: NormalizedManifest
|
|
|
338
349
|
return row.id;
|
|
339
350
|
}
|
|
340
351
|
|
|
341
|
-
function insertChunks(db: Database, sourceRevisionId: string, item: NormalizedManifestItem, now: string, maxChars: number, overlapChars: number): number {
|
|
342
|
-
if (!item.text || item.status.toLowerCase() === 'deleted') return 0;
|
|
343
|
-
const
|
|
352
|
+
function insertChunks(db: Database, sourceRevisionId: string, item: NormalizedManifestItem, now: string, maxChars: number, overlapChars: number, safetyPolicy?: SafetyPolicy): { chunksInserted: number; redactions: number } {
|
|
353
|
+
if (!item.text || item.status.toLowerCase() === 'deleted') return { chunksInserted: 0, redactions: 0 };
|
|
354
|
+
const redacted = redactSecrets(item.text, safetyPolicy);
|
|
355
|
+
if (redacted.findings.length > 0) {
|
|
356
|
+
recordRedactionFindings(db, {
|
|
357
|
+
source_uri: item.sourceUri,
|
|
358
|
+
findings: redacted.findings,
|
|
359
|
+
metadata: { source_ref: item.sourceRef, revision: item.revision },
|
|
360
|
+
created_at: now,
|
|
361
|
+
});
|
|
362
|
+
recordAuditEvent(db, {
|
|
363
|
+
event_type: 'redaction',
|
|
364
|
+
action: 'source_text_redact',
|
|
365
|
+
target_uri: item.sourceUri,
|
|
366
|
+
decision: 'redacted',
|
|
367
|
+
metadata: { findings: redacted.findings.length, source_ref: item.sourceRef, revision: item.revision },
|
|
368
|
+
created_at: now,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const chunks = chunkText(redacted.text, maxChars, overlapChars);
|
|
344
372
|
for (const chunk of chunks) {
|
|
345
373
|
const chunkId = stableId('chk', `${sourceRevisionId}\u0000${chunk.ordinal}\u0000${chunk.text}`);
|
|
346
374
|
const metadata = {
|
|
@@ -373,7 +401,7 @@ function insertChunks(db: Database, sourceRevisionId: string, item: NormalizedMa
|
|
|
373
401
|
[chunkId, chunk.text, item.title ?? '', item.sourceUri],
|
|
374
402
|
);
|
|
375
403
|
}
|
|
376
|
-
return chunks.length;
|
|
404
|
+
return { chunksInserted: chunks.length, redactions: redacted.findings.length };
|
|
377
405
|
}
|
|
378
406
|
|
|
379
407
|
export async function ingestOpenFilesManifest(options: ManifestIngestOptions): Promise<ManifestIngestResult> {
|
|
@@ -383,8 +411,9 @@ export async function ingestOpenFilesManifest(options: ManifestIngestOptions): P
|
|
|
383
411
|
if (maxChunkChars < 500) throw new Error('maxChunkChars must be at least 500.');
|
|
384
412
|
if (chunkOverlapChars < 0 || chunkOverlapChars >= maxChunkChars) throw new Error('chunkOverlapChars must be less than maxChunkChars.');
|
|
385
413
|
|
|
414
|
+
if (options.safetyPolicy) assertWriteAllowed(options.dbPath, options.safetyPolicy);
|
|
386
415
|
migrateKnowledgeDb(options.dbPath);
|
|
387
|
-
const text = await readManifestInput(options.input, options.config);
|
|
416
|
+
const text = await readManifestInput(options.input, options.config, options.safetyPolicy);
|
|
388
417
|
const items = parseManifestText(text);
|
|
389
418
|
const db = openKnowledgeDb(options.dbPath);
|
|
390
419
|
try {
|
|
@@ -393,7 +422,16 @@ export async function ingestOpenFilesManifest(options: ManifestIngestOptions): P
|
|
|
393
422
|
const seenRevisions = new Set<string>();
|
|
394
423
|
let chunksInserted = 0;
|
|
395
424
|
let chunksDeleted = 0;
|
|
425
|
+
let redactions = 0;
|
|
396
426
|
let skipped = 0;
|
|
427
|
+
recordAuditEvent(db, {
|
|
428
|
+
event_type: 'source_read',
|
|
429
|
+
action: options.input.startsWith('s3://') ? 's3_manifest_read' : 'local_manifest_read',
|
|
430
|
+
target_uri: options.input,
|
|
431
|
+
decision: 'allow',
|
|
432
|
+
metadata: { items: items.length, read_only: true },
|
|
433
|
+
created_at: now,
|
|
434
|
+
});
|
|
397
435
|
for (const raw of items) {
|
|
398
436
|
const item = normalizeManifestItem(raw, now);
|
|
399
437
|
const sourceId = upsertSource(db, item, now);
|
|
@@ -403,8 +441,18 @@ export async function ingestOpenFilesManifest(options: ManifestIngestOptions): P
|
|
|
403
441
|
if (item.text || item.status.toLowerCase() === 'deleted') {
|
|
404
442
|
chunksDeleted += deleteChunksForRevision(db, revisionId);
|
|
405
443
|
}
|
|
406
|
-
|
|
444
|
+
const inserted = insertChunks(db, revisionId, item, now, maxChunkChars, chunkOverlapChars, options.safetyPolicy);
|
|
445
|
+
chunksInserted += inserted.chunksInserted;
|
|
446
|
+
redactions += inserted.redactions;
|
|
407
447
|
}
|
|
448
|
+
recordAuditEvent(db, {
|
|
449
|
+
event_type: 'write',
|
|
450
|
+
action: 'knowledge_manifest_ingest',
|
|
451
|
+
target_uri: options.dbPath,
|
|
452
|
+
decision: 'allow',
|
|
453
|
+
metadata: { items: items.length, sources: seenSources.size, revisions: seenRevisions.size, chunks_inserted: chunksInserted, redactions },
|
|
454
|
+
created_at: now,
|
|
455
|
+
});
|
|
408
456
|
return {
|
|
409
457
|
path: options.input,
|
|
410
458
|
db_path: options.dbPath,
|
|
@@ -413,6 +461,7 @@ export async function ingestOpenFilesManifest(options: ManifestIngestOptions): P
|
|
|
413
461
|
revisions_upserted: seenRevisions.size,
|
|
414
462
|
chunks_inserted: chunksInserted,
|
|
415
463
|
chunks_deleted: chunksDeleted,
|
|
464
|
+
redactions,
|
|
416
465
|
skipped,
|
|
417
466
|
};
|
|
418
467
|
})();
|
package/src/mcp.js
CHANGED
|
@@ -7,6 +7,8 @@ import pkg from '../package.json' with { type: 'json' };
|
|
|
7
7
|
import { defaultStorePath, loadStore, saveStore, makeId, withLock } from './store.ts';
|
|
8
8
|
import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace.ts';
|
|
9
9
|
import { parseSourceRef } from './source-ref.ts';
|
|
10
|
+
import { resolveOpenFilesSource } from './source-resolver.ts';
|
|
11
|
+
import { resolveSafetyPolicy } from './safety.ts';
|
|
10
12
|
|
|
11
13
|
const storePathField = z.string().optional().describe('Path to the JSON store file');
|
|
12
14
|
const scopeField = z.enum(['local', 'global', 'project']).optional().describe('Workspace scope');
|
|
@@ -102,6 +104,29 @@ export function buildServer() {
|
|
|
102
104
|
}
|
|
103
105
|
});
|
|
104
106
|
|
|
107
|
+
registerTool(server, 'ok_resolve_source', 'Resolve source content', 'Resolve an indexed source ref through the read-only open-files boundary and return chunk citation evidence', {
|
|
108
|
+
source_ref: z.string().describe('Source reference URI, preferably open-files://...'),
|
|
109
|
+
purpose: z.string().optional().describe('Read-only purpose label, default knowledge_answer'),
|
|
110
|
+
limit: z.number().optional().describe('Maximum chunks to return, default 10'),
|
|
111
|
+
scope: scopeField,
|
|
112
|
+
}, async ({ source_ref, purpose, limit, scope }) => {
|
|
113
|
+
const workspace = ensureKnowledgeWorkspace(resolveScopedWorkspace(scope).home);
|
|
114
|
+
const config = readKnowledgeConfig(workspace.configPath);
|
|
115
|
+
const safetyPolicy = resolveSafetyPolicy(config, workspace);
|
|
116
|
+
try {
|
|
117
|
+
const result = await resolveOpenFilesSource({
|
|
118
|
+
dbPath: workspace.knowledgeDbPath,
|
|
119
|
+
sourceRef: source_ref,
|
|
120
|
+
purpose,
|
|
121
|
+
limit,
|
|
122
|
+
safetyPolicy,
|
|
123
|
+
});
|
|
124
|
+
return jsonText({ ok: true, ...result });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return errorText(error instanceof Error ? error.message : String(error));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
105
130
|
registerTool(server, 'ok_add', 'Add a knowledge item', 'Add a new item to the knowledge store', {
|
|
106
131
|
title: z.string().describe('Item title'),
|
|
107
132
|
content: z.string().describe('Item content/body'),
|
package/src/outbox-consume.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Database } from 'bun:sqlite';
|
|
|
5
5
|
import { migrateKnowledgeDb, openKnowledgeDb } from './knowledge-db';
|
|
6
6
|
import { parseSourceRef, type SourceRef } from './source-ref';
|
|
7
7
|
import type { KnowledgeConfig } from './workspace';
|
|
8
|
+
import { assertS3ReadAllowed, assertWriteAllowed, recordAuditEvent, type SafetyPolicy } from './safety';
|
|
8
9
|
|
|
9
10
|
type OutboxObject = Record<string, unknown>;
|
|
10
11
|
|
|
@@ -12,6 +13,7 @@ export interface OutboxConsumeOptions {
|
|
|
12
13
|
dbPath: string;
|
|
13
14
|
input: string;
|
|
14
15
|
config?: KnowledgeConfig;
|
|
16
|
+
safetyPolicy?: SafetyPolicy;
|
|
15
17
|
now?: Date;
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -165,11 +167,12 @@ function parseOutboxText(text: string): OutboxObject[] {
|
|
|
165
167
|
});
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
async function readS3Text(uri: string, config?: KnowledgeConfig): Promise<string> {
|
|
170
|
+
async function readS3Text(uri: string, config?: KnowledgeConfig, safetyPolicy?: SafetyPolicy): Promise<string> {
|
|
169
171
|
const parsed = new URL(uri);
|
|
170
172
|
const bucket = parsed.hostname;
|
|
171
173
|
const key = decodeURIComponent(parsed.pathname.replace(/^\/+/, ''));
|
|
172
174
|
if (!bucket || !key) throw new Error(`Invalid S3 outbox URI: ${uri}`);
|
|
175
|
+
if (safetyPolicy) assertS3ReadAllowed(uri, safetyPolicy);
|
|
173
176
|
const [{ S3Client, GetObjectCommand }, { fromIni }] = await Promise.all([
|
|
174
177
|
import('@aws-sdk/client-s3'),
|
|
175
178
|
import('@aws-sdk/credential-providers'),
|
|
@@ -185,8 +188,8 @@ async function readS3Text(uri: string, config?: KnowledgeConfig): Promise<string
|
|
|
185
188
|
return await response.Body.transformToString();
|
|
186
189
|
}
|
|
187
190
|
|
|
188
|
-
async function readOutboxInput(input: string, config?: KnowledgeConfig): Promise<string> {
|
|
189
|
-
if (input.startsWith('s3://')) return readS3Text(input, config);
|
|
191
|
+
async function readOutboxInput(input: string, config?: KnowledgeConfig, safetyPolicy?: SafetyPolicy): Promise<string> {
|
|
192
|
+
if (input.startsWith('s3://')) return readS3Text(input, config, safetyPolicy);
|
|
190
193
|
if (!existsSync(input)) throw new Error(`Outbox not found: ${input}`);
|
|
191
194
|
return readFileSync(input, 'utf8');
|
|
192
195
|
}
|
|
@@ -318,8 +321,9 @@ function isPermissionEvent(eventType: string): boolean {
|
|
|
318
321
|
|
|
319
322
|
export async function consumeOpenFilesOutbox(options: OutboxConsumeOptions): Promise<OutboxConsumeResult> {
|
|
320
323
|
const now = (options.now ?? new Date()).toISOString();
|
|
324
|
+
if (options.safetyPolicy) assertWriteAllowed(options.dbPath, options.safetyPolicy);
|
|
321
325
|
migrateKnowledgeDb(options.dbPath);
|
|
322
|
-
const text = await readOutboxInput(options.input, options.config);
|
|
326
|
+
const text = await readOutboxInput(options.input, options.config, options.safetyPolicy);
|
|
323
327
|
const events = parseOutboxText(text);
|
|
324
328
|
const db = openKnowledgeDb(options.dbPath);
|
|
325
329
|
const runId = `run_${randomUUID()}`;
|
|
@@ -350,6 +354,15 @@ export async function consumeOpenFilesOutbox(options: OutboxConsumeOptions): Pro
|
|
|
350
354
|
let movedSources = 0;
|
|
351
355
|
let permissionUpdates = 0;
|
|
352
356
|
|
|
357
|
+
recordAuditEvent(db, {
|
|
358
|
+
event_type: 'source_read',
|
|
359
|
+
action: options.input.startsWith('s3://') ? 's3_outbox_read' : 'local_outbox_read',
|
|
360
|
+
target_uri: options.input,
|
|
361
|
+
decision: 'allow',
|
|
362
|
+
metadata: { events: events.length, read_only: true },
|
|
363
|
+
created_at: now,
|
|
364
|
+
});
|
|
365
|
+
|
|
353
366
|
events.forEach((raw, index) => {
|
|
354
367
|
const event = normalizeEvent(raw, now);
|
|
355
368
|
const sourceId = ensureSource(db, event, now);
|
|
@@ -404,6 +417,22 @@ export async function consumeOpenFilesOutbox(options: OutboxConsumeOptions): Pro
|
|
|
404
417
|
],
|
|
405
418
|
);
|
|
406
419
|
|
|
420
|
+
recordAuditEvent(db, {
|
|
421
|
+
event_type: 'write',
|
|
422
|
+
action: 'knowledge_outbox_invalidation',
|
|
423
|
+
target_uri: options.dbPath,
|
|
424
|
+
decision: 'allow',
|
|
425
|
+
metadata: {
|
|
426
|
+
run_id: runId,
|
|
427
|
+
events: events.length,
|
|
428
|
+
sources: sourcesTouched.size,
|
|
429
|
+
revisions: revisionsTouched.size,
|
|
430
|
+
chunks_deleted: chunksDeleted,
|
|
431
|
+
embeddings_deleted: embeddingsDeleted,
|
|
432
|
+
},
|
|
433
|
+
created_at: now,
|
|
434
|
+
});
|
|
435
|
+
|
|
407
436
|
return {
|
|
408
437
|
path: options.input,
|
|
409
438
|
db_path: options.dbPath,
|