@operor/cli 0.1.0
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 +76 -0
- package/dist/config-Bn2pbORi.js +34 -0
- package/dist/config-Bn2pbORi.js.map +1 -0
- package/dist/converse-C_PB7-JH.js +142 -0
- package/dist/converse-C_PB7-JH.js.map +1 -0
- package/dist/doctor-98gPl743.js +122 -0
- package/dist/doctor-98gPl743.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2268 -0
- package/dist/index.js.map +1 -0
- package/dist/llm-override-BIQl0V6H.js +445 -0
- package/dist/llm-override-BIQl0V6H.js.map +1 -0
- package/dist/reset-DT8SBgFS.js +87 -0
- package/dist/reset-DT8SBgFS.js.map +1 -0
- package/dist/simulate-BKv62GJc.js +144 -0
- package/dist/simulate-BKv62GJc.js.map +1 -0
- package/dist/status-D6LIZvQa.js +82 -0
- package/dist/status-D6LIZvQa.js.map +1 -0
- package/dist/test-DYjkxbtK.js +177 -0
- package/dist/test-DYjkxbtK.js.map +1 -0
- package/dist/test-suite-D8H_5uKs.js +209 -0
- package/dist/test-suite-D8H_5uKs.js.map +1 -0
- package/dist/utils-BuV4q7f6.js +11 -0
- package/dist/utils-BuV4q7f6.js.map +1 -0
- package/dist/vibe-Bl_js3Jo.js +395 -0
- package/dist/vibe-Bl_js3Jo.js.map +1 -0
- package/package.json +43 -0
- package/src/commands/analytics.ts +408 -0
- package/src/commands/chat.ts +310 -0
- package/src/commands/config.ts +34 -0
- package/src/commands/converse.ts +182 -0
- package/src/commands/doctor.ts +154 -0
- package/src/commands/history.ts +60 -0
- package/src/commands/init.ts +163 -0
- package/src/commands/kb.ts +429 -0
- package/src/commands/llm-override.ts +480 -0
- package/src/commands/reset.ts +72 -0
- package/src/commands/simulate.ts +187 -0
- package/src/commands/status.ts +112 -0
- package/src/commands/test-suite.ts +247 -0
- package/src/commands/test.ts +177 -0
- package/src/commands/vibe.ts +478 -0
- package/src/config.ts +127 -0
- package/src/index.ts +190 -0
- package/src/log-timestamps.ts +26 -0
- package/src/setup.ts +712 -0
- package/src/start.ts +573 -0
- package/src/utils.ts +6 -0
- package/templates/agents/_defaults/SOUL.md +20 -0
- package/templates/agents/_defaults/USER.md +16 -0
- package/templates/agents/customer-support/IDENTITY.md +6 -0
- package/templates/agents/customer-support/INSTRUCTIONS.md +79 -0
- package/templates/agents/customer-support/SOUL.md +26 -0
- package/templates/agents/faq-bot/IDENTITY.md +6 -0
- package/templates/agents/faq-bot/INSTRUCTIONS.md +53 -0
- package/templates/agents/faq-bot/SOUL.md +19 -0
- package/templates/agents/sales/IDENTITY.md +6 -0
- package/templates/agents/sales/INSTRUCTIONS.md +67 -0
- package/templates/agents/sales/SOUL.md +20 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +13 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { readConfig, configExists } from '../config.js';
|
|
4
|
+
|
|
5
|
+
export function registerHistoryCommand(program: Command): void {
|
|
6
|
+
const history = program.command('history').description('Conversation history management');
|
|
7
|
+
|
|
8
|
+
history
|
|
9
|
+
.command('clear')
|
|
10
|
+
.description('Clear conversation history from the memory database')
|
|
11
|
+
.option('--customer <id>', 'Clear history for a specific customer ID')
|
|
12
|
+
.option('--agent <name>', 'Clear history for a specific agent')
|
|
13
|
+
.option('--all', 'Clear all conversation history')
|
|
14
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
15
|
+
.action(async (opts: { customer?: string; agent?: string; all?: boolean; yes?: boolean }) => {
|
|
16
|
+
if (!opts.customer && !opts.agent && !opts.all) {
|
|
17
|
+
console.error('Specify --customer <id>, --agent <name>, --all, or a combination.');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!configExists()) {
|
|
22
|
+
console.error('.env file not found. Run "operor setup" first.');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = readConfig();
|
|
27
|
+
if (config.MEMORY_TYPE !== 'sqlite') {
|
|
28
|
+
console.error('History clear requires SQLite memory (MEMORY_TYPE=sqlite).');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { SQLiteMemory } = await import('@operor/memory');
|
|
33
|
+
const memory = new SQLiteMemory(config.MEMORY_DB_PATH || './operor.db');
|
|
34
|
+
await memory.initialize();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const scope = opts.all
|
|
38
|
+
? 'ALL conversation history'
|
|
39
|
+
: `history for ${[opts.customer && `customer=${opts.customer}`, opts.agent && `agent=${opts.agent}`].filter(Boolean).join(', ')}`;
|
|
40
|
+
|
|
41
|
+
if (!opts.yes) {
|
|
42
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
43
|
+
const answer = await new Promise<string>(r => rl.question(`Delete ${scope}? Type "yes" to confirm: `, r));
|
|
44
|
+
rl.close();
|
|
45
|
+
if (answer.trim().toLowerCase() !== 'yes') {
|
|
46
|
+
console.log('Aborted.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { deletedCount } = await memory.clearHistory(
|
|
52
|
+
opts.all ? undefined : opts.customer,
|
|
53
|
+
opts.agent,
|
|
54
|
+
);
|
|
55
|
+
console.log(`Deleted ${deletedCount} message(s).`);
|
|
56
|
+
} finally {
|
|
57
|
+
await memory.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as clack from '@clack/prompts';
|
|
3
|
+
import { readdir, cp, mkdir, access } from 'node:fs/promises';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
function getTemplatesDir(): string {
|
|
11
|
+
// Walk up from __dirname to find templates/agents — works from both
|
|
12
|
+
// dist/index.js (production bundle) and src/commands/init.ts (dev)
|
|
13
|
+
let dir = __dirname;
|
|
14
|
+
for (let i = 0; i < 5; i++) {
|
|
15
|
+
const candidate = join(dir, 'templates', 'agents');
|
|
16
|
+
if (existsSync(candidate)) return candidate;
|
|
17
|
+
dir = dirname(dir);
|
|
18
|
+
}
|
|
19
|
+
throw new Error('Could not find templates directory. Ensure packages/cli/templates/agents/ exists.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TEMPLATES = ['customer-support', 'sales', 'faq-bot'] as const;
|
|
23
|
+
type TemplateName = (typeof TEMPLATES)[number];
|
|
24
|
+
|
|
25
|
+
async function copyDir(src: string, dest: string): Promise<void> {
|
|
26
|
+
await mkdir(dest, { recursive: true });
|
|
27
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const srcPath = join(src, entry.name);
|
|
30
|
+
const destPath = join(dest, entry.name);
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
await copyDir(srcPath, destPath);
|
|
33
|
+
} else {
|
|
34
|
+
await cp(srcPath, destPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function dirExists(path: string): Promise<boolean> {
|
|
40
|
+
try {
|
|
41
|
+
await access(path);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runInit(templateArg?: string): Promise<void> {
|
|
49
|
+
clack.intro('Operor — Initialize Agent');
|
|
50
|
+
|
|
51
|
+
const templatesDir = getTemplatesDir();
|
|
52
|
+
let template: TemplateName;
|
|
53
|
+
|
|
54
|
+
if (templateArg && TEMPLATES.includes(templateArg as TemplateName)) {
|
|
55
|
+
template = templateArg as TemplateName;
|
|
56
|
+
} else if (templateArg === 'blank') {
|
|
57
|
+
// Blank template: just create the directory structure
|
|
58
|
+
const name = await clack.text({
|
|
59
|
+
message: 'Agent name:',
|
|
60
|
+
placeholder: 'my-agent',
|
|
61
|
+
validate: (v) => (v.length === 0 ? 'Name is required' : undefined),
|
|
62
|
+
});
|
|
63
|
+
if (clack.isCancel(name)) { clack.cancel('Cancelled.'); process.exit(0); }
|
|
64
|
+
|
|
65
|
+
const agentDir = join(process.cwd(), 'agents', name as string);
|
|
66
|
+
if (await dirExists(agentDir)) {
|
|
67
|
+
clack.log.error(`Directory already exists: agents/${name}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await mkdir(agentDir, { recursive: true });
|
|
72
|
+
|
|
73
|
+
// Copy defaults if available
|
|
74
|
+
const defaultsDir = join(templatesDir, '_defaults');
|
|
75
|
+
if (await dirExists(defaultsDir)) {
|
|
76
|
+
const defaultsTarget = join(process.cwd(), 'agents', '_defaults');
|
|
77
|
+
if (!(await dirExists(defaultsTarget))) {
|
|
78
|
+
await copyDir(defaultsDir, defaultsTarget);
|
|
79
|
+
clack.log.info('Created agents/_defaults/');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
clack.log.success(`Created blank agent at agents/${name}/`);
|
|
84
|
+
clack.log.info('Add INSTRUCTIONS.md, IDENTITY.md, and SOUL.md to configure your agent.');
|
|
85
|
+
clack.outro('Done!');
|
|
86
|
+
return;
|
|
87
|
+
} else {
|
|
88
|
+
// Prompt user to choose a template
|
|
89
|
+
const choice = await clack.select({
|
|
90
|
+
message: 'Choose a template:',
|
|
91
|
+
options: [
|
|
92
|
+
{ value: 'customer-support', label: 'Customer Support', hint: 'E-commerce support with Shopify' },
|
|
93
|
+
{ value: 'sales', label: 'Sales', hint: 'Lead qualification and demo booking' },
|
|
94
|
+
{ value: 'faq-bot', label: 'FAQ Bot', hint: 'Simple knowledge base Q&A' },
|
|
95
|
+
{ value: 'blank', label: 'Blank', hint: 'Empty agent directory' },
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
if (clack.isCancel(choice)) { clack.cancel('Cancelled.'); process.exit(0); }
|
|
99
|
+
|
|
100
|
+
if (choice === 'blank') {
|
|
101
|
+
await runInit('blank');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
template = choice as TemplateName;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Ask for agent name
|
|
108
|
+
const name = await clack.text({
|
|
109
|
+
message: 'Agent name:',
|
|
110
|
+
placeholder: template,
|
|
111
|
+
defaultValue: template,
|
|
112
|
+
validate: (v) => (v.length === 0 ? 'Name is required' : undefined),
|
|
113
|
+
});
|
|
114
|
+
if (clack.isCancel(name)) { clack.cancel('Cancelled.'); process.exit(0); }
|
|
115
|
+
|
|
116
|
+
const agentName = name as string;
|
|
117
|
+
const agentDir = join(process.cwd(), 'agents', agentName);
|
|
118
|
+
|
|
119
|
+
if (await dirExists(agentDir)) {
|
|
120
|
+
clack.log.error(`Directory already exists: agents/${agentName}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const templateDir = join(templatesDir, template);
|
|
125
|
+
if (!(await dirExists(templateDir))) {
|
|
126
|
+
clack.log.error(`Template not found: ${template}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Copy template files
|
|
131
|
+
await copyDir(templateDir, agentDir);
|
|
132
|
+
clack.log.success(`Created agent from "${template}" template at agents/${agentName}/`);
|
|
133
|
+
|
|
134
|
+
// Copy defaults if they don't already exist
|
|
135
|
+
const defaultsDir = join(templatesDir, '_defaults');
|
|
136
|
+
if (await dirExists(defaultsDir)) {
|
|
137
|
+
const defaultsTarget = join(process.cwd(), 'agents', '_defaults');
|
|
138
|
+
if (!(await dirExists(defaultsTarget))) {
|
|
139
|
+
await copyDir(defaultsDir, defaultsTarget);
|
|
140
|
+
clack.log.info('Created agents/_defaults/');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Copy USER.md to workspace root if it doesn't exist
|
|
145
|
+
// AgentLoader reads USER.md from the workspace root, not agents/
|
|
146
|
+
const userMdSrc = join(templatesDir, '_defaults', 'USER.md');
|
|
147
|
+
const userMdDest = join(process.cwd(), 'USER.md');
|
|
148
|
+
if (await dirExists(userMdSrc) && !(await dirExists(userMdDest))) {
|
|
149
|
+
await cp(userMdSrc, userMdDest);
|
|
150
|
+
clack.log.info('Created USER.md');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
clack.outro('Done! Edit the files in agents/' + agentName + '/ to customize your agent.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function registerInitCommand(program: Command): void {
|
|
157
|
+
program
|
|
158
|
+
.command('init [template]')
|
|
159
|
+
.description('Initialize a new agent from a template (customer-support, sales, faq-bot, blank)')
|
|
160
|
+
.action(async (template?: string) => {
|
|
161
|
+
await runInit(template);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as clack from '@clack/prompts';
|
|
3
|
+
import { readConfig, configExists } from '../config.js';
|
|
4
|
+
|
|
5
|
+
function getKBConfig() {
|
|
6
|
+
if (!configExists()) {
|
|
7
|
+
clack.log.error('.env file not found. Run "operor setup" first.');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
const config = readConfig();
|
|
11
|
+
if (config.KB_ENABLED !== 'true') {
|
|
12
|
+
clack.log.error('Knowledge Base is not enabled. Run "operor setup" or set KB_ENABLED=true.');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
return config;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function loadKB(config: ReturnType<typeof readConfig>) {
|
|
19
|
+
try {
|
|
20
|
+
const {
|
|
21
|
+
SQLiteKnowledgeStore,
|
|
22
|
+
EmbeddingService,
|
|
23
|
+
TextChunker,
|
|
24
|
+
IngestionPipeline,
|
|
25
|
+
RetrievalPipeline,
|
|
26
|
+
UrlIngestor,
|
|
27
|
+
FileIngestor,
|
|
28
|
+
SiteCrawler,
|
|
29
|
+
} = await import('@operor/knowledge');
|
|
30
|
+
|
|
31
|
+
const embeddings = new EmbeddingService({
|
|
32
|
+
provider: (config.KB_EMBEDDING_PROVIDER as 'openai' | 'google' | 'mistral' | 'cohere' | 'ollama') || 'openai',
|
|
33
|
+
apiKey: config.KB_EMBEDDING_API_KEY,
|
|
34
|
+
model: config.KB_EMBEDDING_MODEL,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const store = new SQLiteKnowledgeStore(
|
|
38
|
+
config.KB_DB_PATH || './knowledge.db',
|
|
39
|
+
embeddings.dimensions,
|
|
40
|
+
);
|
|
41
|
+
await store.initialize();
|
|
42
|
+
|
|
43
|
+
const chunker = new TextChunker({
|
|
44
|
+
chunkSize: config.KB_CHUNK_SIZE ? parseInt(config.KB_CHUNK_SIZE) : undefined,
|
|
45
|
+
chunkOverlap: config.KB_CHUNK_OVERLAP ? parseInt(config.KB_CHUNK_OVERLAP) : undefined,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Create LLM provider for Q&A extraction during ingestion (if configured)
|
|
49
|
+
let llmProvider: any;
|
|
50
|
+
if (config.LLM_PROVIDER && config.LLM_API_KEY) {
|
|
51
|
+
const { AIProvider } = await import('@operor/llm');
|
|
52
|
+
llmProvider = new AIProvider({
|
|
53
|
+
provider: config.LLM_PROVIDER as any,
|
|
54
|
+
apiKey: config.LLM_API_KEY,
|
|
55
|
+
model: config.LLM_MODEL,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ingestion = new IngestionPipeline(store, embeddings, chunker, llmProvider);
|
|
60
|
+
const retrieval = new RetrievalPipeline(store, embeddings);
|
|
61
|
+
const crawl4aiUrl = config.CRAWL4AI_URL || undefined;
|
|
62
|
+
const urlIngestor = new UrlIngestor(ingestion, { crawl4aiUrl });
|
|
63
|
+
const fileIngestor = new FileIngestor(ingestion);
|
|
64
|
+
const siteCrawler = new SiteCrawler(ingestion, { crawl4aiUrl });
|
|
65
|
+
|
|
66
|
+
return { store, embeddings, ingestion, retrieval, urlIngestor, fileIngestor, siteCrawler };
|
|
67
|
+
} catch {
|
|
68
|
+
clack.log.error(
|
|
69
|
+
'@operor/knowledge package not found. Install it first:\n pnpm add @operor/knowledge'
|
|
70
|
+
);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function registerKBCommand(program: Command): void {
|
|
76
|
+
const kb = program.command('kb').description('Knowledge Base management');
|
|
77
|
+
|
|
78
|
+
kb.command('add-url')
|
|
79
|
+
.description('Ingest a URL into the knowledge base')
|
|
80
|
+
.argument('<url>', 'URL to ingest')
|
|
81
|
+
.option('--priority <n>', 'Document priority (1=official, 2=supplementary, 3=archived)', '2')
|
|
82
|
+
.option('--extract-qa', 'Use LLM to extract Q&A pairs instead of chunking')
|
|
83
|
+
.action(async (url: string, opts: { priority: string; extractQa?: boolean }) => {
|
|
84
|
+
clack.intro('KB — Add URL');
|
|
85
|
+
const config = getKBConfig();
|
|
86
|
+
const { store, urlIngestor } = await loadKB(config);
|
|
87
|
+
|
|
88
|
+
const spinner = clack.spinner();
|
|
89
|
+
spinner.start(`Fetching and ingesting ${url}`);
|
|
90
|
+
try {
|
|
91
|
+
const doc = await urlIngestor.ingestUrl(url, {
|
|
92
|
+
priority: parseInt(opts.priority),
|
|
93
|
+
extractQA: opts.extractQa,
|
|
94
|
+
});
|
|
95
|
+
spinner.stop(`Ingested: ${doc.title || url}`);
|
|
96
|
+
clack.log.success(`Document ID: ${doc.id}`);
|
|
97
|
+
} catch (error: any) {
|
|
98
|
+
spinner.stop(`Failed to ingest URL`);
|
|
99
|
+
clack.log.error(error.message);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
} finally {
|
|
102
|
+
store.close();
|
|
103
|
+
}
|
|
104
|
+
clack.outro('Done');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
kb.command('add-site')
|
|
108
|
+
.description('Crawl and ingest an entire website')
|
|
109
|
+
.argument('<url>', 'Website URL to crawl')
|
|
110
|
+
.option('--depth <n>', 'Maximum crawl depth', '2')
|
|
111
|
+
.option('--max-pages <n>', 'Maximum pages to crawl', '50')
|
|
112
|
+
.option('--no-sitemap', 'Skip sitemap.xml and use link crawling only')
|
|
113
|
+
.option('--delay <ms>', 'Delay between requests in milliseconds', '500')
|
|
114
|
+
.action(async (url: string, opts: { depth: string; maxPages: string; sitemap: boolean; delay: string }) => {
|
|
115
|
+
clack.intro('KB — Crawl Site');
|
|
116
|
+
const config = getKBConfig();
|
|
117
|
+
const { store, siteCrawler } = await loadKB(config);
|
|
118
|
+
|
|
119
|
+
const spinner = clack.spinner();
|
|
120
|
+
spinner.start('Starting crawl...');
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const docs = await siteCrawler.crawlSite(url, {
|
|
124
|
+
maxDepth: parseInt(opts.depth),
|
|
125
|
+
maxPages: parseInt(opts.maxPages),
|
|
126
|
+
useSitemap: opts.sitemap,
|
|
127
|
+
delayMs: parseInt(opts.delay),
|
|
128
|
+
onProgress: (crawled, discovered, currentUrl) => {
|
|
129
|
+
const shortUrl = currentUrl.length > 60 ? currentUrl.slice(0, 57) + '...' : currentUrl;
|
|
130
|
+
spinner.message(`Crawling... (${crawled}/${discovered} pages) ${shortUrl}`);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
spinner.stop(`Crawled ${docs.length} page(s)`);
|
|
135
|
+
clack.log.success(`Ingested ${docs.length} document(s) from ${url}`);
|
|
136
|
+
} catch (error: any) {
|
|
137
|
+
spinner.stop('Crawl failed');
|
|
138
|
+
clack.log.error(error.message);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
} finally {
|
|
141
|
+
store.close();
|
|
142
|
+
}
|
|
143
|
+
clack.outro('Done');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
kb.command('add-file')
|
|
147
|
+
.description('Ingest a document file into the knowledge base')
|
|
148
|
+
.argument('<path>', 'File path to ingest')
|
|
149
|
+
.option('--priority <n>', 'Document priority (1=official, 2=supplementary, 3=archived)', '2')
|
|
150
|
+
.action(async (filePath: string, opts: { priority: string }) => {
|
|
151
|
+
clack.intro('KB — Add File');
|
|
152
|
+
const config = getKBConfig();
|
|
153
|
+
const { store, fileIngestor } = await loadKB(config);
|
|
154
|
+
|
|
155
|
+
const spinner = clack.spinner();
|
|
156
|
+
spinner.start(`Ingesting ${filePath}`);
|
|
157
|
+
try {
|
|
158
|
+
const doc = await fileIngestor.ingestFile(filePath, undefined, { priority: parseInt(opts.priority) });
|
|
159
|
+
spinner.stop(`Ingested: ${doc.title || filePath}`);
|
|
160
|
+
clack.log.success(`Document ID: ${doc.id}`);
|
|
161
|
+
} catch (error: any) {
|
|
162
|
+
spinner.stop(`Failed to ingest file`);
|
|
163
|
+
clack.log.error(error.message);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
} finally {
|
|
166
|
+
store.close();
|
|
167
|
+
}
|
|
168
|
+
clack.outro('Done');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
kb.command('add-faq')
|
|
172
|
+
.description('Add a manual FAQ entry')
|
|
173
|
+
.argument('<question>', 'FAQ question')
|
|
174
|
+
.argument('<answer>', 'FAQ answer')
|
|
175
|
+
.action(async (question: string, answer: string) => {
|
|
176
|
+
clack.intro('KB — Add FAQ');
|
|
177
|
+
const config = getKBConfig();
|
|
178
|
+
const { store, ingestion } = await loadKB(config);
|
|
179
|
+
|
|
180
|
+
const spinner = clack.spinner();
|
|
181
|
+
spinner.start('Adding FAQ entry');
|
|
182
|
+
try {
|
|
183
|
+
let doc = await ingestion.ingestFaq(question, answer);
|
|
184
|
+
// CLI auto-replaces similar FAQs silently
|
|
185
|
+
if ((doc as any).existingMatch) {
|
|
186
|
+
const match = (doc as any).existingMatch;
|
|
187
|
+
clack.log.info(`Replacing similar FAQ (${(match.score * 100).toFixed(0)}% match): "${match.question}"`);
|
|
188
|
+
doc = await ingestion.ingestFaq(question, answer, { forceReplace: true, replaceId: match.id });
|
|
189
|
+
}
|
|
190
|
+
spinner.stop('FAQ entry added');
|
|
191
|
+
clack.log.success(`Document ID: ${doc.id}`);
|
|
192
|
+
} catch (error: any) {
|
|
193
|
+
spinner.stop('Failed to add FAQ');
|
|
194
|
+
clack.log.error(error.message);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
} finally {
|
|
197
|
+
store.close();
|
|
198
|
+
}
|
|
199
|
+
clack.outro('Done');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
kb.command('list')
|
|
203
|
+
.description('List all KB documents')
|
|
204
|
+
.action(async () => {
|
|
205
|
+
clack.intro('KB — Documents');
|
|
206
|
+
const config = getKBConfig();
|
|
207
|
+
const { store } = await loadKB(config);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const docs = await store.listDocuments();
|
|
211
|
+
if (docs.length === 0) {
|
|
212
|
+
clack.log.info('No documents in the knowledge base.');
|
|
213
|
+
} else {
|
|
214
|
+
const header = `${'ID'.padEnd(40)} ${'Type'.padEnd(12)} Title / Source`;
|
|
215
|
+
clack.log.info(header);
|
|
216
|
+
clack.log.info('─'.repeat(header.length));
|
|
217
|
+
for (const doc of docs) {
|
|
218
|
+
const line = `${doc.id.padEnd(40)} ${doc.sourceType.padEnd(12)} ${doc.title || doc.sourceUrl || doc.fileName || '—'}`;
|
|
219
|
+
clack.log.info(line);
|
|
220
|
+
}
|
|
221
|
+
clack.log.info(`\n${docs.length} document(s)`);
|
|
222
|
+
}
|
|
223
|
+
} catch (error: any) {
|
|
224
|
+
clack.log.error(error.message);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
} finally {
|
|
227
|
+
store.close();
|
|
228
|
+
}
|
|
229
|
+
clack.outro('');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
kb.command('search')
|
|
233
|
+
.description('Test search against the knowledge base')
|
|
234
|
+
.argument('<query>', 'Search query')
|
|
235
|
+
.option('-n, --limit <n>', 'Max results', '5')
|
|
236
|
+
.action(async (query: string, opts: { limit: string }) => {
|
|
237
|
+
clack.intro('KB — Search');
|
|
238
|
+
const config = getKBConfig();
|
|
239
|
+
const { store, retrieval } = await loadKB(config);
|
|
240
|
+
|
|
241
|
+
const spinner = clack.spinner();
|
|
242
|
+
spinner.start(`Searching for "${query}"`);
|
|
243
|
+
try {
|
|
244
|
+
const result = await retrieval.retrieve(query, { limit: parseInt(opts.limit) });
|
|
245
|
+
spinner.stop(`Found ${result.results.length} result(s)`);
|
|
246
|
+
|
|
247
|
+
if (result.results.length === 0) {
|
|
248
|
+
clack.log.info('No results found.');
|
|
249
|
+
} else {
|
|
250
|
+
for (const r of result.results) {
|
|
251
|
+
clack.log.info(`\n[Score: ${r.score.toFixed(4)}] Doc #${r.document.id}`);
|
|
252
|
+
const preview = r.chunk.content.length > 200 ? r.chunk.content.slice(0, 200) + '…' : r.chunk.content;
|
|
253
|
+
clack.log.message(preview);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch (error: any) {
|
|
257
|
+
spinner.stop('Search failed');
|
|
258
|
+
clack.log.error(error.message);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
} finally {
|
|
261
|
+
store.close();
|
|
262
|
+
}
|
|
263
|
+
clack.outro('');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
kb.command('ask')
|
|
267
|
+
.description('Ask a question using KB retrieval + LLM generation (RAG)')
|
|
268
|
+
.argument('<question>', 'Question to answer')
|
|
269
|
+
.option('-n, --limit <n>', 'Max KB chunks to retrieve', '5')
|
|
270
|
+
.option('--no-sources', 'Hide source attribution')
|
|
271
|
+
.action(async (question: string, opts: { limit: string; sources: boolean }) => {
|
|
272
|
+
clack.intro('KB — Ask');
|
|
273
|
+
const config = getKBConfig();
|
|
274
|
+
const { store, retrieval } = await loadKB(config);
|
|
275
|
+
|
|
276
|
+
const spinner = clack.spinner();
|
|
277
|
+
spinner.start('Searching knowledge base...');
|
|
278
|
+
try {
|
|
279
|
+
const result = await retrieval.retrieve(question, { limit: parseInt(opts.limit) });
|
|
280
|
+
|
|
281
|
+
// FAQ fast-path — no LLM needed
|
|
282
|
+
if (result.isFaqMatch && result.results.length > 0) {
|
|
283
|
+
spinner.stop('FAQ match found');
|
|
284
|
+
clack.log.success(result.results[0].chunk.content);
|
|
285
|
+
if (opts.sources) {
|
|
286
|
+
clack.log.info(`\nSource: FAQ (score: ${result.results[0].score.toFixed(4)})`);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (result.results.length === 0) {
|
|
292
|
+
spinner.stop('No relevant KB content found');
|
|
293
|
+
clack.log.warn('Cannot answer — no matching documents.');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// LLM generation
|
|
298
|
+
if (!config.LLM_PROVIDER || !config.LLM_API_KEY) {
|
|
299
|
+
spinner.stop('KB results found, but no LLM configured');
|
|
300
|
+
clack.log.error('LLM_PROVIDER and LLM_API_KEY required. Use "operor kb search" for retrieval-only.');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
spinner.message('Generating answer...');
|
|
305
|
+
const { AIProvider } = await import('@operor/llm');
|
|
306
|
+
const llm = new AIProvider({
|
|
307
|
+
provider: config.LLM_PROVIDER as any,
|
|
308
|
+
apiKey: config.LLM_API_KEY,
|
|
309
|
+
model: config.LLM_MODEL,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const response = await llm.complete([
|
|
313
|
+
{ role: 'system', content: `Answer using ONLY the provided context. If insufficient, say so.\n\n${result.context}` },
|
|
314
|
+
{ role: 'user', content: question },
|
|
315
|
+
]);
|
|
316
|
+
|
|
317
|
+
spinner.stop('Answer generated');
|
|
318
|
+
clack.log.success(response.text);
|
|
319
|
+
|
|
320
|
+
if (opts.sources) {
|
|
321
|
+
clack.log.info('\nSources:');
|
|
322
|
+
for (const r of result.results) {
|
|
323
|
+
const title = r.document?.title || r.document?.id || 'unknown';
|
|
324
|
+
clack.log.info(` - ${title} (score: ${r.score.toFixed(4)})`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch (error: any) {
|
|
328
|
+
spinner.stop('Failed');
|
|
329
|
+
clack.log.error(error.message);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
} finally {
|
|
332
|
+
store.close();
|
|
333
|
+
}
|
|
334
|
+
clack.outro('');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
kb.command('delete')
|
|
338
|
+
.description('Delete a document from the knowledge base')
|
|
339
|
+
.argument('<id>', 'Document ID to delete')
|
|
340
|
+
.action(async (id: string) => {
|
|
341
|
+
clack.intro('KB — Delete');
|
|
342
|
+
const config = getKBConfig();
|
|
343
|
+
const { store } = await loadKB(config);
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
await store.deleteDocument(id);
|
|
347
|
+
clack.log.success(`Document #${id} deleted`);
|
|
348
|
+
} catch (error: any) {
|
|
349
|
+
clack.log.error(error.message);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
} finally {
|
|
352
|
+
store.close();
|
|
353
|
+
}
|
|
354
|
+
clack.outro('Done');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
kb.command('rebuild')
|
|
358
|
+
.description('Rebuild all KB vector embeddings using the current embedding provider')
|
|
359
|
+
.action(async () => {
|
|
360
|
+
clack.intro('KB — Rebuild Embeddings');
|
|
361
|
+
const config = getKBConfig();
|
|
362
|
+
const { store, ingestion } = await loadKB(config);
|
|
363
|
+
|
|
364
|
+
const spinner = clack.spinner();
|
|
365
|
+
try {
|
|
366
|
+
const stats = await store.getStats();
|
|
367
|
+
if (stats.documentCount === 0) {
|
|
368
|
+
clack.log.warn('Knowledge Base is empty — nothing to rebuild.');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
clack.log.info(`Provider: ${config.KB_EMBEDDING_PROVIDER || 'openai'}`);
|
|
373
|
+
clack.log.info(`Documents: ${stats.documentCount}, Chunks: ${stats.chunkCount}`);
|
|
374
|
+
spinner.start('Rebuilding embeddings...');
|
|
375
|
+
|
|
376
|
+
const result = await ingestion.rebuild((current, total, docTitle) => {
|
|
377
|
+
if (current < total) {
|
|
378
|
+
spinner.message(`Rebuilding... (${current + 1}/${total}) ${docTitle}`);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
spinner.stop('Rebuild complete');
|
|
383
|
+
clack.log.success([
|
|
384
|
+
`Documents rebuilt: ${result.documentsRebuilt}`,
|
|
385
|
+
`Chunks rebuilt: ${result.chunksRebuilt}`,
|
|
386
|
+
`Old dimensions: ${result.oldDimensions}`,
|
|
387
|
+
`New dimensions: ${result.newDimensions}`,
|
|
388
|
+
].join('\n'));
|
|
389
|
+
} catch (error: any) {
|
|
390
|
+
spinner.stop('Rebuild failed');
|
|
391
|
+
clack.log.error(error.message);
|
|
392
|
+
process.exit(1);
|
|
393
|
+
} finally {
|
|
394
|
+
store.close();
|
|
395
|
+
}
|
|
396
|
+
clack.outro('Done');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
kb.command('stats')
|
|
400
|
+
.description('Show knowledge base statistics')
|
|
401
|
+
.action(async () => {
|
|
402
|
+
clack.intro('KB — Statistics');
|
|
403
|
+
const config = getKBConfig();
|
|
404
|
+
const { store } = await loadKB(config);
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
const stats = await store.getStats();
|
|
408
|
+
clack.log.info(`Documents: ${stats.documentCount}`);
|
|
409
|
+
clack.log.info(`Chunks: ${stats.chunkCount}`);
|
|
410
|
+
clack.log.info(`Embedding dims: ${stats.embeddingDimensions || '—'}`);
|
|
411
|
+
clack.log.info(`DB size: ${formatBytes(stats.dbSizeBytes)}`);
|
|
412
|
+
clack.log.info(`DB path: ${config.KB_DB_PATH || './knowledge.db'}`);
|
|
413
|
+
clack.log.info(`Embedding provider: ${config.KB_EMBEDDING_PROVIDER || 'openai'}`);
|
|
414
|
+
clack.log.info(`Embedding model: ${config.KB_EMBEDDING_MODEL || 'default'}`);
|
|
415
|
+
} catch (error: any) {
|
|
416
|
+
clack.log.error(error.message);
|
|
417
|
+
process.exit(1);
|
|
418
|
+
} finally {
|
|
419
|
+
store.close();
|
|
420
|
+
}
|
|
421
|
+
clack.outro('');
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function formatBytes(bytes: number): string {
|
|
426
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
427
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
428
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
429
|
+
}
|