@dotdrelle/wiki-manager 0.6.17

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.
@@ -0,0 +1,1044 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { createLlmClientFromWikiConfig } from '../agent/llm.js';
5
+ import { composeServices, listServices, runWikiCli, serviceLogs, serviceStates, startService, stopService } from '../core/compose.js';
6
+ import {
7
+ applyMcpRuntimeStatus,
8
+ buildMcpStatus,
9
+ callMcpTool,
10
+ discoverMcpTools,
11
+ formatMcpToolResult,
12
+ formatMcpStatus,
13
+ formatMcpToolSummary,
14
+ formatMcpTools,
15
+ } from '../core/mcp.js';
16
+ import { createWorkspace, findWorkspace, listWorkspaces } from '../core/workspaces.js';
17
+ import { findSkill, listSkills } from '../core/skills.js';
18
+ import { extractActivity, formatActivityError, formatActivityLine, formatActivitySummary, parseJsonText } from '../core/activity.js';
19
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
20
+ import {
21
+ cancelQueueItem,
22
+ clearFinishedQueueItems,
23
+ enqueueProductionJob,
24
+ formatQueue,
25
+ productionLockBusy,
26
+ } from '../core/jobQueue.js';
27
+ import {
28
+ listWikircProfiles,
29
+ loadWikircProfile,
30
+ resolveWikircProfile,
31
+ summarizeWikircConfig,
32
+ } from '../core/wikirc.js';
33
+ import {
34
+ cleanDocumentUploads,
35
+ convertPendingDocumentUploads,
36
+ convertStoredDocument,
37
+ formatUploadRecord,
38
+ listDocumentUploads,
39
+ storeAndMaybeConvertDocument,
40
+ } from '../core/documentIntake.js';
41
+
42
+ export function printVersion(packageJson) {
43
+ console.log(packageJson.version);
44
+ }
45
+
46
+ const styles = {
47
+ reset: '\u001b[0m',
48
+ cyan: '\u001b[36m',
49
+ bold: '\u001b[1m',
50
+ };
51
+
52
+ function stripAnsi(value) {
53
+ return String(value).replace(/\u001b\[[0-9;]*m/g, '');
54
+ }
55
+
56
+ function padVisible(value, width) {
57
+ const text = String(value);
58
+ return `${text}${' '.repeat(Math.max(0, width - stripAnsi(text).length))}`;
59
+ }
60
+
61
+ function sectionBlock(title, lines = []) {
62
+ return [title, ...lines.map((line) => ` ${line}`)].join('\n');
63
+ }
64
+
65
+ function twoColumns(left, right) {
66
+ const leftLines = String(left || '').split('\n');
67
+ const rightLines = String(right || '').split('\n');
68
+ const rows = Math.max(leftLines.length, rightLines.length);
69
+ const out = [];
70
+ for (let i = 0; i < rows; i += 1) {
71
+ const l = leftLines[i] ?? '';
72
+ const r = rightLines[i] ?? '';
73
+ out.push(r ? `${l}\t${r}` : l);
74
+ }
75
+ return out.join('\n');
76
+ }
77
+
78
+ function commandLabel(value) {
79
+ return `${styles.bold}${styles.cyan}${value}${styles.reset}`;
80
+ }
81
+
82
+ function helpPair(leftCommand, leftText, rightCommand, rightText) {
83
+ const left = `${padVisible(commandLabel(leftCommand), 18)}${leftText}`;
84
+ const right = rightCommand ? `${padVisible(commandLabel(rightCommand), 18)}${rightText}` : '';
85
+ return ` ${padVisible(left, 38)}${right}`;
86
+ }
87
+
88
+ function wikircSummaryText(summary) {
89
+ return [
90
+ `profile=${summary.profile}`,
91
+ `file=${summary.fileName}`,
92
+ `provider=${summary.provider ?? '-'}`,
93
+ `model=${summary.model ?? '-'}`,
94
+ `baseUrl=${summary.baseUrl ?? '-'}`,
95
+ `language=${summary.language ?? '-'}`,
96
+ `apiKey=${summary.hasApiKey ? 'configured' : 'missing'}`,
97
+ `vector=${summary.vectorEnabled ? 'enabled' : 'disabled'}`,
98
+ `embedding=${summary.embeddingModel ?? '-'}`,
99
+ ].join('\n');
100
+ }
101
+
102
+ function toPosixPath(value) {
103
+ return String(value).replace(/\\/g, '/');
104
+ }
105
+
106
+ function walkFiles(rootPath) {
107
+ const files = [];
108
+ if (!rootPath || !existsSync(rootPath)) return files;
109
+ const visit = (dir) => {
110
+ let entries = [];
111
+ try {
112
+ entries = readdirSync(dir, { withFileTypes: true });
113
+ } catch {
114
+ return;
115
+ }
116
+ for (const entry of entries) {
117
+ const absolutePath = join(dir, entry.name);
118
+ if (entry.isDirectory()) {
119
+ visit(absolutePath);
120
+ } else if (entry.isFile()) {
121
+ try {
122
+ const stat = statSync(absolutePath);
123
+ files.push({ absolutePath, size: stat.size, mtimeMs: stat.mtimeMs });
124
+ } catch {
125
+ // Ignore files that disappear or cannot be read while status is collected.
126
+ }
127
+ }
128
+ }
129
+ };
130
+ visit(rootPath);
131
+ return files;
132
+ }
133
+
134
+ function markdownFiles(workspacePath, relativeDir) {
135
+ const rootPath = join(workspacePath, relativeDir);
136
+ return walkFiles(rootPath)
137
+ .filter((file) => file.absolutePath.endsWith('.md'))
138
+ .map((file) => ({
139
+ ...file,
140
+ relativePath: toPosixPath(relative(workspacePath, file.absolutePath)),
141
+ }));
142
+ }
143
+
144
+ function formatBytes(bytes) {
145
+ const value = Number(bytes);
146
+ if (!Number.isFinite(value) || value <= 0) return '0 B';
147
+ if (value < 1024) return `${value} B`;
148
+ if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
149
+ return `${(value / 1024 / 1024).toFixed(1)} MB`;
150
+ }
151
+
152
+ function formatDate(value) {
153
+ if (!Number.isFinite(value) || value <= 0) return '-';
154
+ return new Date(value).toLocaleString();
155
+ }
156
+
157
+ function compactPath(value) {
158
+ const text = String(value ?? '');
159
+ if (!text || text === '-') return '-';
160
+ const normalized = toPosixPath(text).replace(/\/+$/g, '');
161
+ const parts = normalized.split('/').filter(Boolean);
162
+ if (parts.length <= 2) return normalized;
163
+ const prefix = normalized.startsWith('/') ? '/…' : '…';
164
+ return `${prefix}/${parts.slice(-2).join('/')}`;
165
+ }
166
+
167
+ function countIndexLinks(workspacePath) {
168
+ const indexPath = join(workspacePath, 'wiki', 'index.md');
169
+ if (!existsSync(indexPath)) return { exists: false, links: 0 };
170
+ try {
171
+ const raw = readFileSync(indexPath, 'utf8');
172
+ const markdownLinks = raw.match(/\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/g) ?? [];
173
+ const wikiLinks = raw.match(/\[\[[^\]]+\]\]/g) ?? [];
174
+ return { exists: true, links: markdownLinks.length + wikiLinks.length };
175
+ } catch {
176
+ return { exists: true, links: 0 };
177
+ }
178
+ }
179
+
180
+ function folderStats(files) {
181
+ const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
182
+ const latest = files.reduce((best, file) => (file.mtimeMs > (best?.mtimeMs ?? 0) ? file : best), null);
183
+ const largest = files.reduce((best, file) => (file.size > (best?.size ?? 0) ? file : best), null);
184
+ return { count: files.length, totalBytes, latest, largest };
185
+ }
186
+
187
+ function formatRecentFiles(files, limit = 3) {
188
+ const recent = [...files].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, limit);
189
+ if (recent.length === 0) return ' recent: -';
190
+ return [
191
+ ' recent:',
192
+ ...recent.map((file) => ` - ${file.relativePath} (${formatBytes(file.size)})`),
193
+ ].join('\n');
194
+ }
195
+
196
+ function collectWorkspaceStats(session) {
197
+ if (!session.workspacePath) return null;
198
+ const workspacePath = session.workspacePath;
199
+ const wiki = markdownFiles(workspacePath, 'wiki');
200
+ const concepts = markdownFiles(workspacePath, join('wiki', 'concepts'));
201
+ const sourceNotes = markdownFiles(workspacePath, join('wiki', 'sources'));
202
+ const answers = markdownFiles(workspacePath, join('wiki', 'answers'));
203
+ const untracked = markdownFiles(workspacePath, join('raw', 'untracked'));
204
+ const ingested = markdownFiles(workspacePath, join('raw', 'ingested'));
205
+ const templates = markdownFiles(workspacePath, 'templates');
206
+ const deliverables = markdownFiles(workspacePath, 'deliverables');
207
+ const logs = walkFiles(join(workspacePath, '.wiki', 'logs'));
208
+ const index = countIndexLinks(workspacePath);
209
+ return {
210
+ wiki: folderStats(wiki),
211
+ concepts: folderStats(concepts),
212
+ sourceNotes: folderStats(sourceNotes),
213
+ answers: folderStats(answers),
214
+ untracked: folderStats(untracked),
215
+ ingested: folderStats(ingested),
216
+ templates: folderStats(templates),
217
+ deliverables: folderStats(deliverables),
218
+ logs: folderStats(logs),
219
+ index,
220
+ untrackedFiles: untracked,
221
+ };
222
+ }
223
+
224
+ function statLine(label, stat) {
225
+ return `${label}: ${stat.count} (${formatBytes(stat.totalBytes)})`;
226
+ }
227
+
228
+ function workspaceStatsText(stats) {
229
+ if (!stats) return 'No workspace loaded.';
230
+ const hints = [];
231
+ if (stats.untracked.count > 0) {
232
+ hints.push(`${stats.untracked.count} raw/untracked document(s) are waiting for ingest.`);
233
+ }
234
+ if (!stats.index.exists) {
235
+ hints.push('wiki/index.md is missing.');
236
+ } else if (stats.index.links === 0) {
237
+ hints.push('wiki/index.md exists but has no markdown/wiki links.');
238
+ }
239
+ if (stats.wiki.count === 0) {
240
+ hints.push('The wiki has no markdown pages yet.');
241
+ }
242
+
243
+ const wikiLatest = formatDate(Math.max(
244
+ stats.wiki.latest?.mtimeMs ?? 0,
245
+ stats.concepts.latest?.mtimeMs ?? 0,
246
+ stats.sourceNotes.latest?.mtimeMs ?? 0,
247
+ stats.answers.latest?.mtimeMs ?? 0,
248
+ ));
249
+ const deliverablesLatest = formatDate(Math.max(
250
+ stats.templates.latest?.mtimeMs ?? 0,
251
+ stats.deliverables.latest?.mtimeMs ?? 0,
252
+ ));
253
+
254
+ const wikiColumn = sectionBlock(`Wiki content: ${wikiLatest}`, [
255
+ statLine('wiki pages', stats.wiki),
256
+ statLine('concepts', stats.concepts),
257
+ statLine('source notes', stats.sourceNotes),
258
+ statLine('answers', stats.answers),
259
+ `index: ${stats.index.exists ? 'ok' : 'missing'} (${stats.index.links} links)`,
260
+ ]);
261
+ const rawColumn = sectionBlock('Raw sources', [
262
+ statLine('untracked', stats.untracked),
263
+ statLine('ingested', stats.ingested),
264
+ stats.untracked.largest ? `largest: ${stats.untracked.largest.relativePath} (${formatBytes(stats.untracked.largest.size)})` : 'largest: -',
265
+ ...formatRecentFiles(stats.untrackedFiles).split('\n'),
266
+ ]);
267
+ const deliveryColumn = sectionBlock(`Deliverables: ${deliverablesLatest}`, [
268
+ statLine('templates', stats.templates),
269
+ statLine('deliverables', stats.deliverables),
270
+ ]);
271
+ const internalColumn = sectionBlock('Internal', [
272
+ `logs: ${stats.logs.count} (${formatBytes(stats.logs.totalBytes)})`,
273
+ ]);
274
+ const hintsColumn = sectionBlock('Hints', hints.length > 0 ? hints : ['No immediate content action detected.']);
275
+
276
+ return [
277
+ twoColumns(wikiColumn, rawColumn),
278
+ '',
279
+ twoColumns(deliveryColumn, `${internalColumn}\n\n${hintsColumn}`),
280
+ ].join('\n');
281
+ }
282
+
283
+ function workspaceLoadedText(workspace, summary, session) {
284
+ return [
285
+ `Workspace: ${workspace.name}`,
286
+ '',
287
+ `Path: ${workspace.workspacePath}`,
288
+ `Env: ${workspace.envFile}`,
289
+ '',
290
+ 'Active config',
291
+ '',
292
+ `profile: ${summary.profile}`,
293
+ `file: ${summary.fileName}`,
294
+ `language: ${summary.language ?? '-'}`,
295
+ `provider: ${summary.provider ?? '-'}`,
296
+ `model: ${summary.model ?? '-'}`,
297
+ `baseUrl: ${summary.baseUrl ?? '-'}`,
298
+ `apiKey: ${summary.hasApiKey ? 'configured' : 'missing'}`,
299
+ `vector: ${summary.vectorEnabled ? 'enabled' : 'disabled'}`,
300
+ `embedding: ${summary.embeddingModel ?? '-'}`,
301
+ '',
302
+ 'Session',
303
+ '',
304
+ `llm: ${session.llm ? 'configured' : 'missing config'}`,
305
+ `mcp: ${Object.values(session.mcp ?? {}).filter((value) => value.status === 'connected').length} connected`,
306
+ ].join('\n');
307
+ }
308
+
309
+ function workspaceLoadedWithoutConfigText(workspace, message) {
310
+ return [
311
+ `Workspace: ${workspace.name}`,
312
+ '',
313
+ `Path: ${workspace.workspacePath}`,
314
+ `Env: ${workspace.envFile}`,
315
+ '',
316
+ 'Active config',
317
+ '',
318
+ `Wikirc not loaded: ${message}`,
319
+ ].join('\n');
320
+ }
321
+
322
+ function serviceStatesText(states) {
323
+ const entries = Object.entries(states ?? {});
324
+ if (entries.length === 0) return 'No running compose services.';
325
+ return entries
326
+ .sort(([left], [right]) => left.localeCompare(right))
327
+ .map(([service, state]) => `- ${service}: ${state.running ? 'running' : state.state || 'unknown'}`)
328
+ .join('\n');
329
+ }
330
+
331
+ function mcpEndpointsText(mcpStatus) {
332
+ const entries = Object.entries(mcpStatus ?? {});
333
+ if (entries.length === 0) return 'No MCP endpoints configured.';
334
+ return entries
335
+ .map(([name, endpoint]) => {
336
+ const headerNames = Object.keys(endpoint.headers ?? {});
337
+ const auth = headerNames.length > 0
338
+ ? `headers: ${headerNames.join(',')}`
339
+ : `token: ${endpoint.token ? 'configured' : 'missing'}`;
340
+ const url = endpoint.url ?? '-';
341
+ return `${name}\t${url}\t${auth}\tstatus: ${endpoint.status}`;
342
+ })
343
+ .join('\n');
344
+ }
345
+
346
+ function skillsText(session) {
347
+ const skills = listSkills(session);
348
+ if (skills.length === 0) return 'No skills discovered.';
349
+ return skills
350
+ .map((skill) => {
351
+ const description = String(skill.description || 'workflow skill').replace(/\s+/g, ' ').trim();
352
+ const compact = description.length > 96 ? `${description.slice(0, 93)}...` : description;
353
+ return `${skill.name}\t${skill.scope}\t${compact}`;
354
+ })
355
+ .join('\n');
356
+ }
357
+
358
+ function skillDetailText(skill) {
359
+ return [
360
+ `# ${skill.name}`,
361
+ '',
362
+ `Scope: ${skill.scope}`,
363
+ `Path: ${skill.path}`,
364
+ skill.description ? `Description: ${skill.description}` : null,
365
+ skill.params?.length ? `Params: ${skill.params.join(', ')}` : null,
366
+ '',
367
+ skill.body || '_Empty skill body._',
368
+ ].filter(Boolean).join('\n');
369
+ }
370
+
371
+
372
+
373
+ function buildSkillRunPrompt(skill) {
374
+ return [
375
+ `Execute the "${skill.name}" skill for the current workspace.`,
376
+ 'Follow the workflow steps below. Call MCP tools and shell commands as needed for each step.',
377
+ 'Report progress as you go. Ask for confirmation before irreversible or costly actions not already defined in the skill.',
378
+ '',
379
+ skill.body || '',
380
+ ].filter(Boolean).join('\n');
381
+ }
382
+
383
+ function skillActionCommand(session, action, name) {
384
+ if (!name) {
385
+ const available = listSkills(session);
386
+ if (!available.length) return { output: `No skills available. Load a workspace with /use first.` };
387
+ const usage = `/skills ${action} <skill>`;
388
+ return { output: `Available skills: ${available.map((s) => s.name).join(', ')}\nUsage: ${usage}` };
389
+ }
390
+ const skill = findSkill(session, name);
391
+ if (!skill) {
392
+ const available = listSkills(session);
393
+ const hint = available.length ? ` Available: ${available.map((s) => s.name).join(', ')}` : '';
394
+ return { output: `Skill not found: ${name}.${hint}` };
395
+ }
396
+ if (action === 'run') {
397
+ return { output: `Skill: ${skill.name} — launching…`, agentTrigger: buildSkillRunPrompt(skill) };
398
+ }
399
+ return { output: skillDetailText(skill) };
400
+ }
401
+
402
+ function skillEditCommand(session, name) {
403
+ if (!name) {
404
+ const available = listSkills(session);
405
+ if (!available.length) return { output: 'No skills available. Load a workspace with /use first.' };
406
+ return { output: `Available skills: ${available.map((s) => s.name).join(', ')}\nUsage: /skills edit <skill>` };
407
+ }
408
+ const skill = findSkill(session, name);
409
+ if (!skill) {
410
+ const available = listSkills(session);
411
+ const hint = available.length ? ` Available: ${available.map((s) => s.name).join(', ')}` : '';
412
+ return { output: `Skill not found: ${name}.${hint}` };
413
+ }
414
+ const openEditor = session._onOpenEditor;
415
+ if (typeof openEditor !== 'function') {
416
+ return { output: `Edit file: ${skill.path}` };
417
+ }
418
+ const content = readFileSync(skill.path, 'utf8');
419
+ const displayPath = session.workspacePath ? relative(session.workspacePath, skill.path) : skill.path;
420
+ openEditor({
421
+ title: `Edit skill: ${skill.name}`,
422
+ filePath: skill.path,
423
+ displayPath,
424
+ content,
425
+ language: skill.path.endsWith('.yaml') || skill.path.endsWith('.yml') ? 'yaml' : 'markdown',
426
+ });
427
+ return { output: `Editing ${displayPath}` };
428
+ }
429
+
430
+ async function createWorkspaceCommand(context, workspaceName, targetPath) {
431
+ if (!workspaceName) {
432
+ return {
433
+ output: [
434
+ 'Usage: /new <name> [path]',
435
+ '',
436
+ 'Creates/configures a new workspace through wiki-workspace config.',
437
+ 'Legacy form: /workspace init <name> [path].',
438
+ 'For llm-wiki init inside the current workspace, use /wiki run init.',
439
+ ].join('\n'),
440
+ };
441
+ }
442
+ try {
443
+ context.onStep?.(`Workspace: creating ${workspaceName}…`);
444
+ const output = await createWorkspace(workspaceName, targetPath, { timeout: 600_000 });
445
+ return {
446
+ output: [
447
+ output,
448
+ '',
449
+ `Workspace created: ${workspaceName}`,
450
+ `Use /use ${workspaceName} to load it.`,
451
+ ].filter(Boolean).join('\n'),
452
+ };
453
+ } catch (err) {
454
+ const message = err instanceof Error ? err.message : String(err);
455
+ return { output: message };
456
+ }
457
+ }
458
+
459
+ function formatMcpCallActivity(serverName, toolName, resultText) {
460
+ if (serverName === 'production') return null;
461
+ return formatActivitySummary(serverName, toolName, resultText);
462
+ }
463
+
464
+ function publishPayloadActivity(session, payload, context = {}) {
465
+ const activity = extractActivity(payload, context);
466
+ if (!activity) return null;
467
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
468
+ origin: context.server ?? 'mcp',
469
+ payload: { activity },
470
+ }));
471
+ return formatActivityLine(activity);
472
+ }
473
+
474
+ function publishDocumentActivity(session, activity) {
475
+ if (!activity) return null;
476
+ return publishPayloadActivity(session, { _activity: activity }, { server: 'documents', tool: 'documents_convert_to_markdown' });
477
+ }
478
+
479
+ async function refreshMcpRuntimeStatus(session) {
480
+ session.mcp = buildMcpStatus(session);
481
+ if (!session.workspacePath) return null;
482
+ try {
483
+ const states = await serviceStates(session);
484
+ session.mcp = applyMcpRuntimeStatus(session.mcp, states);
485
+ session.mcp = await discoverMcpTools(session.mcp);
486
+ return states;
487
+ } catch {
488
+ session.mcp = await discoverMcpTools(session.mcp);
489
+ return null;
490
+ }
491
+ }
492
+
493
+ async function statusText(session) {
494
+ const states = await refreshMcpRuntimeStatus(session);
495
+ const services = session.workspacePath
496
+ ? await composeServices(session).catch(() => [])
497
+ : [];
498
+ const workspaceStats = collectWorkspaceStats(session);
499
+ const workspaceColumn = sectionBlock('Workspace', [
500
+ `workspace: ${session.workspace ?? '-'}`,
501
+ `path: ${compactPath(session.workspacePath ?? '-')}`,
502
+ `env: ${compactPath(session.workspaceEnvFile ?? '-')}`,
503
+ ]);
504
+ const configColumn = sectionBlock('Config', [
505
+ `wikirc: ${session.wikirc?.profile ?? '-'}${session.wikirc?.fileName ? ` (${session.wikirc.fileName})` : ''}`,
506
+ `language: ${session.language ?? '-'}`,
507
+ `llm: ${session.llm ? 'configured' : 'missing'}`,
508
+ `provider: ${session.wikircConfig?.llm?.provider ?? '-'}`,
509
+ `model: ${session.wikircConfig?.llm?.model ?? '-'}`,
510
+ `baseUrl: ${session.wikircConfig?.llm?.baseUrl ?? '-'}`,
511
+ ]);
512
+ const servicesColumn = sectionBlock('Services', services.length > 0
513
+ ? services.map((service) => `- ${service}`)
514
+ : ['No workspace loaded.']);
515
+ const runtimeColumn = sectionBlock('Runtime', (states ? serviceStatesText(states) : 'Docker runtime not available or no workspace loaded.').split('\n'));
516
+ const mcpColumn = sectionBlock('MCP', formatMcpStatus(session.mcp).split('\n'));
517
+ const mcpToolsColumn = sectionBlock('MCP tool summary', formatMcpToolSummary(session.mcp).split('\n'));
518
+
519
+ return [
520
+ twoColumns(workspaceColumn, configColumn),
521
+ '',
522
+ workspaceStatsText(workspaceStats),
523
+ '',
524
+ twoColumns(servicesColumn, runtimeColumn),
525
+ '',
526
+ twoColumns(mcpColumn, mcpToolsColumn),
527
+ ].join('\n');
528
+ }
529
+
530
+ function loadWorkspaceSystemPrompt(workspacePath) {
531
+ const promptPath = join(workspacePath, '.wiki', 'system-prompt.md');
532
+ return existsSync(promptPath) ? readFileSync(promptPath, 'utf8').trim() || null : null;
533
+ }
534
+
535
+ function loadSessionWikirc(session, profileName = 'default') {
536
+ if (!session.workspacePath) {
537
+ throw new Error('No workspace loaded. Use /use <workspace>.');
538
+ }
539
+ const loaded = loadWikircProfile(session.workspacePath, profileName);
540
+ session.wikirc = {
541
+ profile: loaded.profile.name,
542
+ fileName: loaded.profile.fileName,
543
+ path: loaded.profile.path,
544
+ };
545
+ session.wikircConfig = loaded.config;
546
+ session.language = loaded.config?.language ?? null;
547
+ session.llm = createLlmClientFromWikiConfig(loaded.config);
548
+ if (session.mcp?.production) {
549
+ session.mcp.production.activeConfigPath = loaded.profile.fileName;
550
+ }
551
+ return summarizeWikircConfig(loaded.profile, loaded.config);
552
+ }
553
+
554
+ export function helpText(packageJson) {
555
+ return `wiki-manager ${packageJson.version}
556
+
557
+ Agent-first shell and orchestration cockpit for llm-wiki workspaces.
558
+
559
+ Usage:
560
+ wiki-manager [options]
561
+
562
+ Options:
563
+ -v, --version Print version
564
+ -h, --help Print help
565
+ --once <prompt> Run one agent turn and exit
566
+ --headless Run a workspace task non-interactively
567
+ --workspace <name> Workspace for --headless
568
+ --skill <name> Skill to run in --headless (implies --wait)
569
+ --prompt <text> Task or extra instruction for --headless
570
+ --log-file <path> Optional headless log path
571
+ --wait Wait for active jobs to complete after agent turn (--prompt only)
572
+ --no-wait Disable agentic loop for --skill (single turn)
573
+ --timeout <seconds> Per-wave job wait timeout in seconds (default: 3600)
574
+ --max-turns <n> Max agent turns in agentic loop (default: 20)
575
+
576
+ Interactive shell:
577
+ ${helpPair('/help', 'Help', '/version', 'Version')}
578
+ ${helpPair('/workspaces', 'Workspaces', '/new <n> [path]', 'New workspace')}
579
+ ${helpPair('/use <workspace>', 'Use workspace', '/status', 'Session status')}
580
+ ${helpPair('/config list', 'Config profiles', '/config use <n>', 'Use config')}
581
+ ${helpPair('/config edit <n>', 'Edit config', '/config status', 'Active config')}
582
+ ${helpPair('/services', 'Services', '/start [service]', 'Start service(s)')}
583
+ ${helpPair('/stop [service]', 'Stop service(s)', '/logs <service>', 'Service logs')}
584
+ ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
585
+ ${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
586
+ ${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
587
+ ${helpPair('/mcp tools [mcp]', 'MCP tools', '/mcp call ...', 'Call MCP tool')}
588
+ ${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
589
+ ${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
590
+ ${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
591
+ ${helpPair('/chat', 'Chat mode', '/agent', 'Agent mode')}
592
+ ${helpPair('/openui', 'Open web UI in browser', '', '')}
593
+ ${helpPair('/queue', 'MCP job queue', '/queue clear', 'Clear finished')}
594
+ ${helpPair('/queue cancel <id>', 'Cancel queued/running', '', '')}
595
+ ${helpPair('/clear', 'Clear screen', '/exit', 'Exit')}
596
+ ${helpPair('Ctrl+Y', 'Copy last reply', '', '')}
597
+ ${helpPair('PgUp/PgDn', 'Scroll thread', 'Ctrl+C Ctrl+C', 'Exit')}
598
+
599
+ Modes:
600
+ Default startup mode is chat: free text is sent directly to the LLM without tools.
601
+ Use /agent to route free text to the LangGraph orchestrator with MCP tools.
602
+ Use /chat to return to direct LLM chat mode.
603
+
604
+ Status:
605
+ Agent-first shell is installed with workspace services, MCP calls, wiki CLI, skill discovery, and headless runs.
606
+ Shell UI is English. Agent exchange language is read from the active .wikirc.yaml.
607
+ LLM config is intentionally workspace-scoped and will be read from .wikirc.yaml after /use <workspace>.
608
+ Headless mode supports one-shot workspace prompts and skill runs with log output.
609
+ `;
610
+ }
611
+
612
+ export function printHelp(packageJson) {
613
+ console.log(helpText(packageJson));
614
+ }
615
+
616
+ export async function handleSlashCommand(line, context) {
617
+ const args = line.slice(1).trim().split(/\s+/).filter(Boolean);
618
+ const [command] = args;
619
+ const step = context.onStep ?? (() => {});
620
+
621
+ switch (command) {
622
+ case '':
623
+ case 'help':
624
+ return { output: helpText(context.packageJson) };
625
+ case 'version':
626
+ return { output: context.packageJson.version };
627
+ case 'chat':
628
+ context.session.chatMode = true;
629
+ return { setMode: 'chat', output: 'Mode: chat' };
630
+ case 'agent':
631
+ context.session.chatMode = false;
632
+ return { setMode: 'agent', output: 'Mode: agent' };
633
+ case 'workspaces': {
634
+ const workspaces = listWorkspaces();
635
+ if (workspaces.length === 0) {
636
+ return { output: 'No workspace configured.' };
637
+ }
638
+ return {
639
+ output: workspaces
640
+ .map((workspace) => `${workspace.name}\t${workspace.workspacePath}`)
641
+ .join('\n'),
642
+ };
643
+ }
644
+ case 'status': {
645
+ step('Shell: refreshing workspace, services and MCP status…');
646
+ return { output: await statusText(context.session) };
647
+ }
648
+ case 'use': {
649
+ const workspaceName = args[1];
650
+ if (!workspaceName) {
651
+ return { output: 'Usage: /use <workspace>' };
652
+ }
653
+ const workspace = findWorkspace(workspaceName);
654
+ if (!workspace) {
655
+ return { output: `Workspace not found: ${workspaceName}` };
656
+ }
657
+ context.session.workspace = workspace.name;
658
+ context.session.workspacePath = workspace.workspacePath;
659
+ context.session.workspaceEnv = workspace.env;
660
+ context.session.workspaceEnvFile = workspace.envFile;
661
+ context.session.mcp = buildMcpStatus(context.session);
662
+ context.session.systemPrompt = loadWorkspaceSystemPrompt(workspace.workspacePath);
663
+ try {
664
+ step(`Workspace: loading ${workspace.name} config…`);
665
+ const summary = loadSessionWikirc(context.session, 'default');
666
+ step(`Workspace: discovering ${workspace.name} MCP tools…`);
667
+ await refreshMcpRuntimeStatus(context.session);
668
+ return {
669
+ output: workspaceLoadedText(workspace, summary, context.session),
670
+ };
671
+ } catch (err) {
672
+ const message = err instanceof Error ? err.message : String(err);
673
+ return {
674
+ output: workspaceLoadedWithoutConfigText(workspace, message),
675
+ };
676
+ }
677
+ }
678
+ case 'config': {
679
+ const subcommand = args[1] ?? 'status';
680
+ if (!context.session.workspacePath) {
681
+ return { output: 'No workspace loaded. Use /use <workspace>.' };
682
+ }
683
+ if (subcommand === 'list') {
684
+ const profiles = listWikircProfiles(context.session.workspacePath);
685
+ if (profiles.length === 0) {
686
+ return { output: 'No .wikirc.yaml profile found in the workspace.' };
687
+ }
688
+ const active = context.session.wikirc?.profile;
689
+ return {
690
+ output: profiles
691
+ .map((profile) => {
692
+ const marker = profile.name === active ? '*' : ' ';
693
+ return `${marker} ${profile.name}\t${profile.fileName}`;
694
+ })
695
+ .join('\n'),
696
+ };
697
+ }
698
+ if (subcommand === 'use') {
699
+ const profileName = args[2];
700
+ if (!profileName) {
701
+ return { output: 'Usage: /config use <default|name>' };
702
+ }
703
+ try {
704
+ const summary = loadSessionWikirc(context.session, profileName);
705
+ return {
706
+ output: [
707
+ 'Active wikirc:',
708
+ wikircSummaryText(summary),
709
+ context.session.llm ? 'LLM session: reinitialized' : 'LLM session: missing config',
710
+ ].join('\n'),
711
+ };
712
+ } catch (err) {
713
+ const message = err instanceof Error ? err.message : String(err);
714
+ return { output: message };
715
+ }
716
+ }
717
+ if (subcommand === 'edit') {
718
+ const profileName = args[2];
719
+ if (!profileName) {
720
+ const profiles = listWikircProfiles(context.session.workspacePath);
721
+ const available = profiles.map((profile) => profile.name).join(', ') || 'none';
722
+ return { output: `Usage: /config edit <profile>\nAvailable profiles: ${available}` };
723
+ }
724
+ try {
725
+ const profile = resolveWikircProfile(context.session.workspacePath, profileName);
726
+ const content = readFileSync(profile.path, 'utf8');
727
+ const openEditor = context.session._onOpenEditor;
728
+ if (typeof openEditor !== 'function') {
729
+ return { output: `Edit file: ${profile.path}` };
730
+ }
731
+ openEditor({
732
+ title: `Edit wikirc: ${profile.name}`,
733
+ filePath: profile.path,
734
+ displayPath: profile.fileName,
735
+ content,
736
+ language: 'yaml',
737
+ });
738
+ return { output: `Editing ${profile.fileName}` };
739
+ } catch (err) {
740
+ const message = err instanceof Error ? err.message : String(err);
741
+ return { output: message };
742
+ }
743
+ }
744
+ if (subcommand === 'status') {
745
+ if (!context.session.wikirc || !context.session.wikircConfig) {
746
+ return { output: 'No active wikirc profile.' };
747
+ }
748
+ const summary = {
749
+ ...summarizeWikircConfig(
750
+ {
751
+ name: context.session.wikirc.profile,
752
+ path: context.session.wikirc.path,
753
+ },
754
+ context.session.wikircConfig,
755
+ ),
756
+ fileName: context.session.wikirc.fileName,
757
+ };
758
+ return { output: wikircSummaryText(summary) };
759
+ }
760
+ return { output: 'Usage: /config <list|use|edit|status>' };
761
+ }
762
+ case 'services': {
763
+ try {
764
+ step('Services: reading compose state…');
765
+ await refreshMcpRuntimeStatus(context.session);
766
+ return { output: await listServices(context.session) };
767
+ } catch (err) {
768
+ const message = err instanceof Error ? err.message : String(err);
769
+ step(formatActivityError('services', 'list', err));
770
+ return { output: message };
771
+ }
772
+ }
773
+ case 'start': {
774
+ const service = args[1];
775
+ try {
776
+ step(`Services: starting ${service ?? 'workspace services'}…`);
777
+ const output = await startService(context.session, service);
778
+ step('Services: refreshing MCP runtime…');
779
+ await refreshMcpRuntimeStatus(context.session);
780
+ return { output };
781
+ } catch (err) {
782
+ const message = err instanceof Error ? err.message : String(err);
783
+ step(formatActivityError('services', 'stop', err));
784
+ return { output: message };
785
+ }
786
+ }
787
+ case 'stop': {
788
+ const service = args[1];
789
+ try {
790
+ step(`Services: stopping ${service ?? 'workspace services'}…`);
791
+ const output = await stopService(context.session, service);
792
+ step('Services: refreshing MCP runtime…');
793
+ await refreshMcpRuntimeStatus(context.session);
794
+ return { output };
795
+ } catch (err) {
796
+ const message = err instanceof Error ? err.message : String(err);
797
+ step(formatActivityError('services', 'logs', err));
798
+ return { output: message };
799
+ }
800
+ }
801
+ case 'logs': {
802
+ const service = args[1];
803
+ const tail = args[2] ? Number(args[2]) : 120;
804
+ try {
805
+ step(`Services: reading logs for ${service ?? 'service'}…`);
806
+ return { output: await serviceLogs(context.session, service, { tail }) };
807
+ } catch (err) {
808
+ const message = err instanceof Error ? err.message : String(err);
809
+ return { output: message };
810
+ }
811
+ }
812
+ case 'mcp': {
813
+ const subcommand = args[1] ?? 'status';
814
+ step('MCP: refreshing endpoints and tools…');
815
+ await refreshMcpRuntimeStatus(context.session);
816
+ if (subcommand === 'status') {
817
+ return { output: formatMcpStatus(context.session.mcp) };
818
+ }
819
+ if (subcommand === 'endpoints') {
820
+ return { output: mcpEndpointsText(context.session.mcp) };
821
+ }
822
+ if (subcommand === 'tools') {
823
+ const filterName = args[2] ?? null;
824
+ if (filterName && !context.session.mcp?.[filterName]) {
825
+ return { output: `Unknown MCP: ${filterName}` };
826
+ }
827
+ return { output: formatMcpTools(context.session.mcp, filterName) };
828
+ }
829
+ if (subcommand === 'call') {
830
+ const serverName = args[2];
831
+ const toolName = args[3];
832
+ if (!serverName || !toolName) {
833
+ return { output: 'Usage: /mcp call <mcp> <tool> [json]' };
834
+ }
835
+ try {
836
+ const rawArgs = args.slice(4).join(' ');
837
+ let toolArgs = rawArgs ? JSON.parse(rawArgs) : {};
838
+ if (serverName === 'production' && toolName === 'production_start_job' && context.session.workspace && !toolArgs.callerLabel) {
839
+ toolArgs = { ...toolArgs, callerLabel: `${context.session.workspace}/wiki-manager` };
840
+ }
841
+ if (serverName === 'production' && toolName === 'production_start_job' && productionLockBusy(context.session)) {
842
+ const item = enqueueProductionJob(context.session, toolArgs, 'production lock busy');
843
+ return { output: `Queued ${item.id}: waiting ${item.workspace ?? 'no-workspace'} ${item.tool}` };
844
+ }
845
+ step(`MCP: calling ${serverName}.${toolName}…`);
846
+ const result = await callMcpTool(context.session.mcp, serverName, toolName, toolArgs);
847
+ const output = formatMcpToolResult(result);
848
+ const payload = parseJsonText(output);
849
+ if (serverName === 'production' && toolName === 'production_start_job' && payload?.ok === false && payload?.error === 'workspace_busy') {
850
+ const item = enqueueProductionJob(context.session, toolArgs, 'workspace_busy');
851
+ return { output: `Queued ${item.id}: waiting for production lock (${payload.activeJobId ?? 'active job'})` };
852
+ }
853
+ const activity = formatMcpCallActivity(serverName, toolName, output);
854
+ if (activity) step(activity);
855
+ return { output };
856
+ } catch (err) {
857
+ const message = err instanceof Error ? err.message : String(err);
858
+ step(formatActivityError(serverName, toolName, err));
859
+ return { output: message };
860
+ }
861
+ }
862
+ return { output: 'Usage: /mcp <status|endpoints|tools|call> [mcp]' };
863
+ }
864
+ case 'queue': {
865
+ const subcommand = args[1] ?? 'list';
866
+ if (subcommand === 'list') return { output: formatQueue(context.session) };
867
+ if (subcommand === 'clear') {
868
+ const count = clearFinishedQueueItems(context.session);
869
+ return { output: `Cleared ${count} finished queue item${count === 1 ? '' : 's'}.` };
870
+ }
871
+ if (subcommand === 'cancel') {
872
+ const id = args[2];
873
+ if (!id) return { output: 'Usage: /queue cancel <id>' };
874
+ const result = await cancelQueueItem(context.session, id);
875
+ return { output: result.message };
876
+ }
877
+ return { output: 'Usage: /queue [list|clear|cancel <id>]' };
878
+ }
879
+ case 'upload': {
880
+ const rest = line.replace(/^\/upload(?:\s+|$)/, '').trim();
881
+ if (!rest) return { output: 'Usage: /upload <path>\n /upload convert <id|pending>' };
882
+ if (rest.startsWith('convert ')) {
883
+ try {
884
+ const target = rest.replace(/^convert\s+/, '').trim();
885
+ if (!target) return { output: 'Usage: /upload convert <id|pending>' };
886
+ step('Documents: refreshing MCP status…');
887
+ await refreshMcpRuntimeStatus(context.session);
888
+ if (target === 'pending') {
889
+ step('Documents: converting pending uploads…');
890
+ const results = await convertPendingDocumentUploads(context.session);
891
+ if (results.length === 0) return { output: 'No pending document upload.' };
892
+ for (const result of results) {
893
+ const activityLine = publishDocumentActivity(context.session, result.activity);
894
+ if (activityLine) step(activityLine);
895
+ }
896
+ return {
897
+ output: results.map(({ record }) => formatUploadRecord(record)).join('\n\n'),
898
+ };
899
+ }
900
+ step(`Documents: converting upload ${target}…`);
901
+ const { record, activity } = await convertStoredDocument(context.session, target);
902
+ const activityLine = publishDocumentActivity(context.session, activity);
903
+ if (activityLine) step(activityLine);
904
+ return { output: formatUploadRecord(record) };
905
+ } catch (err) {
906
+ const message = err instanceof Error ? err.message : String(err);
907
+ step(formatActivityError('documents', 'convert', err));
908
+ return { output: message };
909
+ }
910
+ }
911
+ try {
912
+ step('Documents: storing upload…');
913
+ await refreshMcpRuntimeStatus(context.session);
914
+ step('Documents: converting with documents MCP when available…');
915
+ const { record, activity, converted } = await storeAndMaybeConvertDocument(context.session, rest);
916
+ const activityLine = publishDocumentActivity(context.session, activity);
917
+ if (activityLine) step(activityLine);
918
+ const note = converted === false && activity && !activity.terminal
919
+ ? '\nConversion en cours — suivez la progression dans le panneau Plan.'
920
+ : '';
921
+ return { output: formatUploadRecord(record) + note };
922
+ } catch (err) {
923
+ const message = err instanceof Error ? err.message : String(err);
924
+ step(formatActivityError('documents', 'upload', err));
925
+ return { output: message };
926
+ }
927
+ }
928
+ case 'uploads': {
929
+ try {
930
+ if (args[1] === 'clean') {
931
+ const flagIndex = args.indexOf('--older-than');
932
+ const olderThan = flagIndex !== -1 ? args[flagIndex + 1] : '30d';
933
+ const result = await cleanDocumentUploads(context.session, olderThan);
934
+ return {
935
+ output: `Removed ${result.removed.length} upload record${result.removed.length === 1 ? '' : 's'} older than ${olderThan}.`,
936
+ };
937
+ }
938
+ if (args[1] && args[1] !== 'list') {
939
+ return { output: 'Usage: /uploads [list]\n /uploads clean [--older-than 30d]' };
940
+ }
941
+ const uploads = await listDocumentUploads(context.session);
942
+ if (uploads.length === 0) return { output: 'No document uploads for this workspace.' };
943
+ return { output: uploads.map(formatUploadRecord).join('\n\n') };
944
+ } catch (err) {
945
+ const message = err instanceof Error ? err.message : String(err);
946
+ return { output: message };
947
+ }
948
+ }
949
+ case 'new': {
950
+ return createWorkspaceCommand(context, args[1], args[2] ?? null);
951
+ }
952
+ case 'workspace': {
953
+ const subcommand = args[1];
954
+ if (subcommand !== 'init') {
955
+ return { output: 'Usage: /new <name> [path]\nLegacy: /workspace init <name> [path]' };
956
+ }
957
+ return createWorkspaceCommand(context, args[2], args[3] ?? null);
958
+ }
959
+ case 'wiki': {
960
+ const subcommand = args[1];
961
+ if (!subcommand) {
962
+ try {
963
+ step('Wiki: running index…');
964
+ const output = await runWikiCli(context.session, ['index'], {
965
+ timeout: 600_000,
966
+ onOutput: (line) => step(`Wiki: ${line}`),
967
+ });
968
+ const activity = formatActivitySummary('wiki', 'index', output);
969
+ if (activity) step(activity);
970
+ return { output };
971
+ } catch (err) {
972
+ const message = err instanceof Error ? err.message : String(err);
973
+ step(formatActivityError('wiki', 'index', err));
974
+ return { output: message };
975
+ }
976
+ }
977
+ try {
978
+ if (subcommand === 'run') {
979
+ const wikiArgs = args.slice(2);
980
+ if (wikiArgs.length === 0) return { output: 'Usage: /wiki run <args...>' };
981
+ step(`Wiki: running ${wikiArgs.join(' ')}…`);
982
+ const output = await runWikiCli(context.session, wikiArgs, {
983
+ onOutput: (line) => step(`Wiki: ${line}`),
984
+ });
985
+ const activity = formatActivitySummary('wiki', wikiArgs[0] ?? 'run', output);
986
+ if (activity) step(activity);
987
+ return { output };
988
+ }
989
+ return {
990
+ output: [
991
+ `/${command} ${subcommand} is not a direct shell primitive.`,
992
+ subcommand === 'init' ? 'Use /workspace init <name> [path] to create a new workspace, or /wiki run init for the explicit current-workspace init hatch.' : null,
993
+ subcommand === 'index' ? 'Use /wiki for index, or /wiki run index for the explicit backup hatch.' : null,
994
+ 'Use the MCP production agent for ingest/build/export/polish/pipeline actions.',
995
+ 'Diagnostics stay behind the explicit hatch: /wiki run doctor.',
996
+ ].filter(Boolean).join('\n'),
997
+ };
998
+ } catch (err) {
999
+ const message = err instanceof Error ? err.message : String(err);
1000
+ step(formatActivityError('wiki', subcommand ?? 'run', err));
1001
+ return { output: message };
1002
+ }
1003
+ }
1004
+ case 'skills': {
1005
+ if (args[1] === 'show') {
1006
+ return skillActionCommand(context.session, 'show', args[2]);
1007
+ }
1008
+ if (args[1] === 'run') {
1009
+ return skillActionCommand(context.session, 'run', args[2]);
1010
+ }
1011
+ if (args[1] === 'edit') {
1012
+ return skillEditCommand(context.session, args[2]);
1013
+ }
1014
+ if (args[1] && args[1] !== 'list') {
1015
+ return { output: 'Usage: /skills [list|show|run|edit] [skill]' };
1016
+ }
1017
+ return { output: skillsText(context.session) };
1018
+ }
1019
+ case 'openui': {
1020
+ const port = context.session.workspaceEnv?.WIKI_SERVE_PORT ?? '3100';
1021
+ const url = `http://localhost:${port}`;
1022
+ const note = context.session.workspaceEnv ? '' : ' (no workspace loaded — using default port)';
1023
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
1024
+ try {
1025
+ execFileSync(opener, [url], { stdio: 'ignore' });
1026
+ return { output: `Opening web UI: ${url}${note}` };
1027
+ } catch {
1028
+ return { output: `Web UI: ${url}${note}` };
1029
+ }
1030
+ }
1031
+ case 'clear': {
1032
+ const key = context.session.workspace || '__global__';
1033
+ context.session.conversations[key] = [];
1034
+ return { output: null };
1035
+ }
1036
+ case 'exit':
1037
+ case 'quit':
1038
+ return { exit: true };
1039
+ default:
1040
+ return {
1041
+ output: `Unknown command: /${command}\nUse /help to see available commands.`,
1042
+ };
1043
+ }
1044
+ }