@dotdrelle/wiki-manager 0.15.28 → 0.15.32
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/.env.example +5 -2
- package/README.md +5 -4
- package/agents.docker-compose.override.example.yml +1 -1
- package/agents.docker-compose.yml +10 -0
- package/bin/wiki-manager.js +1 -1
- package/docker-compose.override.example.yml +3 -2
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +77 -13
- package/src/agent/graph.test.js +165 -8
- package/src/cli/runtimeStartup.test.js +14 -0
- package/src/cli/wiki-manager.js +118 -19
- package/src/cli/wiki-manager.test.js +192 -0
- package/src/commands/slash.js +168 -39
- package/src/commands/slash.test.js +100 -1
- package/src/core/agentsCompose.js +79 -0
- package/src/core/agentsCompose.test.js +99 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/composeOverrides.test.js +17 -3
- package/src/core/env.js +27 -10
- package/src/core/mcp.js +113 -38
- package/src/core/mcp.test.js +181 -20
- package/src/core/startupCheck.js +64 -27
- package/src/core/startupCheck.test.js +49 -1
- package/src/core/wikiSetup.js +57 -22
- package/src/core/wikiSetup.test.js +87 -0
- package/src/core/wikiWorkspace.test.js +16 -0
- package/src/orchestrator/agentRegistry.js +35 -7
- package/src/orchestrator/agentRegistry.test.js +73 -0
- package/src/orchestrator/dispatcher.js +6 -1
- package/src/orchestrator/dispatcher.test.js +24 -1
- package/src/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +27 -0
- package/src/runtime/auth.test.js +1 -65
- package/src/runtime/donna-contract.test.js +2 -1
- package/src/runtime/lifecycle.js +0 -33
- package/src/runtime/runner.test.js +10 -1
- package/src/runtime/supervisor.test.js +11 -1
- package/src/shell/LeftPane.tsx +49 -24
- package/src/shell/repl.js +114 -21
- package/src/shell/repl.test.js +88 -17
- package/src/shell/tui.tsx +10 -19
- package/wiki-workspace +98 -12
|
@@ -64,6 +64,33 @@ test('resolveObjective uses an unambiguously mentioned registry operation withou
|
|
|
64
64
|
assert.equal(result.provider.agentInstanceId, 'production-1');
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
test('resolveObjective declines an objective no listed capability covers', async () => {
|
|
68
|
+
// Forcing a pick is how "cree l'auth pour le gmail" became a doctor run on
|
|
69
|
+
// the production agent. Declining must be reported as such, not as a defect.
|
|
70
|
+
const session = sessionWithSelection({ capability: null, reason: 'Gmail authorization is not an orchestrable capability.' });
|
|
71
|
+
await assert.rejects(
|
|
72
|
+
resolveObjective("cree l'auth pour le gmail", session),
|
|
73
|
+
(err) => {
|
|
74
|
+
assert.equal(err.name, 'ObjectiveNotOrchestrableError');
|
|
75
|
+
assert.match(err.message, /No connected agent can do that\./);
|
|
76
|
+
assert.match(err.message, /Available capabilities: knowledge\.update\./);
|
|
77
|
+
assert.deepEqual(err.candidates, ['knowledge.update']);
|
|
78
|
+
return true;
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('resolveObjective treats a malformed selection as a resolution defect, not a decline', async () => {
|
|
84
|
+
await assert.rejects(
|
|
85
|
+
resolveObjective('Traite tout', sessionWithSelection({ reason: 'missing capability' })),
|
|
86
|
+
(err) => {
|
|
87
|
+
assert.notEqual(err.name, 'ObjectiveNotOrchestrableError');
|
|
88
|
+
assert.match(err.message, /unknown capability/);
|
|
89
|
+
return true;
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
67
94
|
test('resolveObjective rejects invented capability and operation', async () => {
|
|
68
95
|
await assert.rejects(
|
|
69
96
|
resolveObjective('Traite tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
|
package/src/runtime/auth.test.js
CHANGED
|
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import { resolveRuntimeAuthToken } from './auth.js';
|
|
7
|
-
import { assertRuntimeNode, runtimeNodeExecutable
|
|
7
|
+
import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
|
|
8
8
|
|
|
9
9
|
test('resolveRuntimeAuthToken: loopback host does not require token', () => {
|
|
10
10
|
const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
|
|
@@ -27,67 +27,3 @@ test('runtime lifecycle uses a Node executable with node:sqlite support', async
|
|
|
27
27
|
assert.ok(runtimeNode.executable);
|
|
28
28
|
assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
|
|
29
29
|
});
|
|
30
|
-
|
|
31
|
-
test('shutdownOwnedRuntime reports progress and stops an idle owned runtime', async () => {
|
|
32
|
-
const originalFetch = globalThis.fetch;
|
|
33
|
-
const calls = [];
|
|
34
|
-
const logs = [];
|
|
35
|
-
globalThis.fetch = async (url, options = {}) => {
|
|
36
|
-
calls.push({ url: String(url), method: options.method ?? 'GET' });
|
|
37
|
-
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
38
|
-
status: calls.length === 1 ? 200 : 202,
|
|
39
|
-
headers: { 'content-type': 'application/json' },
|
|
40
|
-
});
|
|
41
|
-
};
|
|
42
|
-
try {
|
|
43
|
-
const result = await shutdownOwnedRuntime(
|
|
44
|
-
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
45
|
-
{ log: (message) => logs.push(message), timeoutMs: 100 },
|
|
46
|
-
);
|
|
47
|
-
assert.equal(result.action, 'shutdown');
|
|
48
|
-
assert.deepEqual(calls.map((call) => call.method), ['GET', 'POST']);
|
|
49
|
-
assert.match(logs.join('\n'), /runtime arrêté/);
|
|
50
|
-
} finally {
|
|
51
|
-
globalThis.fetch = originalFetch;
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('shutdownOwnedRuntime also stops an idle reused runtime', async () => {
|
|
56
|
-
const originalFetch = globalThis.fetch;
|
|
57
|
-
const calls = [];
|
|
58
|
-
globalThis.fetch = async (_url, options = {}) => {
|
|
59
|
-
calls.push(options.method ?? 'GET');
|
|
60
|
-
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
61
|
-
status: calls.length === 1 ? 200 : 202,
|
|
62
|
-
headers: { 'content-type': 'application/json' },
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
try {
|
|
66
|
-
const result = await shutdownOwnedRuntime(
|
|
67
|
-
{ url: 'http://127.0.0.1:7788', started: false, token: null },
|
|
68
|
-
{ timeoutMs: 100 },
|
|
69
|
-
);
|
|
70
|
-
assert.equal(result.action, 'shutdown');
|
|
71
|
-
assert.deepEqual(calls, ['GET', 'POST']);
|
|
72
|
-
} finally {
|
|
73
|
-
globalThis.fetch = originalFetch;
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('shutdownOwnedRuntime bounds an unresponsive shutdown', async () => {
|
|
78
|
-
const originalFetch = globalThis.fetch;
|
|
79
|
-
const logs = [];
|
|
80
|
-
globalThis.fetch = async (_url, { signal } = {}) => new Promise((_resolve, reject) => {
|
|
81
|
-
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
|
|
82
|
-
});
|
|
83
|
-
try {
|
|
84
|
-
const result = await shutdownOwnedRuntime(
|
|
85
|
-
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
86
|
-
{ log: (message) => logs.push(message), timeoutMs: 5 },
|
|
87
|
-
);
|
|
88
|
-
assert.equal(result.action, 'timeout');
|
|
89
|
-
assert.match(logs.join('\n'), /délai de fermeture/);
|
|
90
|
-
} finally {
|
|
91
|
-
globalThis.fetch = originalFetch;
|
|
92
|
-
}
|
|
93
|
-
});
|
|
@@ -146,7 +146,8 @@ test('CME setup and source configuration stay outside export orchestration', asy
|
|
|
146
146
|
const calledTools = [];
|
|
147
147
|
globalThis.fetch = async (_url, options = {}) => {
|
|
148
148
|
const body = JSON.parse(String(options.body ?? '{}'));
|
|
149
|
-
|
|
149
|
+
// Record tool calls only — the MCP session handshake is not a tool call.
|
|
150
|
+
if (body.method === 'tools/call') calledTools.push(body.params?.name);
|
|
150
151
|
return {
|
|
151
152
|
ok: true,
|
|
152
153
|
status: 200,
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -139,36 +139,3 @@ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = run
|
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
|
|
143
|
-
// Close the runtime attached to the shell when it is idle, including a process
|
|
144
|
-
// reused at startup. Restricting cleanup to `started: true` left ownerless
|
|
145
|
-
// runtimes occupying port 7788 with another manager directory/token.
|
|
146
|
-
// An active run still survives the shell.
|
|
147
|
-
export async function shutdownOwnedRuntime(runtime, { log = (_message) => {}, timeoutMs = 3000 } = {}) {
|
|
148
|
-
if (!runtime?.url) return { action: 'kept', reason: 'unavailable' };
|
|
149
|
-
const controller = new AbortController();
|
|
150
|
-
const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 3000));
|
|
151
|
-
try {
|
|
152
|
-
const health = await runtimeHealthOrNull(runtime.url, runtime.token, controller.signal);
|
|
153
|
-
if (!health) return { action: 'kept', reason: 'unreachable' };
|
|
154
|
-
const activeRuns = Array.isArray(health.activeRuns) ? health.activeRuns : [];
|
|
155
|
-
if (activeRuns.length > 0) {
|
|
156
|
-
const labels = activeRuns
|
|
157
|
-
.map((run) => [run.workspace, run.runId ? String(run.runId).slice(0, 8) : null].filter(Boolean).join('/'))
|
|
158
|
-
.join(', ');
|
|
159
|
-
log(`runtime laissé actif : run en cours (${labels}) — il survivra à ce shell ; relance wiki-manager pour le retrouver.`);
|
|
160
|
-
return { action: 'kept', reason: 'run_active', activeRuns };
|
|
161
|
-
}
|
|
162
|
-
await postRuntimeShutdown({ url: runtime.url, token: runtime.token, signal: controller.signal });
|
|
163
|
-
log('runtime arrêté (aucun run en cours).');
|
|
164
|
-
return { action: 'shutdown' };
|
|
165
|
-
} catch (err) {
|
|
166
|
-
if (controller.signal.aborted) {
|
|
167
|
-
log('délai de fermeture du runtime dépassé — le shell termine sans attendre davantage.');
|
|
168
|
-
return { action: 'timeout', reason: 'shutdown_timeout' };
|
|
169
|
-
}
|
|
170
|
-
return { action: 'error', reason: err instanceof Error ? err.message : String(err) };
|
|
171
|
-
} finally {
|
|
172
|
-
clearTimeout(timeout);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
@@ -401,7 +401,16 @@ test('replanRuntimeRun requires runtime approval for mutating replanned work', a
|
|
|
401
401
|
test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
|
|
402
402
|
const originalFetch = globalThis.fetch;
|
|
403
403
|
let pollAttempts = 0;
|
|
404
|
-
globalThis.fetch = async () => {
|
|
404
|
+
globalThis.fetch = async (_url, init) => {
|
|
405
|
+
// Count polls, not the one-off MCP session handshake.
|
|
406
|
+
if (JSON.parse(init.body).method === 'initialize') {
|
|
407
|
+
return {
|
|
408
|
+
ok: true,
|
|
409
|
+
status: 200,
|
|
410
|
+
headers: { get: () => null },
|
|
411
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
412
|
+
};
|
|
413
|
+
}
|
|
405
414
|
pollAttempts += 1;
|
|
406
415
|
return {
|
|
407
416
|
ok: true,
|
|
@@ -93,7 +93,17 @@ test('pollActivitiesOnce does not repeat an unchanged retry state when retryAt m
|
|
|
93
93
|
test('pollActivitiesOnce retries transient MCP poll failures', async () => {
|
|
94
94
|
const originalFetch = globalThis.fetch;
|
|
95
95
|
let attempts = 0;
|
|
96
|
-
globalThis.fetch = async () => {
|
|
96
|
+
globalThis.fetch = async (_url, init) => {
|
|
97
|
+
// The session handshake is not the thing under test: answer it, and make
|
|
98
|
+
// the first *poll* the transient failure.
|
|
99
|
+
if (JSON.parse(init.body).method === 'initialize') {
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
status: 200,
|
|
103
|
+
headers: { get: () => null },
|
|
104
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
97
107
|
attempts += 1;
|
|
98
108
|
if (attempts === 1) {
|
|
99
109
|
return {
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -77,8 +77,6 @@ type RenderedLine = {
|
|
|
77
77
|
statusLeft?: string;
|
|
78
78
|
statusRight?: string;
|
|
79
79
|
copyContent?: string;
|
|
80
|
-
redoContent?: string;
|
|
81
|
-
redoIndex?: number;
|
|
82
80
|
};
|
|
83
81
|
const STATUS_COLUMN_GAP = 2;
|
|
84
82
|
type HelpCard = { title: string; text: string; example: string };
|
|
@@ -142,14 +140,11 @@ function WelcomeHelpPanels(props: { width: number }) {
|
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
const COPY_BTN = ' [ copy ]';
|
|
145
|
-
// Only user messages carry it: redo re-asks *this* question, so the action has
|
|
146
|
-
// to sit on the message it acts upon.
|
|
147
|
-
const REDO_BTN = ' [ redo ]';
|
|
148
143
|
|
|
149
|
-
function messageHeaderSegments(role: string, columns: number
|
|
144
|
+
function messageHeaderSegments(role: string, columns: number): Segment[] {
|
|
150
145
|
const label = `[${roleLabel(role)}]`;
|
|
151
146
|
const left = '── ';
|
|
152
|
-
const actionsLength = COPY_BTN.length
|
|
147
|
+
const actionsLength = COPY_BTN.length;
|
|
153
148
|
const rightLength = Math.max(2, columns - left.length - label.length - 1 - actionsLength);
|
|
154
149
|
return [
|
|
155
150
|
{ text: left, color: '#4B5563' },
|
|
@@ -370,6 +365,13 @@ function statusValueColor(value: string) {
|
|
|
370
365
|
function statusSegments(value: string): Segment[] {
|
|
371
366
|
const text = value || ' ';
|
|
372
367
|
const trimmed = text.trim();
|
|
368
|
+
const linkedValue = text.match(/^(\s*[A-Za-z][A-Za-z0-9 _./-]{0,34}:\s*)\[([^\]]+)\]\((https?:\/\/[^)]+)\)\s*$/);
|
|
369
|
+
if (linkedValue) {
|
|
370
|
+
return [
|
|
371
|
+
{ text: linkedValue[1], color: '#8BD5CA' },
|
|
372
|
+
{ text: linkedValue[2], color: '#5DADE2', url: linkedValue[3] },
|
|
373
|
+
];
|
|
374
|
+
}
|
|
373
375
|
if (trimmed && !text.startsWith(' ') && !trimmed.includes(':') && !/^[●◐○-]/.test(trimmed)) {
|
|
374
376
|
return [{ text, color: '#FBBF24' }];
|
|
375
377
|
}
|
|
@@ -416,13 +418,11 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
|
|
|
416
418
|
const raw = String(message.content || '');
|
|
417
419
|
const previous = index > 0 ? messages[index - 1] : null;
|
|
418
420
|
const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
|
|
419
|
-
const canRedo = message.role === 'user' && raw.trim().length > 0;
|
|
420
421
|
const headerLines: RenderedLine[] = showHeader
|
|
421
422
|
? [
|
|
422
423
|
{
|
|
423
|
-
segments: messageHeaderSegments(message.role, columns
|
|
424
|
+
segments: messageHeaderSegments(message.role, columns),
|
|
424
425
|
copyContent: raw,
|
|
425
|
-
...(canRedo ? { redoContent: raw, redoIndex: index } : {}),
|
|
426
426
|
},
|
|
427
427
|
{ segments: [{ text: ' ', color: '#D6DEE8' }] },
|
|
428
428
|
]
|
|
@@ -433,6 +433,11 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
|
|
|
433
433
|
...headerLines,
|
|
434
434
|
...raw.split('\n').map((line) => {
|
|
435
435
|
const { left, right } = statusColumns(line || ' ');
|
|
436
|
+
// A row with nothing on either side is vertical padding, not data.
|
|
437
|
+
// Rendering it as a status row drew an empty blue-bordered box under
|
|
438
|
+
// the last real line — the leftover visible at the bottom right of
|
|
439
|
+
// /status. Emit a plain spacer so the boxes stop with the content.
|
|
440
|
+
if (!left.trim() && !right.trim()) return { segments: [{ text: ' ', color: '#D6DEE8' }] };
|
|
436
441
|
return { status: true, statusLeft: left, statusRight: right, segments: [] };
|
|
437
442
|
}),
|
|
438
443
|
{ segments: [{ text: ' ', color: '#D6DEE8' }] },
|
|
@@ -472,15 +477,18 @@ export function ConversationView(props: {
|
|
|
472
477
|
onScroll: (delta: number) => void;
|
|
473
478
|
spinnerFrame: string;
|
|
474
479
|
onCopy?: (content: string) => void;
|
|
475
|
-
onRedo?: (index: number, content: string) => void;
|
|
476
480
|
onOpenLink?: (url: string) => void;
|
|
477
481
|
}) {
|
|
478
482
|
const renderer = useRenderer();
|
|
483
|
+
const [hoveredUrl, setHoveredUrl] = createSignal<string | null>(null);
|
|
479
484
|
// Terminals have no CSS cursor, but OpenTUI can ask the emulator to swap the
|
|
480
485
|
// mouse pointer shape. Without it nothing on screen distinguishes a clickable
|
|
481
486
|
// link from ordinary blue text, so operators never discovered links were
|
|
482
487
|
// clickable at all.
|
|
483
|
-
const hoverLink = (url: string | null) =>
|
|
488
|
+
const hoverLink = (url: string | null) => {
|
|
489
|
+
setHoveredUrl(url);
|
|
490
|
+
renderer?.setMousePointer?.(url ? 'pointer' : 'default');
|
|
491
|
+
};
|
|
484
492
|
const allLines = createMemo(() => conversationLines(props.messages, props.columns));
|
|
485
493
|
const visibleLines = () => {
|
|
486
494
|
const lines = allLines();
|
|
@@ -516,7 +524,9 @@ export function ConversationView(props: {
|
|
|
516
524
|
event.stopPropagation?.();
|
|
517
525
|
}}
|
|
518
526
|
>
|
|
519
|
-
<text height={1} fg=
|
|
527
|
+
<text height={1} fg={hoveredUrl() ? '#5DADE2' : '#7F8C8D'}>
|
|
528
|
+
{hoveredUrl() ? `↗ ${hoveredUrl()}` : scrollHint()}
|
|
529
|
+
</text>
|
|
520
530
|
<For each={visibleLines()}>
|
|
521
531
|
{(line) => (
|
|
522
532
|
line.copyContent !== undefined ? (
|
|
@@ -525,13 +535,6 @@ export function ConversationView(props: {
|
|
|
525
535
|
{(seg: Segment) => <text width={seg.width} fg={seg.color} bg={seg.bg}>{seg.text}</text>}
|
|
526
536
|
</For>
|
|
527
537
|
<text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
|
|
528
|
-
<Show when={line.redoContent !== undefined}>
|
|
529
|
-
<text
|
|
530
|
-
fg="#FBBF24"
|
|
531
|
-
content={REDO_BTN}
|
|
532
|
-
onMouseUp={() => props.onRedo?.(line.redoIndex!, line.redoContent!)}
|
|
533
|
-
/>
|
|
534
|
-
</Show>
|
|
535
538
|
</box>
|
|
536
539
|
) : line.role === 'user' && line.userBubbleWidth ? (
|
|
537
540
|
<box height={1} flexDirection="row" overflow="hidden">
|
|
@@ -562,7 +565,19 @@ export function ConversationView(props: {
|
|
|
562
565
|
overflow="hidden"
|
|
563
566
|
>
|
|
564
567
|
<For each={statusSegments(line.statusLeft ?? '')}>
|
|
565
|
-
{(seg) =>
|
|
568
|
+
{(seg) => (
|
|
569
|
+
<text
|
|
570
|
+
width={seg.width}
|
|
571
|
+
fg={seg.color}
|
|
572
|
+
bg="#111318"
|
|
573
|
+
onMouseOver={() => hoverLink(seg.url ? normalizeExternalUrl(seg.url) : null)}
|
|
574
|
+
onMouseOut={() => hoverLink(null)}
|
|
575
|
+
onMouseUp={() => {
|
|
576
|
+
const url = seg.url ? normalizeExternalUrl(seg.url) : null;
|
|
577
|
+
if (url) props.onOpenLink?.(url);
|
|
578
|
+
}}
|
|
579
|
+
>{seg.text}</text>
|
|
580
|
+
)}
|
|
566
581
|
</For>
|
|
567
582
|
</box>
|
|
568
583
|
{line.statusRight ? (
|
|
@@ -578,7 +593,19 @@ export function ConversationView(props: {
|
|
|
578
593
|
overflow="hidden"
|
|
579
594
|
>
|
|
580
595
|
<For each={statusSegments(line.statusRight)}>
|
|
581
|
-
{(seg) =>
|
|
596
|
+
{(seg) => (
|
|
597
|
+
<text
|
|
598
|
+
width={seg.width}
|
|
599
|
+
fg={seg.color}
|
|
600
|
+
bg="#111318"
|
|
601
|
+
onMouseOver={() => hoverLink(seg.url ? normalizeExternalUrl(seg.url) : null)}
|
|
602
|
+
onMouseOut={() => hoverLink(null)}
|
|
603
|
+
onMouseUp={() => {
|
|
604
|
+
const url = seg.url ? normalizeExternalUrl(seg.url) : null;
|
|
605
|
+
if (url) props.onOpenLink?.(url);
|
|
606
|
+
}}
|
|
607
|
+
>{seg.text}</text>
|
|
608
|
+
)}
|
|
582
609
|
</For>
|
|
583
610
|
</box>
|
|
584
611
|
) : null}
|
|
@@ -774,7 +801,6 @@ export function LeftPane(props: {
|
|
|
774
801
|
spinnerFrame: string;
|
|
775
802
|
onInputHeightChange: (height: number) => void;
|
|
776
803
|
onCopy?: (content: string) => void;
|
|
777
|
-
onRedo?: (index: number, content: string) => void;
|
|
778
804
|
onOpenLink?: (url: string) => void;
|
|
779
805
|
}) {
|
|
780
806
|
const modeColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
|
|
@@ -805,7 +831,6 @@ export function LeftPane(props: {
|
|
|
805
831
|
onScroll={props.scrollConversation}
|
|
806
832
|
spinnerFrame={props.spinnerFrame}
|
|
807
833
|
onCopy={props.onCopy}
|
|
808
|
-
onRedo={props.onRedo}
|
|
809
834
|
onOpenLink={props.onOpenLink}
|
|
810
835
|
/>
|
|
811
836
|
)}
|
package/src/shell/repl.js
CHANGED
|
@@ -8,7 +8,7 @@ import { stdin as input, stdout as output } from 'node:process';
|
|
|
8
8
|
import { marked } from 'marked';
|
|
9
9
|
import { markedTerminal } from 'marked-terminal';
|
|
10
10
|
import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
|
|
11
|
-
import { handleSlashCommand } from '../commands/slash.js';
|
|
11
|
+
import { handleSlashCommand, rawCommandAgentPrompt } from '../commands/slash.js';
|
|
12
12
|
import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
|
|
13
13
|
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
14
14
|
import { syncActivitiesToPlan } from '../core/plan.js';
|
|
@@ -74,7 +74,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
|
|
|
74
74
|
'/config': 'Inspect or switch .wikirc.yaml profiles.',
|
|
75
75
|
'/status': 'Show the current workspace and session state.',
|
|
76
76
|
'/services': 'List workspace Docker Compose services.',
|
|
77
|
-
'/start': 'Start
|
|
77
|
+
'/start': 'Start everything (all), the agents only, or one service.',
|
|
78
78
|
'/stop': 'Stop one service or the workspace service set.',
|
|
79
79
|
'/logs': 'Show recent logs for a service.',
|
|
80
80
|
'/mcp': 'Inspect or call workspace MCP servers.',
|
|
@@ -301,7 +301,8 @@ function completionValuesFor(parts, inputBuffer, session) {
|
|
|
301
301
|
if (command === '/wiki' && tokenIndex === 1) return ['run'];
|
|
302
302
|
if (command === '/skills' && tokenIndex === 1) return ['edit', 'list', 'run', 'show'];
|
|
303
303
|
if (command === '/skills' && ['edit', 'run', 'show'].includes(previousToken ?? '')) return skillNames(session);
|
|
304
|
-
if (
|
|
304
|
+
if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceNames()];
|
|
305
|
+
if (command === '/stop' && tokenIndex === 1) return ['agents', ...serviceNames()];
|
|
305
306
|
if (command === '/logs' && tokenIndex === 1) return serviceNames();
|
|
306
307
|
return [];
|
|
307
308
|
}
|
|
@@ -434,6 +435,7 @@ function buildDirectChatSystemPrompt(session, rawOpenWikiPages) {
|
|
|
434
435
|
return [
|
|
435
436
|
'You are Donna, the llm-wiki-manager chat assistant: warm, plain-spoken, and helpful — like an attentive colleague, never a raw status dump.',
|
|
436
437
|
'You have a small READ-ONLY toolset — the tools provided to you for this turn, which may be none. Use them to answer questions about live state (e.g. "le CME est-il configuré", "quelles pages sont en attente"), and answer only from their results.',
|
|
438
|
+
'Questions about Donna, wikiLLM, the manager, its interfaces, commands, configuration, agents, concurrency, or troubleshooting are product-help questions, not action requests. When PRODUCT HELP REFERENCE content is attached, answer directly from it in chat mode; never redirect such a question to /agent.',
|
|
437
439
|
'When the conversation already contains attached document content (delimited by BEGIN/END ATTACHED DOCUMENT markers), read and summarize or answer from that content directly — you do NOT need a tool for it, and must not claim you cannot read the document.',
|
|
438
440
|
'If no provided tool covers the request and no attached content answers it — or the request is an action or mutation (ingest, build, export, configure, send, delete…), or needs a service that is not connected — say plainly you cannot do it in chat mode and to switch to agent mode (/agent). Do not pretend to execute it and never guess.',
|
|
439
441
|
'Answer directly and concisely. Do not claim to have called tools or changed files beyond the tools actually provided.',
|
|
@@ -449,6 +451,50 @@ function buildDirectChatSystemPrompt(session, rawOpenWikiPages) {
|
|
|
449
451
|
].join('\n');
|
|
450
452
|
}
|
|
451
453
|
|
|
454
|
+
export function isProductHelpQuestion(input) {
|
|
455
|
+
const text = String(input ?? '')
|
|
456
|
+
.normalize('NFKD')
|
|
457
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
458
|
+
.toLowerCase();
|
|
459
|
+
if (!text.trim()) return false;
|
|
460
|
+
if (/\b(donna|wikillm|llm-wiki|wiki-manager)\b/.test(text)) return true;
|
|
461
|
+
if (/\/(status|help|chat|agent|start|services|mcp|run|approve|queue)\b/.test(text)) return true;
|
|
462
|
+
if (/\b(manager ceiling|parallelism|throughput|collection concurrency|scheduler workers?)\b/.test(text)) return true;
|
|
463
|
+
const productConcept = /\b(workspaces?|agents?|connecteurs?|connectors?|mcp|runtime|approbations?|approvals?|ingestion|deliverables?|parallelisme|concurrence)\b/.test(text);
|
|
464
|
+
const explanatoryQuestion = /\b(comment|pourquoi|a quoi|qu est ce|que signifie|explique|fonctionne|difference|combien)\b/.test(text);
|
|
465
|
+
return productConcept && explanatoryQuestion;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function productHelpContextMessages(input, session, onStep) {
|
|
469
|
+
if (!isProductHelpQuestion(input)) return [];
|
|
470
|
+
const provider = Object.entries(session?.mcp ?? {}).find(([, entry]) =>
|
|
471
|
+
entry?.status === 'connected'
|
|
472
|
+
&& (entry.tools ?? []).some((tool) => String(tool?.name ?? '') === 'help_search'));
|
|
473
|
+
if (!provider) return [];
|
|
474
|
+
const [server] = provider;
|
|
475
|
+
try {
|
|
476
|
+
onStep?.('Chat: consulting product documentation…');
|
|
477
|
+
const result = await callMcpTool(
|
|
478
|
+
session.mcp,
|
|
479
|
+
server,
|
|
480
|
+
'help_search',
|
|
481
|
+
{ query: input },
|
|
482
|
+
session._abortSignal,
|
|
483
|
+
);
|
|
484
|
+
const content = formatMcpToolResult(result).trim();
|
|
485
|
+
if (!content) return [];
|
|
486
|
+
return [{
|
|
487
|
+
role: 'user',
|
|
488
|
+
content:
|
|
489
|
+
'PRODUCT HELP REFERENCE follows. It is read-only product documentation. '
|
|
490
|
+
+ 'Use it to answer the question directly and do not redirect me to agent mode:\n\n'
|
|
491
|
+
+ `--- BEGIN PRODUCT HELP REFERENCE ---\n${content}\n--- END PRODUCT HELP REFERENCE ---`,
|
|
492
|
+
}];
|
|
493
|
+
} catch {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
452
498
|
function commonPrefix(values) {
|
|
453
499
|
if (values.length === 0) return '';
|
|
454
500
|
let prefix = values[0];
|
|
@@ -486,7 +532,12 @@ export function completionDescription(value, parts) {
|
|
|
486
532
|
const subcommand = SUBCOMMAND_COMPLETION_DESCRIPTIONS[`${command}:${value}`];
|
|
487
533
|
if (subcommand) return subcommand;
|
|
488
534
|
if (command === '/use') return 'Load this workspace.';
|
|
489
|
-
if (command === '/start')
|
|
535
|
+
if (command === '/start') {
|
|
536
|
+
if (value === 'all') return 'Start the workspace services AND the external agents.';
|
|
537
|
+
if (value === 'agents' || value === 'agent') return 'Start the external agents only.';
|
|
538
|
+
if (value === 'services') return 'Start the workspace services only.';
|
|
539
|
+
return serviceDescription(value) ?? 'Start this Docker Compose service.';
|
|
540
|
+
}
|
|
490
541
|
if (command === '/stop') return serviceDescription(value) ?? 'Stop this Docker Compose service.';
|
|
491
542
|
if (command === '/logs') return serviceDescription(value) ?? 'Show logs for this Docker Compose service.';
|
|
492
543
|
if (command === '/workspace') {
|
|
@@ -925,20 +976,24 @@ function rememberProductionActivity(session, payload) {
|
|
|
925
976
|
export function applyRuntimeStateToShellSession(session, state) {
|
|
926
977
|
if (!state || typeof state !== 'object') return false;
|
|
927
978
|
const displayState = sanitizeRuntimeStateForDisplay(state);
|
|
979
|
+
const terminalStateDismissed = session._dismissedTerminalRunId
|
|
980
|
+
&& state.runId === session._dismissedTerminalRunId
|
|
981
|
+
&& ['done', 'error', 'failed', 'cancelled'].includes(String(displayState.status ?? '').toLowerCase());
|
|
982
|
+
session._lastRuntimeRunId = state.runId ?? session._lastRuntimeRunId ?? null;
|
|
928
983
|
session.agentProjection = {
|
|
929
984
|
conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
|
|
930
985
|
chain: Array.isArray(displayState.chain) ? displayState.chain.map((step) => ({ ...step })) : [],
|
|
931
|
-
plan: Array.isArray(displayState.plan) && displayState.plan.length > 0
|
|
986
|
+
plan: !terminalStateDismissed && Array.isArray(displayState.plan) && displayState.plan.length > 0
|
|
932
987
|
? displayState.plan.map((step) => ({ ...step }))
|
|
933
988
|
: null,
|
|
934
|
-
activities: Array.isArray(displayState.activities) ? displayState.activities.map((activity) => ({ ...activity })) : [],
|
|
935
|
-
logs: Array.isArray(displayState.logs) ? [...displayState.logs] : [],
|
|
936
|
-
summary: displayState.summary ?? null,
|
|
989
|
+
activities: !terminalStateDismissed && Array.isArray(displayState.activities) ? displayState.activities.map((activity) => ({ ...activity })) : [],
|
|
990
|
+
logs: !terminalStateDismissed && Array.isArray(displayState.logs) ? [...displayState.logs] : [],
|
|
991
|
+
summary: terminalStateDismissed ? null : displayState.summary ?? null,
|
|
937
992
|
status: displayState.status ?? 'idle',
|
|
938
993
|
planRevision: displayState.planRevision ?? 0,
|
|
939
|
-
planPatches: Array.isArray(displayState.planPatches) ? displayState.planPatches.map((patch) => ({ ...patch })) : [],
|
|
994
|
+
planPatches: !terminalStateDismissed && Array.isArray(displayState.planPatches) ? displayState.planPatches.map((patch) => ({ ...patch })) : [],
|
|
940
995
|
};
|
|
941
|
-
session.workflow = displayState.workflow && typeof displayState.workflow === 'object'
|
|
996
|
+
session.workflow = !terminalStateDismissed && displayState.workflow && typeof displayState.workflow === 'object'
|
|
942
997
|
? {
|
|
943
998
|
...state.workflow,
|
|
944
999
|
nodes: Array.isArray(state.workflow.nodes) ? state.workflow.nodes.map((node) => ({ ...node })) : [],
|
|
@@ -1206,11 +1261,26 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
|
|
|
1206
1261
|
output.write(buf);
|
|
1207
1262
|
}
|
|
1208
1263
|
|
|
1209
|
-
async function runAgentTurn(input, {
|
|
1264
|
+
async function runAgentTurn(input, {
|
|
1265
|
+
agent,
|
|
1266
|
+
session,
|
|
1267
|
+
onUpdate,
|
|
1268
|
+
onStep,
|
|
1269
|
+
displayInput = input,
|
|
1270
|
+
synthesisOnly = false,
|
|
1271
|
+
}) {
|
|
1210
1272
|
const messages = conversationMessages(session);
|
|
1211
1273
|
const history = toConversationHistory(messages);
|
|
1212
1274
|
session._onStep = onStep ?? null;
|
|
1213
1275
|
session.packageJson = session.packageJson ?? {};
|
|
1276
|
+
if (
|
|
1277
|
+
session._lastRuntimeRunId
|
|
1278
|
+
&& ['done', 'error', 'failed', 'cancelled'].includes(
|
|
1279
|
+
String(session.agentProjection?.status ?? '').toLowerCase(),
|
|
1280
|
+
)
|
|
1281
|
+
) {
|
|
1282
|
+
session._dismissedTerminalRunId = session._lastRuntimeRunId;
|
|
1283
|
+
}
|
|
1214
1284
|
dispatchAgentEvent(session, createAgentEvent('run_started', {
|
|
1215
1285
|
origin: 'user',
|
|
1216
1286
|
payload: { input: displayInput },
|
|
@@ -1252,6 +1322,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayIn
|
|
|
1252
1322
|
|
|
1253
1323
|
let agentResult;
|
|
1254
1324
|
try {
|
|
1325
|
+
if (synthesisOnly) session._responseSynthesisOnly = true;
|
|
1255
1326
|
agentResult = await agent.invoke({ input, session, messages: history });
|
|
1256
1327
|
} catch (err) {
|
|
1257
1328
|
if (err.name === 'AbortError') {
|
|
@@ -1272,6 +1343,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayIn
|
|
|
1272
1343
|
delete session._onStep;
|
|
1273
1344
|
delete session._onStream;
|
|
1274
1345
|
delete session._onStreamReset;
|
|
1346
|
+
delete session._responseSynthesisOnly;
|
|
1275
1347
|
}
|
|
1276
1348
|
|
|
1277
1349
|
if (agentResult.streamedInline) {
|
|
@@ -1403,15 +1475,25 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
|
|
|
1403
1475
|
messages.push(donnaMessage);
|
|
1404
1476
|
onUpdate?.();
|
|
1405
1477
|
const allowedTools = chatAllowedTools(session);
|
|
1478
|
+
const productHelpMessages = await productHelpContextMessages(input, session, onStep);
|
|
1406
1479
|
const canUseTools = allowedTools.length > 0 && typeof session.llm.completeWithTools === 'function';
|
|
1407
1480
|
try {
|
|
1408
1481
|
if (canUseTools) {
|
|
1409
|
-
await runChatToolLoop({
|
|
1482
|
+
await runChatToolLoop({
|
|
1483
|
+
input,
|
|
1484
|
+
session,
|
|
1485
|
+
history,
|
|
1486
|
+
donnaMessage,
|
|
1487
|
+
onUpdate,
|
|
1488
|
+
onStep,
|
|
1489
|
+
allowedTools,
|
|
1490
|
+
contextMessages: productHelpMessages,
|
|
1491
|
+
});
|
|
1410
1492
|
} else {
|
|
1411
1493
|
onStep?.('Chat: streaming direct answer…');
|
|
1412
1494
|
for await (const delta of session.llm.stream({
|
|
1413
1495
|
system: buildDirectChatSystemPrompt(session),
|
|
1414
|
-
messages: [...history, { role: 'user', content: input }],
|
|
1496
|
+
messages: [...history, ...productHelpMessages, { role: 'user', content: input }],
|
|
1415
1497
|
signal: session._abortSignal,
|
|
1416
1498
|
})) {
|
|
1417
1499
|
const cleanDelta = stripDsmlArtifacts(delta);
|
|
@@ -1454,7 +1536,10 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
|
|
|
1454
1536
|
// a read tool is offered or the model chooses to call it. Multiple documents
|
|
1455
1537
|
// (up to five) are each folded in as their own delimited block.
|
|
1456
1538
|
const attachedDocs = await readSelectedPageDocuments(session, selectedPages);
|
|
1457
|
-
const contextMessages =
|
|
1539
|
+
const contextMessages = [
|
|
1540
|
+
...buildAttachedDocMessages(attachedDocs),
|
|
1541
|
+
...await productHelpContextMessages(input, session, onStep),
|
|
1542
|
+
];
|
|
1458
1543
|
const canUseTools = allowedTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
|
|
1459
1544
|
if (canUseTools) {
|
|
1460
1545
|
await runChatToolLoop({ input, session, history, donnaMessage, onStep, allowedTools, openWikiPages: selectedPages, contextMessages });
|
|
@@ -1532,7 +1617,11 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
|
|
|
1532
1617
|
// and only for slash commands — free text got a single row.
|
|
1533
1618
|
const result = await handleSlashCommand(trimmed, { packageJson, session, onStep, runtime });
|
|
1534
1619
|
const messages = conversationMessages(session);
|
|
1535
|
-
const
|
|
1620
|
+
const deterministicDisplayOnly = /^\/status(?:\s|$)/.test(trimmed);
|
|
1621
|
+
const commandAgentTrigger = result.output && agent && !deterministicDisplayOnly
|
|
1622
|
+
? (result.agentTrigger ?? rawCommandAgentPrompt(trimmed, result.output))
|
|
1623
|
+
: null;
|
|
1624
|
+
const handoffToAgent = Boolean(commandAgentTrigger);
|
|
1536
1625
|
if (result.output) {
|
|
1537
1626
|
const parts = trimmed.split(/\s+/);
|
|
1538
1627
|
if (parts[0] === '/mcp' && parts[1] === 'call' && parts[2]) {
|
|
@@ -1548,15 +1637,19 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
|
|
|
1548
1637
|
}
|
|
1549
1638
|
}
|
|
1550
1639
|
}
|
|
1551
|
-
if (result.output && !
|
|
1640
|
+
if (result.output && !handoffToAgent) {
|
|
1552
1641
|
messages.push({ role: 'command', content: result.output });
|
|
1553
1642
|
onUpdate?.();
|
|
1554
1643
|
}
|
|
1555
|
-
if (
|
|
1556
|
-
const agentResult = await runAgentTurn(
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1644
|
+
if (handoffToAgent) {
|
|
1645
|
+
const agentResult = await runAgentTurn(commandAgentTrigger, {
|
|
1646
|
+
agent,
|
|
1647
|
+
session,
|
|
1648
|
+
onUpdate,
|
|
1649
|
+
onStep,
|
|
1650
|
+
displayInput: trimmed,
|
|
1651
|
+
synthesisOnly: true,
|
|
1652
|
+
});
|
|
1560
1653
|
if (agentResult.aborted) return { exit: false, aborted: true };
|
|
1561
1654
|
}
|
|
1562
1655
|
return { exit: Boolean(result.exit), setMode: result.setMode };
|