@dotdrelle/wiki-manager 0.15.28 → 0.15.29
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 +5 -4
- package/agents.docker-compose.override.example.yml +1 -1
- package/docker-compose.override.example.yml +2 -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 +148 -4
- package/src/cli/wiki-manager.js +39 -6
- package/src/cli/wiki-manager.test.js +35 -0
- package/src/commands/slash.js +159 -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 +1 -1
- 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/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +27 -0
- 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 +0 -1
- package/wiki-workspace +29 -12
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 };
|
package/src/shell/repl.test.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
applyRuntimeStateToShellSession,
|
|
8
8
|
chatAllowedTools,
|
|
9
9
|
createSession,
|
|
10
|
+
isProductHelpQuestion,
|
|
10
11
|
runHeadlessChatTurn,
|
|
11
12
|
sanitizeOpenWikiPage,
|
|
12
13
|
sanitizeOpenWikiPages,
|
|
@@ -42,23 +43,14 @@ test('ShellUI renders the user header full-width before constraining only its bo
|
|
|
42
43
|
assert.match(source.slice(userBodyBranch), /justifyContent="flex-start"/);
|
|
43
44
|
assert.doesNotMatch(source.slice(userBodyBranch, source.indexOf(') : line.status ?', userBodyBranch)), /#12263A/);
|
|
44
45
|
assert.doesNotMatch(source, /messageHeaderSegments\(message\.role, contentColumns\)/);
|
|
45
|
-
|
|
46
|
-
assert.match(source, /messageHeaderSegments\(message\.role, columns(, canRedo)?\)/);
|
|
46
|
+
assert.match(source, /messageHeaderSegments\(message\.role, columns\)/);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
test('
|
|
49
|
+
test('ShellUI exposes copy without a reply or redo action', async () => {
|
|
50
50
|
const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
|
|
51
|
-
assert.match(source, /const
|
|
52
|
-
assert.
|
|
53
|
-
|
|
54
|
-
const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
|
|
55
|
-
// The runtime must be truncated first: its conversation is derived from the
|
|
56
|
-
// event log and would be merged straight back in otherwise.
|
|
57
|
-
const truncateIndex = session.indexOf('postRuntimeConversationTruncate(runtimeIndex');
|
|
58
|
-
const spliceIndex = session.indexOf('full.splice(index)');
|
|
59
|
-
const submitIndex = session.indexOf('return submitInput(content)');
|
|
60
|
-
assert.ok(truncateIndex > 0 && spliceIndex > truncateIndex && submitIndex > spliceIndex);
|
|
61
|
-
assert.match(session, /const windowOffset = Math\.max\(0, full\.length - messages\(\)\.length\);/);
|
|
51
|
+
assert.match(source, /const COPY_BTN = ' \[ copy \]';/);
|
|
52
|
+
assert.doesNotMatch(source, /\[\s*(?:reply|redo)\s*\]/i);
|
|
53
|
+
assert.doesNotMatch(source, /onRedo|redoContent|redoIndex|REDO_BTN/);
|
|
62
54
|
});
|
|
63
55
|
|
|
64
56
|
test('redo translates the local thread index into a runtime conversation index', async () => {
|
|
@@ -388,6 +380,29 @@ test('applyRuntimeStateToShellSession preserves terminal diagnostics when runtim
|
|
|
388
380
|
assert.deepEqual(session.agentProjection.planPatches, [{ id: 'old-patch' }]);
|
|
389
381
|
});
|
|
390
382
|
|
|
383
|
+
test('runtime sync does not resurrect a terminal plan dismissed by a newer user turn', () => {
|
|
384
|
+
const session = createSession();
|
|
385
|
+
session._dismissedTerminalRunId = 'old-run';
|
|
386
|
+
applyRuntimeStateToShellSession(session, {
|
|
387
|
+
runId: 'old-run',
|
|
388
|
+
status: 'error',
|
|
389
|
+
conversation: [{ role: 'assistant', content: 'Old failure' }],
|
|
390
|
+
plan: [{ id: 'old-task', status: 'failed' }],
|
|
391
|
+
activities: [{ key: 'old-job', status: 'failed', terminal: true }],
|
|
392
|
+
logs: ['old error'],
|
|
393
|
+
summary: 'Old run failed',
|
|
394
|
+
workflow: { nodes: [{ id: 'task:old' }], relations: [] },
|
|
395
|
+
planPatches: [{ id: 'old-patch' }],
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
assert.equal(session.headlessPlan, null);
|
|
399
|
+
assert.deepEqual(session.activities, {});
|
|
400
|
+
assert.equal(session.workflow, null);
|
|
401
|
+
assert.deepEqual(session.agentProjection.logs, []);
|
|
402
|
+
assert.equal(session.agentProjection.summary, null);
|
|
403
|
+
assert.deepEqual(session.agentProjection.conversation, [{ role: 'assistant', content: 'Old failure' }]);
|
|
404
|
+
});
|
|
405
|
+
|
|
391
406
|
test('legacy runtime sync does not synthesize per-job or canned completion messages', () => {
|
|
392
407
|
const session = createSession();
|
|
393
408
|
applyRuntimeStateToShellSession(session, {
|
|
@@ -567,6 +582,53 @@ test('/agent <question> reports an unavailable runtime without leaving chat mode
|
|
|
567
582
|
assert.match(conversationMessages(session).at(-1).content, /runtime stopped/);
|
|
568
583
|
});
|
|
569
584
|
|
|
585
|
+
test('every slash-command result is presented by Donna when an agent is available', async () => {
|
|
586
|
+
const session = createSession();
|
|
587
|
+
let synthesisInput = '';
|
|
588
|
+
const agent = {
|
|
589
|
+
async invoke({ input, session: activeSession }) {
|
|
590
|
+
synthesisInput = input;
|
|
591
|
+
assert.equal(activeSession._responseSynthesisOnly, true);
|
|
592
|
+
return { response: 'Vous utilisez wiki-manager test.' };
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const result = await runLine('/version', {
|
|
597
|
+
agent,
|
|
598
|
+
packageJson: { version: 'test' },
|
|
599
|
+
session,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
assert.equal(result.exit, false);
|
|
603
|
+
assert.ok(synthesisInput.includes('commande shell /version'));
|
|
604
|
+
assert.deepEqual(conversationMessages(session).map((message) => message.role), ['user', 'donna']);
|
|
605
|
+
assert.equal(conversationMessages(session)[0].content, '/version');
|
|
606
|
+
assert.equal(conversationMessages(session)[1].content, 'Vous utilisez wiki-manager test.');
|
|
607
|
+
assert.equal(session._responseSynthesisOnly, undefined);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('/status remains an immediate deterministic display without Donna', async () => {
|
|
611
|
+
const session = createSession();
|
|
612
|
+
let invoked = false;
|
|
613
|
+
const agent = {
|
|
614
|
+
async invoke() {
|
|
615
|
+
invoked = true;
|
|
616
|
+
return { response: 'must not run' };
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const result = await runLine('/status', {
|
|
621
|
+
agent,
|
|
622
|
+
packageJson: { version: 'test' },
|
|
623
|
+
session,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
assert.equal(result.exit, false);
|
|
627
|
+
assert.equal(invoked, false);
|
|
628
|
+
assert.equal(conversationMessages(session).at(-1)?.role, 'command');
|
|
629
|
+
assert.match(conversationMessages(session).at(-1)?.content ?? '', /Workspace · -/);
|
|
630
|
+
});
|
|
631
|
+
|
|
570
632
|
test('runLine does not update workspace profile before Donna handles the request', async () => {
|
|
571
633
|
const workspacePath = mkdtempSync(join(tmpdir(), 'donna-profile-shell-'));
|
|
572
634
|
mkdirSync(join(workspacePath, '.wiki'), { recursive: true });
|
|
@@ -745,9 +807,9 @@ test('chatAllowedTools exposes exactly the declared MCP tools to /chat', () => {
|
|
|
745
807
|
};
|
|
746
808
|
const names = chatAllowedTools(session).map((item) => item.function.name).sort();
|
|
747
809
|
// cme_setup: not declared. documents_status: server absent from chatAccess.
|
|
748
|
-
// cme_export_run
|
|
749
|
-
//
|
|
750
|
-
assert.deepEqual(names, ['
|
|
810
|
+
// cme_export_run is an orchestration bypass and stays hidden even when an
|
|
811
|
+
// older allow-list still names it.
|
|
812
|
+
assert.deepEqual(names, ['cme__cme_sources_list', 'cme__cme_status']);
|
|
751
813
|
});
|
|
752
814
|
|
|
753
815
|
test('chatAllowedTools offers every declared tool, reads and writes alike', () => {
|
|
@@ -940,6 +1002,15 @@ test('runHeadlessChatTurn (HTTP /chat) uses the read-tool path and returns text'
|
|
|
940
1002
|
assert.doesNotMatch(reply, /STREAM_FALLBACK/);
|
|
941
1003
|
});
|
|
942
1004
|
|
|
1005
|
+
test('product-help questions are detected without treating ordinary domain questions as product help', () => {
|
|
1006
|
+
assert.equal(isProductHelpQuestion('À quoi correspond Parallelism & throughput ?'), true);
|
|
1007
|
+
assert.equal(isProductHelpQuestion('Comment fonctionne Donna ?'), true);
|
|
1008
|
+
assert.equal(isProductHelpQuestion('Explique le panneau /status'), true);
|
|
1009
|
+
assert.equal(isProductHelpQuestion('Comment fonctionnent les approbations ?'), true);
|
|
1010
|
+
assert.equal(isProductHelpQuestion('Que dit le wiki sur la météo ?'), false);
|
|
1011
|
+
assert.equal(isProductHelpQuestion('Résume ce document'), false);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
943
1014
|
test('sanitizeOpenWikiPage accepts wiki and untracked markdown context paths', () => {
|
|
944
1015
|
assert.equal(sanitizeOpenWikiPage('wiki/concepts/foo.md'), 'wiki/concepts/foo.md');
|
|
945
1016
|
assert.equal(sanitizeOpenWikiPage(' wiki/a.md '), 'wiki/a.md');
|
package/src/shell/tui.tsx
CHANGED
|
@@ -398,7 +398,6 @@ function App(props: {
|
|
|
398
398
|
scrollConversation={state.scrollConversation}
|
|
399
399
|
spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
|
|
400
400
|
onInputHeightChange={setChatInputHeight}
|
|
401
|
-
onRedo={(index, content) => { void state.redoMessage(index, content); }}
|
|
402
401
|
onCopy={(content) => showCopyHint(
|
|
403
402
|
copyToClipboard(content, renderer)
|
|
404
403
|
? 'Copied.'
|