@dotdrelle/wiki-manager 0.14.8 → 0.14.10
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 +12 -0
- package/package.json +2 -7
- package/src/agent/graph.test.js +1 -8
- package/src/cli/wiki-manager.js +51 -20
- package/src/commands/slash.js +33 -6
- package/src/commands/slash.test.js +17 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/cacert.js +11 -8
- package/src/core/cacert.test.js +35 -0
- package/src/core/compose.js +21 -3
- package/src/core/dockerImages.js +22 -0
- package/src/core/env.js +9 -2
- package/src/core/mcp.js +1 -1
- package/src/core/skills.js +1 -30
- package/src/core/wikiSetup.js +26 -2
- package/src/runtime/recoveryManager.test.js +1 -1
- package/src/runtime/store.js +4 -1
- package/src/runtime/store.test.js +6 -6
- package/src/shell/LeftPane.tsx +17 -2
- package/src/shell/RightPane.tsx +34 -17
- package/src/shell/SetupWizard.tsx +28 -9
- package/src/shell/externalLinks.js +67 -0
- package/src/shell/repl.js +115 -23
- package/src/shell/repl.test.js +110 -0
- package/wiki-workspace +6 -1
package/.env.example
CHANGED
|
@@ -16,6 +16,18 @@ WORKSPACES_ROOT=/path/to/workspaces
|
|
|
16
16
|
# Defaults to .agents-data/ next to wiki-workspace.
|
|
17
17
|
# AGENTS_DATA_DIR=/path/to/.agents-data
|
|
18
18
|
|
|
19
|
+
# ── VPN / outbound HTTP proxy (optional) ──────────────────────────────────────
|
|
20
|
+
#
|
|
21
|
+
# Node fetch does not use HTTP_PROXY / HTTPS_PROXY unless environment proxy
|
|
22
|
+
# support is enabled. This matters for the manager runtime's own LLM calls
|
|
23
|
+
# (including runtime.delegate objective resolution) behind a corporate VPN.
|
|
24
|
+
# Keep these lines commented when no proxy is required.
|
|
25
|
+
# NODE_USE_ENV_PROXY=1
|
|
26
|
+
# HTTPS_PROXY=http://proxy.example:11011
|
|
27
|
+
# HTTP_PROXY=http://proxy.example:11011
|
|
28
|
+
# Always bypass the proxy for the local runtime, local MCPs, and Docker host.
|
|
29
|
+
# NO_PROXY=localhost,127.0.0.1,host.docker.internal
|
|
30
|
+
|
|
19
31
|
# ── Agent auth tokens ──────────────────────────────────────────────────────────
|
|
20
32
|
#
|
|
21
33
|
# Each agent has its own bearer token. Set the matching Authorization header
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.10",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
5
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
6
|
"author": "dotrelle",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"start": "bun ./bin/wiki-manager.js",
|
|
14
|
-
"test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
|
|
14
|
+
"test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
|
|
15
15
|
"check-versions": "node scripts/check-versions.js",
|
|
16
16
|
"prepack": "node scripts/check-versions.js",
|
|
17
17
|
"prepublishOnly": "node scripts/check-versions.js",
|
|
@@ -46,11 +46,6 @@
|
|
|
46
46
|
"cli"
|
|
47
47
|
],
|
|
48
48
|
"packageManager": "pnpm@10.29.2",
|
|
49
|
-
"pnpm": {
|
|
50
|
-
"onlyBuiltDependencies": [
|
|
51
|
-
"bun"
|
|
52
|
-
]
|
|
53
|
-
},
|
|
54
49
|
"dependencies": {
|
|
55
50
|
"@langchain/langgraph": "^1.3.2",
|
|
56
51
|
"@opentui/core": "^0.3.2",
|
package/src/agent/graph.test.js
CHANGED
|
@@ -801,16 +801,10 @@ test('buildAgentSystemPrompt forbids inventing slash commands or arguments', ()
|
|
|
801
801
|
assert.doesNotMatch(prompt, /executor:"/);
|
|
802
802
|
});
|
|
803
803
|
|
|
804
|
-
test('workspace
|
|
804
|
+
test('workspace skills are discovered from the fixed .wiki/skills directory', () => {
|
|
805
805
|
const root = mkdtempSync(join(tmpdir(), 'wiki-manager-skills-'));
|
|
806
806
|
try {
|
|
807
807
|
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
808
|
-
writeFileSync(join(root, 'skill.yaml'), [
|
|
809
|
-
'name: basic',
|
|
810
|
-
'description: Demo workspace package',
|
|
811
|
-
'entrypoints:',
|
|
812
|
-
' uiSkillDir: .wiki/skills',
|
|
813
|
-
].join('\n'));
|
|
814
808
|
writeFileSync(join(root, '.wiki', 'skills', 'ingest.md'), [
|
|
815
809
|
'---',
|
|
816
810
|
'name: ingest',
|
|
@@ -820,7 +814,6 @@ test('workspace package manifest is not exposed as an executable skill', () => {
|
|
|
820
814
|
].join('\n'));
|
|
821
815
|
|
|
822
816
|
const prompt = buildAgentSystemPrompt({ session: sessionBase({ workspacePath: root }) });
|
|
823
|
-
assert.doesNotMatch(prompt, /\/basic:/);
|
|
824
817
|
assert.match(prompt, /\/ingest: Ingest pending sources/);
|
|
825
818
|
} finally {
|
|
826
819
|
rmSync(root, { recursive: true, force: true });
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -31,6 +31,22 @@ function valueAfter(argv, flag) {
|
|
|
31
31
|
return argv[index + 1];
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function errorDiagnostic(err) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
let current = err;
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
while (current && !seen.has(current) && parts.length < 4) {
|
|
39
|
+
seen.add(current);
|
|
40
|
+
const name = current?.name && current.name !== 'Error' ? String(current.name) : '';
|
|
41
|
+
const code = current?.code ? String(current.code) : '';
|
|
42
|
+
const message = current instanceof Error ? current.message : String(current);
|
|
43
|
+
const detail = [name, code, message].filter(Boolean).join(' ');
|
|
44
|
+
if (detail && !parts.includes(detail)) parts.push(detail);
|
|
45
|
+
current = current?.cause;
|
|
46
|
+
}
|
|
47
|
+
return parts.join(' <- ') || 'unknown error';
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
function unavailableRuntime(err) {
|
|
35
51
|
const reason = err instanceof Error ? err.message : String(err);
|
|
36
52
|
return { url: null, error: reason };
|
|
@@ -478,7 +494,7 @@ async function runRuntime(argv, agent) {
|
|
|
478
494
|
'Defaults:',
|
|
479
495
|
' --host 127.0.0.1',
|
|
480
496
|
' --port 7788',
|
|
481
|
-
' --state-dir .wiki
|
|
497
|
+
' --state-dir .wiki/runtime',
|
|
482
498
|
].join('\n'));
|
|
483
499
|
return;
|
|
484
500
|
}
|
|
@@ -787,27 +803,42 @@ async function runRuntime(argv, agent) {
|
|
|
787
803
|
const { resolveObjective } = await import('../orchestrator/objectiveResolver.js');
|
|
788
804
|
const { validateFragment } = await import('../orchestrator/planValidator.js');
|
|
789
805
|
const session = context.session;
|
|
790
|
-
|
|
806
|
+
let selection;
|
|
807
|
+
try {
|
|
808
|
+
selection = await resolveObjective(objective, session);
|
|
809
|
+
} catch (err) {
|
|
810
|
+
throw new Error(`Delegation failed during objective_resolution: ${errorDiagnostic(err)}`, { cause: err });
|
|
811
|
+
}
|
|
791
812
|
const provider = selection.provider;
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
813
|
+
let planResult;
|
|
814
|
+
try {
|
|
815
|
+
planResult = await callMcpTool(
|
|
816
|
+
session.mcp,
|
|
817
|
+
provider.serverName,
|
|
818
|
+
'agent_plan',
|
|
819
|
+
{
|
|
820
|
+
capability: selection.capability,
|
|
821
|
+
operation: selection.operation,
|
|
822
|
+
objective,
|
|
823
|
+
workspace: { revision: String(Date.now()) },
|
|
824
|
+
constraints: {
|
|
825
|
+
maxConcurrency: resolveCapabilityConcurrency(
|
|
826
|
+
provider,
|
|
827
|
+
undefined,
|
|
828
|
+
process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
|
|
829
|
+
),
|
|
830
|
+
requireApprovalForMutations: true,
|
|
831
|
+
},
|
|
808
832
|
},
|
|
809
|
-
|
|
810
|
-
)
|
|
833
|
+
);
|
|
834
|
+
} catch (err) {
|
|
835
|
+
const endpoint = session.mcp?.[provider.serverName]?.url ?? 'unknown endpoint';
|
|
836
|
+
throw new Error(
|
|
837
|
+
`Delegation failed during agent_plan: provider=${provider.serverName} endpoint=${endpoint} ${errorDiagnostic(err)}`,
|
|
838
|
+
{ cause: err },
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
const fragment = parseJsonText(formatMcpToolResult(planResult));
|
|
811
842
|
if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
|
|
812
843
|
throw new Error(fragment?.summary?.initialSynthesis?.[0] ?? `No task was planned for ${selection.capability}/${selection.operation}.`);
|
|
813
844
|
}
|
package/src/commands/slash.js
CHANGED
|
@@ -627,7 +627,7 @@ ${helpPair('/mcp tools [mcp]', 'MCP tools', '/mcp call ...', 'Call MCP tool')}
|
|
|
627
627
|
${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
|
|
628
628
|
${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
|
|
629
629
|
${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
|
|
630
|
-
${helpPair('/chat', 'Chat mode', '/agent', 'Agent mode')}
|
|
630
|
+
${helpPair('/chat', 'Chat mode', '/agent [question]', 'Agent mode / one-shot')}
|
|
631
631
|
${helpPair('/openui', 'Open web UI in browser', '', '')}
|
|
632
632
|
${helpPair('/run status', 'Runtime status', '/run kill', 'Kill runtime run(s)')}
|
|
633
633
|
${helpPair('/run capability <id>', 'Deterministic capability run', '/approve', 'Grant pending approval')}
|
|
@@ -678,8 +678,31 @@ function rawCommandResult(command, output) {
|
|
|
678
678
|
};
|
|
679
679
|
}
|
|
680
680
|
|
|
681
|
-
|
|
682
|
-
|
|
681
|
+
// Shared by every deterministic command that can trigger an on-demand
|
|
682
|
+
// `docker pull` (agents up, service start): reports the images as they're
|
|
683
|
+
// found missing and hands back the final list for localizedOperationResult.
|
|
684
|
+
async function collectMissingImages(step, fn) {
|
|
685
|
+
let missingImages = [];
|
|
686
|
+
await fn({
|
|
687
|
+
onImagesMissing: (images) => {
|
|
688
|
+
missingImages = images;
|
|
689
|
+
step(`Donna: downloading and installing missing components: ${images.join(', ')}…`);
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
return missingImages;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function componentInstallAction(missingImages) {
|
|
696
|
+
return missingImages.length > 0 ? 'downloaded-and-installed-missing-components' : null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [] }) {
|
|
700
|
+
const facts = JSON.stringify({
|
|
701
|
+
operation,
|
|
702
|
+
target,
|
|
703
|
+
status,
|
|
704
|
+
...(componentAction ? { componentAction, images } : {}),
|
|
705
|
+
});
|
|
683
706
|
return {
|
|
684
707
|
output: facts,
|
|
685
708
|
rawOutput: true,
|
|
@@ -730,10 +753,12 @@ export async function handleSlashCommand(line, context) {
|
|
|
730
753
|
const runAgentCommand = async (fn, verb) => {
|
|
731
754
|
try {
|
|
732
755
|
step(`Agents: ${verb}ing external agents…`);
|
|
733
|
-
await fn
|
|
756
|
+
const missingImages = await collectMissingImages(step, fn);
|
|
734
757
|
return localizedOperationResult({
|
|
735
758
|
operation: verb,
|
|
736
759
|
target: 'agents',
|
|
760
|
+
componentAction: componentInstallAction(missingImages),
|
|
761
|
+
images: missingImages,
|
|
737
762
|
});
|
|
738
763
|
} catch (err) {
|
|
739
764
|
step(formatActivityError('agents', verb, err));
|
|
@@ -893,15 +918,17 @@ export async function handleSlashCommand(line, context) {
|
|
|
893
918
|
// do not remap it to undefined, that bypasses any custom "all" target list and always
|
|
894
919
|
// falls back to the hardcoded COMPOSE_SERVICES constant instead.
|
|
895
920
|
const service = args[1];
|
|
896
|
-
if (service === 'agents') return runAgentCommand(startAgents, 'start');
|
|
921
|
+
if (service === 'agents' || service === 'agent') return runAgentCommand(startAgents, 'start');
|
|
897
922
|
try {
|
|
898
923
|
step(`Services: starting ${service ?? 'workspace services'}…`);
|
|
899
|
-
await startService(context.session, service);
|
|
924
|
+
const missingImages = await collectMissingImages(step, (opts) => startService(context.session, service, opts));
|
|
900
925
|
step('Services: refreshing MCP runtime…');
|
|
901
926
|
await refreshMcpRuntimeStatus(context.session);
|
|
902
927
|
return localizedOperationResult({
|
|
903
928
|
operation: 'start',
|
|
904
929
|
target: service || 'workspace-services',
|
|
930
|
+
componentAction: componentInstallAction(missingImages),
|
|
931
|
+
images: missingImages,
|
|
905
932
|
});
|
|
906
933
|
} catch (err) {
|
|
907
934
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -15,6 +15,23 @@ test('deterministic operation results ask Donna to localize compact facts withou
|
|
|
15
15
|
assert.doesNotMatch(result.agentTrigger, /\/start|Docker|compose/);
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
test('start result tells Donna when missing container components were installed', () => {
|
|
19
|
+
const result = localizedOperationResult({
|
|
20
|
+
operation: 'start',
|
|
21
|
+
target: 'all',
|
|
22
|
+
componentAction: 'downloaded-and-installed-missing-components',
|
|
23
|
+
images: ['dotdrelle/llm-wiki:latest'],
|
|
24
|
+
});
|
|
25
|
+
assert.deepEqual(JSON.parse(result.output), {
|
|
26
|
+
operation: 'start',
|
|
27
|
+
target: 'all',
|
|
28
|
+
status: 'succeeded',
|
|
29
|
+
componentAction: 'downloaded-and-installed-missing-components',
|
|
30
|
+
images: ['dotdrelle/llm-wiki:latest'],
|
|
31
|
+
});
|
|
32
|
+
assert.match(result.agentTrigger, /downloaded-and-installed-missing-components/);
|
|
33
|
+
});
|
|
34
|
+
|
|
18
35
|
test('/workspace delete removes files and clears current session context after confirmation', async () => {
|
|
19
36
|
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-delete-workspace-'));
|
|
20
37
|
const registryRoot = join(root, 'registry');
|
package/src/core/buildInfo.json
CHANGED
package/src/core/cacert.js
CHANGED
|
@@ -40,18 +40,15 @@ function composeOverrideContent(cacertPath, services) {
|
|
|
40
40
|
services: serviceEntries,
|
|
41
41
|
};
|
|
42
42
|
return [
|
|
43
|
-
'# Generated by wiki-manager.
|
|
43
|
+
'# Generated by wiki-manager. Changes are overwritten on the next compose command.',
|
|
44
44
|
YAML.stringify(doc).trimEnd(),
|
|
45
45
|
'',
|
|
46
46
|
].join('\n');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
let _cacertOverrideCache;
|
|
50
|
-
|
|
51
49
|
export function ensureCacertComposeOverride(composeFilePath, fileName = 'cacert.compose.yml') {
|
|
52
|
-
if (_cacertOverrideCache !== undefined) return _cacertOverrideCache;
|
|
53
50
|
const cacertPath = activeCacertPath();
|
|
54
|
-
if (!cacertPath) return
|
|
51
|
+
if (!cacertPath) return null;
|
|
55
52
|
if (!existsSync(cacertPath)) {
|
|
56
53
|
throw new Error(`--cacert file not found: ${cacertPath}`);
|
|
57
54
|
}
|
|
@@ -60,8 +57,14 @@ export function ensureCacertComposeOverride(composeFilePath, fileName = 'cacert.
|
|
|
60
57
|
const runtimeDir = managerRuntimeDir();
|
|
61
58
|
mkdirSync(runtimeDir, { recursive: true });
|
|
62
59
|
const overridePath = join(runtimeDir, fileName);
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
const content = composeOverrideContent(cacertPath, services);
|
|
61
|
+
// The override is generated state, not user configuration. Always compare
|
|
62
|
+
// it with the active CLI value: keeping an existing file here left Docker
|
|
63
|
+
// mounting yesterday's CA after --cacert changed, despite all CA
|
|
64
|
+
// environment variables looking right.
|
|
65
|
+
const existing = existsSync(overridePath) ? readFileSync(overridePath, 'utf8') : null;
|
|
66
|
+
if (existing !== content) {
|
|
67
|
+
writeFileSync(overridePath, content, 'utf8');
|
|
65
68
|
}
|
|
66
|
-
return
|
|
69
|
+
return overridePath;
|
|
67
70
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { ensureCacertComposeOverride } from './cacert.js';
|
|
7
|
+
|
|
8
|
+
test('cacert compose override follows the active certificate path', () => {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'wiki-manager-cacert-'));
|
|
10
|
+
const compose = join(root, 'compose.yml');
|
|
11
|
+
const firstCa = join(root, 'first.pem');
|
|
12
|
+
const secondCa = join(root, 'second.pem');
|
|
13
|
+
writeFileSync(compose, 'services:\n serve:\n image: example\n', 'utf8');
|
|
14
|
+
writeFileSync(firstCa, 'first', 'utf8');
|
|
15
|
+
writeFileSync(secondCa, 'second', 'utf8');
|
|
16
|
+
const previousCa = process.env.WIKI_MANAGER_CACERT_PATH;
|
|
17
|
+
const previousEnvFile = process.env.WIKI_MANAGER_ENV_FILE;
|
|
18
|
+
process.env.WIKI_MANAGER_ENV_FILE = join(root, '.env');
|
|
19
|
+
try {
|
|
20
|
+
process.env.WIKI_MANAGER_CACERT_PATH = firstCa;
|
|
21
|
+
const override = ensureCacertComposeOverride(compose, 'test-cacert.compose.yml');
|
|
22
|
+
assert.match(readFileSync(override, 'utf8'), new RegExp(firstCa.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
23
|
+
|
|
24
|
+
process.env.WIKI_MANAGER_CACERT_PATH = secondCa;
|
|
25
|
+
ensureCacertComposeOverride(compose, 'test-cacert.compose.yml');
|
|
26
|
+
const updated = readFileSync(override, 'utf8');
|
|
27
|
+
assert.match(updated, new RegExp(secondCa.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
28
|
+
assert.doesNotMatch(updated, new RegExp(firstCa.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
29
|
+
} finally {
|
|
30
|
+
if (previousCa === undefined) delete process.env.WIKI_MANAGER_CACERT_PATH;
|
|
31
|
+
else process.env.WIKI_MANAGER_CACERT_PATH = previousCa;
|
|
32
|
+
if (previousEnvFile === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
|
|
33
|
+
else process.env.WIKI_MANAGER_ENV_FILE = previousEnvFile;
|
|
34
|
+
}
|
|
35
|
+
});
|
package/src/core/compose.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import YAML from 'yaml';
|
|
6
6
|
import { cacertEnv, ensureCacertComposeOverride } from './cacert.js';
|
|
7
|
+
import { checkMissingDockerImages } from './dockerImages.js';
|
|
7
8
|
import { managerEnvFile, readEnvFile, resolveAgentsDataDir } from './env.js';
|
|
8
9
|
import { managerRoot } from './workspaces.js';
|
|
9
10
|
|
|
@@ -281,11 +282,28 @@ export async function serviceStates(session) {
|
|
|
281
282
|
return states;
|
|
282
283
|
}
|
|
283
284
|
|
|
284
|
-
export async function
|
|
285
|
+
export async function missingServiceImages(service) {
|
|
285
286
|
const aliases = serviceAliases();
|
|
286
287
|
const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
|
|
287
|
-
const
|
|
288
|
-
|
|
288
|
+
const config = readComposeConfig();
|
|
289
|
+
const images = targets.map((target) => config.services?.[target]?.image).filter(Boolean);
|
|
290
|
+
return checkMissingDockerImages(images);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function startService(session, service, options = {}) {
|
|
294
|
+
const aliases = serviceAliases();
|
|
295
|
+
const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
|
|
296
|
+
const absentImages = await missingServiceImages(service);
|
|
297
|
+
if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
|
|
298
|
+
const output = await runCompose(session, ['up', '-d', ...targets], {
|
|
299
|
+
timeout: 180_000,
|
|
300
|
+
onOutput: options.onOutput,
|
|
301
|
+
});
|
|
302
|
+
return {
|
|
303
|
+
output: [`Started: ${targets.join(', ')}`, output].filter(Boolean).join('\n'),
|
|
304
|
+
targets,
|
|
305
|
+
missingImages: absentImages,
|
|
306
|
+
};
|
|
289
307
|
}
|
|
290
308
|
|
|
291
309
|
export async function stopService(session, service) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function checkMissingDockerImages(images) {
|
|
7
|
+
const unique = [...new Set(images.filter(Boolean))];
|
|
8
|
+
const checks = await Promise.all(unique.map(async (image) => {
|
|
9
|
+
try {
|
|
10
|
+
await execFileAsync('docker', ['image', 'inspect', image], {
|
|
11
|
+
timeout: 30_000,
|
|
12
|
+
maxBuffer: 1024 * 1024,
|
|
13
|
+
});
|
|
14
|
+
return null;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
const detail = [err?.stderr, err?.stdout, err?.message].filter(Boolean).join('\n');
|
|
17
|
+
if (/no such (?:image|object)/i.test(detail)) return image;
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}));
|
|
21
|
+
return checks.filter(Boolean);
|
|
22
|
+
}
|
package/src/core/env.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
@@ -13,7 +13,14 @@ export function managerStateDir() {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function managerRuntimeDir() {
|
|
16
|
-
|
|
16
|
+
const root = managerStateDir();
|
|
17
|
+
const runtimeDir = join(root, '.wiki', 'runtime');
|
|
18
|
+
const legacyDir = join(root, '.wiki-manager');
|
|
19
|
+
if (!existsSync(runtimeDir) && existsSync(legacyDir)) {
|
|
20
|
+
mkdirSync(join(root, '.wiki'), { recursive: true });
|
|
21
|
+
renameSync(legacyDir, runtimeDir);
|
|
22
|
+
}
|
|
23
|
+
return runtimeDir;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
export function defaultRuntimeStateDir() {
|
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.14.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.14.10';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/skills.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
|
-
import { parse as parseYaml } from 'yaml';
|
|
4
3
|
|
|
5
4
|
const SKILL_NAME_RE = /^[a-zA-Z0-9_-]{1,80}$/;
|
|
6
5
|
const DEFAULT_UI_SKILL_DIR = '.wiki/skills';
|
|
@@ -51,33 +50,6 @@ export function readOptionalText(filePath) {
|
|
|
51
50
|
return readFileSync(filePath, 'utf8').trim();
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
function safeRelativeEntry(value, fallback) {
|
|
55
|
-
const text = String(value || fallback).trim();
|
|
56
|
-
if (!text || text.startsWith('/') || text.includes('..')) return fallback;
|
|
57
|
-
return text;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function readWorkspaceManifest(workspacePath) {
|
|
61
|
-
const manifestPath = join(workspacePath, 'skill.yaml');
|
|
62
|
-
if (!existsSync(manifestPath)) return null;
|
|
63
|
-
try {
|
|
64
|
-
const manifest = parseYaml(readFileSync(manifestPath, 'utf8'));
|
|
65
|
-
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return null;
|
|
66
|
-
return { manifest, manifestPath };
|
|
67
|
-
} catch {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function workspaceUiSkillDir(loadedManifest = null) {
|
|
73
|
-
if (!loadedManifest) return DEFAULT_UI_SKILL_DIR;
|
|
74
|
-
const { manifest } = loadedManifest;
|
|
75
|
-
const entrypoints = manifest?.entrypoints && typeof manifest.entrypoints === 'object'
|
|
76
|
-
? manifest.entrypoints
|
|
77
|
-
: {};
|
|
78
|
-
return safeRelativeEntry(entrypoints.uiSkillDir, DEFAULT_UI_SKILL_DIR);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
53
|
function collectDirectorySkills(dir, scope) {
|
|
82
54
|
if (!existsSync(dir)) return [];
|
|
83
55
|
return readdirSync(dir, { withFileTypes: true })
|
|
@@ -92,8 +64,7 @@ function collectDirectorySkills(dir, scope) {
|
|
|
92
64
|
export function listSkills(session = {}) {
|
|
93
65
|
const skills = [];
|
|
94
66
|
if (session.workspacePath) {
|
|
95
|
-
|
|
96
|
-
skills.push(...collectDirectorySkills(join(session.workspacePath, workspaceUiSkillDir(loadedManifest)), 'workspace'));
|
|
67
|
+
skills.push(...collectDirectorySkills(join(session.workspacePath, DEFAULT_UI_SKILL_DIR), 'workspace'));
|
|
97
68
|
}
|
|
98
69
|
|
|
99
70
|
const byName = new Map();
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
4
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import YAML from 'yaml';
|
|
7
|
+
import { checkMissingDockerImages } from './dockerImages.js';
|
|
5
8
|
import { patchWikircProfile } from './wikirc.js';
|
|
6
9
|
import { managerEnvFile, managerMcpEndpointsFile } from './env.js';
|
|
7
10
|
import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
8
11
|
|
|
9
12
|
const execFileAsync = promisify(execFile);
|
|
10
13
|
|
|
14
|
+
async function missingAgentImages() {
|
|
15
|
+
const composeFiles = [
|
|
16
|
+
join(managerRoot(), 'agents.docker-compose.yml'),
|
|
17
|
+
join(dirname(managerEnvFile()), 'agents.docker-compose.override.yml'),
|
|
18
|
+
].filter(existsSync);
|
|
19
|
+
const images = [...new Set(composeFiles.flatMap((filePath) => {
|
|
20
|
+
try {
|
|
21
|
+
const config = YAML.parse(readFileSync(filePath, 'utf8')) ?? {};
|
|
22
|
+
return Object.values(config.services ?? {}).map((service) => service?.image).filter(Boolean);
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}))];
|
|
27
|
+
return checkMissingDockerImages(images);
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
function wrapDockerError(err) {
|
|
12
31
|
if (err?.code === 'ENOENT') {
|
|
13
32
|
return new Error('wiki-workspace script not found. Reinstall wiki-manager.');
|
|
@@ -35,6 +54,8 @@ function wrapDockerError(err) {
|
|
|
35
54
|
|
|
36
55
|
export async function startAgents(options = {}) {
|
|
37
56
|
try {
|
|
57
|
+
const absentImages = await missingAgentImages();
|
|
58
|
+
if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
|
|
38
59
|
const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
|
|
39
60
|
cwd: managerRoot(),
|
|
40
61
|
env: {
|
|
@@ -51,7 +72,10 @@ export async function startAgents(options = {}) {
|
|
|
51
72
|
timeout: options.timeout ?? 180_000,
|
|
52
73
|
maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
|
|
53
74
|
});
|
|
54
|
-
return
|
|
75
|
+
return {
|
|
76
|
+
output: [stdout, stderr].filter(Boolean).join('\n').trim(),
|
|
77
|
+
missingImages: absentImages,
|
|
78
|
+
};
|
|
55
79
|
} catch (err) {
|
|
56
80
|
throw wrapDockerError(err);
|
|
57
81
|
}
|
|
@@ -81,7 +81,7 @@ test('recoveryManager requeues an active job by idempotencyKey when status is st
|
|
|
81
81
|
|
|
82
82
|
function storeWithActiveTask() {
|
|
83
83
|
const root = mkdtempSync(join(tmpdir(), 'wiki-manager-recovery-'));
|
|
84
|
-
const stateDir = join(root, '.wiki
|
|
84
|
+
const stateDir = join(root, '.wiki', 'runtime');
|
|
85
85
|
const store = openRuntimeStore({ stateDir });
|
|
86
86
|
const runId = 'run-recovery';
|
|
87
87
|
const taskId = 'run-recovery:build-a';
|
package/src/runtime/store.js
CHANGED
|
@@ -1261,7 +1261,10 @@ function ensureRuntimeMeta(metaPath) {
|
|
|
1261
1261
|
|
|
1262
1262
|
function migrateLegacyRuntimeMeta(metaPath) {
|
|
1263
1263
|
if (existsSync(metaPath)) return;
|
|
1264
|
-
const
|
|
1264
|
+
const stateDir = dirname(metaPath);
|
|
1265
|
+
const legacyPath = stateDir.endsWith(join('.wiki', 'runtime'))
|
|
1266
|
+
? join(stateDir, '..', 'meta.json')
|
|
1267
|
+
: join(stateDir, '..', '.wiki', 'meta.json');
|
|
1265
1268
|
if (!existsSync(legacyPath)) return;
|
|
1266
1269
|
mkdirSync(dirname(metaPath), { recursive: true });
|
|
1267
1270
|
renameSync(legacyPath, metaPath);
|
|
@@ -8,7 +8,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
8
8
|
import { openRuntimeStore } from './store.js';
|
|
9
9
|
|
|
10
10
|
function runtimeStateDir() {
|
|
11
|
-
return join(mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-')), '.wiki
|
|
11
|
+
return join(mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-')), '.wiki', 'runtime');
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function plannedTask(id) {
|
|
@@ -223,10 +223,10 @@ test('runtime store persists task assignments attempts and results from events',
|
|
|
223
223
|
|
|
224
224
|
test('runtime store writes schema versions to sqlite and meta file', () => {
|
|
225
225
|
const root = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
226
|
-
const stateDir = join(root, '.wiki
|
|
226
|
+
const stateDir = join(root, '.wiki', 'runtime');
|
|
227
227
|
const store = openRuntimeStore({ stateDir });
|
|
228
228
|
const version = store.db.prepare('PRAGMA user_version').get().user_version;
|
|
229
|
-
const meta = JSON.parse(readFileSync(join(root, '.wiki
|
|
229
|
+
const meta = JSON.parse(readFileSync(join(root, '.wiki', 'runtime', 'meta.json'), 'utf8'));
|
|
230
230
|
|
|
231
231
|
assert.equal(version, 1);
|
|
232
232
|
assert.equal(meta.schemaVersion, 1);
|
|
@@ -249,7 +249,7 @@ test('runtime store refuses unknown sqlite schema versions', () => {
|
|
|
249
249
|
|
|
250
250
|
test('runtime store refuses unknown runtime metadata versions', () => {
|
|
251
251
|
const root = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
252
|
-
const stateDir = join(root, '.wiki
|
|
252
|
+
const stateDir = join(root, '.wiki', 'runtime');
|
|
253
253
|
mkdirSync(stateDir, { recursive: true });
|
|
254
254
|
writeFileSync(join(stateDir, 'meta.json'), '{"schemaVersion":99}\n', 'utf8');
|
|
255
255
|
|
|
@@ -259,9 +259,9 @@ test('runtime store refuses unknown runtime metadata versions', () => {
|
|
|
259
259
|
);
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
-
test('runtime store migrates legacy .wiki metadata to .wiki
|
|
262
|
+
test('runtime store migrates legacy .wiki metadata to .wiki/runtime', () => {
|
|
263
263
|
const root = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
264
|
-
const stateDir = join(root, '.wiki
|
|
264
|
+
const stateDir = join(root, '.wiki', 'runtime');
|
|
265
265
|
mkdirSync(join(root, '.wiki'), { recursive: true });
|
|
266
266
|
writeFileSync(join(root, '.wiki', 'meta.json'), '{"schemaVersion":1,"legacy":true}\n', 'utf8');
|
|
267
267
|
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
3
3
|
import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
|
|
4
|
+
import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
|
|
4
5
|
|
|
5
6
|
const LEGACY_DONNA_ROLE = 'do' + 't';
|
|
6
7
|
|
|
@@ -47,7 +48,7 @@ function wrapLine(line: string, width: number) {
|
|
|
47
48
|
return out;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
type Segment = { text: string; color: string; width?: number; bg?: string };
|
|
51
|
+
type Segment = { text: string; color: string; width?: number; bg?: string; url?: string };
|
|
51
52
|
type RenderedLine = {
|
|
52
53
|
segments: Segment[];
|
|
53
54
|
status?: boolean;
|
|
@@ -300,6 +301,16 @@ function renderMarkdownLines(lines: Array<{ text: string; isCode: boolean }>, ro
|
|
|
300
301
|
const rendered = renderPlainMarkdown(text);
|
|
301
302
|
const isCmdDesc = splitCmdDesc(rendered) !== null;
|
|
302
303
|
const fallback = isCmdDesc ? '#FFFFFF' : colorForRenderedLine(rendered, role);
|
|
304
|
+
if (httpLinkParts(rendered).some((part: { url?: string }) => part.url)) {
|
|
305
|
+
output.push(...wrapHttpLinks(rendered, columns).map((parts: Array<{ text: string; url?: string }>) => ({
|
|
306
|
+
segments: parts.map((part) => ({
|
|
307
|
+
text: part.text || ' ',
|
|
308
|
+
color: part.url ? '#5DADE2' : fallback,
|
|
309
|
+
url: part.url,
|
|
310
|
+
})),
|
|
311
|
+
})));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
303
314
|
output.push(...wrapLine(rendered, columns).map((piece, idx) => ({
|
|
304
315
|
segments: idx === 0
|
|
305
316
|
? segmentsForLine(piece, role, columns)
|
|
@@ -489,7 +500,11 @@ export function ConversationView(props: {
|
|
|
489
500
|
) : (
|
|
490
501
|
<box height={1} flexDirection="row" overflow="hidden">
|
|
491
502
|
<For each={line.segments}>
|
|
492
|
-
{(seg: Segment) =>
|
|
503
|
+
{(seg: Segment) => (
|
|
504
|
+
<text width={seg.width} fg={seg.color} bg={seg.bg}>
|
|
505
|
+
{seg.url ? <a href={seg.url}>{seg.text}</a> : seg.text}
|
|
506
|
+
</text>
|
|
507
|
+
)}
|
|
493
508
|
</For>
|
|
494
509
|
</box>
|
|
495
510
|
)
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -20,6 +20,7 @@ type LogLineParts = { time: string | null; message: string };
|
|
|
20
20
|
// wrapped status/error), so fewer, readable entries beat more, truncated ones.
|
|
21
21
|
const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
|
|
22
22
|
const LOG_SLOTS = Array.from({ length: 24 }, (_, index) => index);
|
|
23
|
+
const PLAN_VIEWPORT_ROWS = 12;
|
|
23
24
|
|
|
24
25
|
function wrapLine(value: string, width: number) {
|
|
25
26
|
const max = Math.max(8, width);
|
|
@@ -131,29 +132,45 @@ function queueSummary(item: QueueItem) {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string }) {
|
|
134
|
-
|
|
135
|
+
// Keep one column for the native vertical scrollbar when the plan is long.
|
|
136
|
+
const lineWidth = () => Math.max(8, props.width - 3);
|
|
135
137
|
const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
|
|
136
138
|
const icon = (status: string) =>
|
|
137
139
|
status === 'done' ? '[✓]' : status === 'failed' ? '[✗]' : status === 'running' ? '[…]' : '[ ]';
|
|
138
|
-
const
|
|
140
|
+
const visualRows = createMemo(() => props.plan.reduce((total, step) =>
|
|
141
|
+
total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, lineWidth()).slice(0, 2).length, 0));
|
|
142
|
+
const title = () => {
|
|
143
|
+
const label = props.jobName ? `Plan : ${props.jobName}` : 'Plan';
|
|
144
|
+
return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
|
|
145
|
+
};
|
|
146
|
+
const viewportRows = () => Math.min(PLAN_VIEWPORT_ROWS, Math.max(1, visualRows()));
|
|
139
147
|
return (
|
|
140
148
|
<box flexShrink={0} flexDirection="column" padding={1}>
|
|
141
149
|
<text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
|
|
142
|
-
<
|
|
143
|
-
{(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
<scrollbox
|
|
151
|
+
height={viewportRows()}
|
|
152
|
+
scrollY={true}
|
|
153
|
+
scrollX={false}
|
|
154
|
+
stickyStart="top"
|
|
155
|
+
viewportCulling={true}
|
|
156
|
+
verticalScrollbarOptions={{ visible: visualRows() > PLAN_VIEWPORT_ROWS }}
|
|
157
|
+
>
|
|
158
|
+
<Index each={props.plan}>
|
|
159
|
+
{(step) => {
|
|
160
|
+
// Wrap step descriptions over up to 2 lines instead of truncating —
|
|
161
|
+
// "Ingest des 39 documents raw/untrac…" hid the actual target.
|
|
162
|
+
const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
|
|
163
|
+
return (
|
|
164
|
+
<box flexShrink={0} flexDirection="column">
|
|
165
|
+
<text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
|
|
166
|
+
<Show when={lines()[1]}>
|
|
167
|
+
<text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
|
|
168
|
+
</Show>
|
|
169
|
+
</box>
|
|
170
|
+
);
|
|
171
|
+
}}
|
|
172
|
+
</Index>
|
|
173
|
+
</scrollbox>
|
|
157
174
|
</box>
|
|
158
175
|
);
|
|
159
176
|
}
|
|
@@ -23,7 +23,7 @@ type Step =
|
|
|
23
23
|
| { kind: 'menu'; title: string; items: Array<{ label: string; value: string; muted?: boolean }> }
|
|
24
24
|
| { kind: 'confirm'; title: string; message: string; yesLabel: string; noLabel: string }
|
|
25
25
|
| { kind: 'select'; title: string; label: string; options: string[]; note?: string }
|
|
26
|
-
| { kind: 'text'; title: string; label: string; placeholder?: string; prefill?: string; secret?: boolean }
|
|
26
|
+
| { kind: 'text'; title: string; label: string; note?: string; placeholder?: string; prefill?: string; secret?: boolean }
|
|
27
27
|
| { kind: 'done' };
|
|
28
28
|
type LogEntry = { icon: string; label: string; detail?: string };
|
|
29
29
|
|
|
@@ -219,21 +219,32 @@ export function SetupWizard(props: {
|
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
if (currentRoute === 'llm-baseurl') {
|
|
222
|
-
const
|
|
223
|
-
return {
|
|
222
|
+
const example = defaultBaseUrl(llm().provider);
|
|
223
|
+
return {
|
|
224
|
+
kind: 'text',
|
|
225
|
+
title: 'LLM configuration',
|
|
226
|
+
label: `Base URL${example ? ` (example: ${example})` : ''}`,
|
|
227
|
+
prefill: llm().baseUrl || '',
|
|
228
|
+
};
|
|
224
229
|
}
|
|
225
230
|
if (currentRoute === 'llm-apikey') {
|
|
226
231
|
return { kind: 'text', title: 'LLM configuration', label: 'API key (required)', secret: true };
|
|
227
232
|
}
|
|
228
233
|
if (currentRoute === 'llm-model') {
|
|
229
|
-
const
|
|
230
|
-
return {
|
|
234
|
+
const example = fallbackModels(llm().provider)[0] || 'provider-agentic-model';
|
|
235
|
+
return {
|
|
236
|
+
kind: 'text',
|
|
237
|
+
title: 'LLM configuration',
|
|
238
|
+
label: `Model (example: ${example})`,
|
|
239
|
+
note: 'Required: an agentic model with tool/function calling support.',
|
|
240
|
+
prefill: llm().model || '',
|
|
241
|
+
};
|
|
231
242
|
}
|
|
232
243
|
if (currentRoute === 'vector-confirm') {
|
|
233
244
|
return { kind: 'confirm', title: 'Vector search', message: 'Configure vector search?', yesLabel: 'Enable', noLabel: 'Skip' };
|
|
234
245
|
}
|
|
235
246
|
if (currentRoute === 'vector-baseurl') {
|
|
236
|
-
const baseUrl = vector().baseUrl || llm().baseUrl
|
|
247
|
+
const baseUrl = vector().baseUrl || llm().baseUrl;
|
|
237
248
|
return { kind: 'text', title: 'Vector search', label: 'Embeddings/rerank base URL', prefill: baseUrl, placeholder: baseUrl };
|
|
238
249
|
}
|
|
239
250
|
if (currentRoute === 'vector-apikey') {
|
|
@@ -462,7 +473,10 @@ export function SetupWizard(props: {
|
|
|
462
473
|
if (currentRoute === 'llm-provider') {
|
|
463
474
|
const provider = normalizeProvider(value);
|
|
464
475
|
setLlm((old: any) => {
|
|
465
|
-
const
|
|
476
|
+
const asksForBaseUrl = provider === 'ollama' || provider === 'openai-compatible';
|
|
477
|
+
const baseUrl = (old.provider === provider && old.baseUrl)
|
|
478
|
+
? old.baseUrl
|
|
479
|
+
: asksForBaseUrl ? '' : defaultBaseUrl(provider);
|
|
466
480
|
return { ...old, provider, baseUrl, ...(provider === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
|
|
467
481
|
});
|
|
468
482
|
if (provider === 'ollama' || provider === 'openai-compatible') return navigate('llm-baseurl');
|
|
@@ -560,7 +574,8 @@ export function SetupWizard(props: {
|
|
|
560
574
|
return;
|
|
561
575
|
}
|
|
562
576
|
if (currentRoute === 'llm-baseurl') {
|
|
563
|
-
|
|
577
|
+
if (!value) return setError('Base URL is required.');
|
|
578
|
+
setLlm((old: any) => ({ ...old, baseUrl: value }));
|
|
564
579
|
if (llm().provider === 'ollama') return navigate('llm-model');
|
|
565
580
|
return navigate('llm-apikey');
|
|
566
581
|
}
|
|
@@ -570,7 +585,8 @@ export function SetupWizard(props: {
|
|
|
570
585
|
return navigate('llm-model');
|
|
571
586
|
}
|
|
572
587
|
if (currentRoute === 'vector-baseurl') {
|
|
573
|
-
const baseUrl = value || vector().baseUrl || llm().baseUrl
|
|
588
|
+
const baseUrl = value || vector().baseUrl || llm().baseUrl;
|
|
589
|
+
if (!baseUrl) return setError('Embeddings/rerank base URL is required.');
|
|
574
590
|
setVector((old: any) => ({ ...old, provider: llm().provider, baseUrl }));
|
|
575
591
|
return navigate('vector-apikey');
|
|
576
592
|
}
|
|
@@ -750,6 +766,9 @@ export function SetupWizard(props: {
|
|
|
750
766
|
<Show when={(step() as any).message || (step() as any).label}>
|
|
751
767
|
<text height={1} fg="#D6DEE8">{(step() as any).message ?? (step() as any).label}</text>
|
|
752
768
|
</Show>
|
|
769
|
+
<Show when={(step() as any).note}>
|
|
770
|
+
<text height={1} fg="#9CA3AF">{(step() as any).note}</text>
|
|
771
|
+
</Show>
|
|
753
772
|
<Show when={step().kind === 'text'}>
|
|
754
773
|
<box
|
|
755
774
|
height={5}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const HTTP_URL = /https?:\/\/[^\s<>"'`]+/giu;
|
|
2
|
+
const TRAILING_PUNCTUATION = /[.,;:!?]+$/u;
|
|
3
|
+
|
|
4
|
+
function trimUrlEnd(value) {
|
|
5
|
+
let url = value.replace(TRAILING_PUNCTUATION, '');
|
|
6
|
+
while (url.endsWith(')') && (url.match(/\)/g)?.length ?? 0) > (url.match(/\(/g)?.length ?? 0)) {
|
|
7
|
+
url = url.slice(0, -1);
|
|
8
|
+
}
|
|
9
|
+
while (url.endsWith(']') && (url.match(/\]/g)?.length ?? 0) > (url.match(/\[/g)?.length ?? 0)) {
|
|
10
|
+
url = url.slice(0, -1);
|
|
11
|
+
}
|
|
12
|
+
return url;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function httpLinkParts(value) {
|
|
16
|
+
const text = String(value ?? '');
|
|
17
|
+
const parts = [];
|
|
18
|
+
let cursor = 0;
|
|
19
|
+
for (const match of text.matchAll(HTTP_URL)) {
|
|
20
|
+
const start = match.index ?? 0;
|
|
21
|
+
const candidate = match[0];
|
|
22
|
+
const url = trimUrlEnd(candidate);
|
|
23
|
+
if (!url) continue;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(url);
|
|
26
|
+
if (!parsed.hostname || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) continue;
|
|
27
|
+
} catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (start > cursor) parts.push({ text: text.slice(cursor, start) });
|
|
31
|
+
parts.push({ text: url, url });
|
|
32
|
+
cursor = start + url.length;
|
|
33
|
+
}
|
|
34
|
+
if (cursor < text.length) parts.push({ text: text.slice(cursor) });
|
|
35
|
+
return parts.length > 0 ? parts : [{ text }];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function wrapHttpLinks(value, width) {
|
|
39
|
+
const columns = Math.max(12, Number(width) || 12);
|
|
40
|
+
const rows = [[]];
|
|
41
|
+
let used = 0;
|
|
42
|
+
for (const part of httpLinkParts(value)) {
|
|
43
|
+
if (part.url) {
|
|
44
|
+
const parsed = new URL(part.url);
|
|
45
|
+
const label = `[link: ${parsed.hostname}]`;
|
|
46
|
+
if (used > 0 && used + label.length > columns) {
|
|
47
|
+
rows.push([]);
|
|
48
|
+
used = 0;
|
|
49
|
+
}
|
|
50
|
+
rows.at(-1).push({ text: label, url: part.url });
|
|
51
|
+
used += label.length;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
let rest = part.text;
|
|
55
|
+
while (rest.length > 0) {
|
|
56
|
+
if (used === columns) {
|
|
57
|
+
rows.push([]);
|
|
58
|
+
used = 0;
|
|
59
|
+
}
|
|
60
|
+
const piece = rest.slice(0, columns - used);
|
|
61
|
+
rows.at(-1).push({ text: piece });
|
|
62
|
+
used += piece.length;
|
|
63
|
+
rest = rest.slice(piece.length);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return rows;
|
|
67
|
+
}
|
package/src/shell/repl.js
CHANGED
|
@@ -82,7 +82,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
|
|
|
82
82
|
'/uploads': 'List or clean uploaded documents.',
|
|
83
83
|
'/clear': 'Clear the conversation screen.',
|
|
84
84
|
'/chat': 'Switch free text to direct LLM chat without tools.',
|
|
85
|
-
'/agent': 'Switch
|
|
85
|
+
'/agent': 'Switch to agent mode, or run one agent request with /agent <question>.',
|
|
86
86
|
'/openui': 'Open the workspace web UI in the browser.',
|
|
87
87
|
'/run': 'Inspect, cancel, kill runtime runs, or start a capability run.',
|
|
88
88
|
'/approve': 'Approve a pending runtime run or tool.',
|
|
@@ -797,8 +797,73 @@ function rememberProductionActivity(session, payload) {
|
|
|
797
797
|
return true;
|
|
798
798
|
}
|
|
799
799
|
|
|
800
|
+
const TERMINAL_PLAN_STATUSES = new Set(['done', 'completed', 'succeeded', 'success', 'failed', 'error', 'cancelled', 'canceled']);
|
|
801
|
+
|
|
802
|
+
function planTaskKey(task, index) {
|
|
803
|
+
return String(task?.id ?? task?.step ?? `task-${index + 1}`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function errorDescription(value) {
|
|
807
|
+
if (!value) return null;
|
|
808
|
+
if (typeof value === 'string') return value.trim() || null;
|
|
809
|
+
if (value instanceof Error) return value.message;
|
|
810
|
+
if (typeof value === 'object') {
|
|
811
|
+
return errorDescription(value.message ?? value.reason ?? value.detail ?? value.description ?? value.error)
|
|
812
|
+
?? (() => { try { return JSON.stringify(value); } catch { return String(value); } })();
|
|
813
|
+
}
|
|
814
|
+
return String(value);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function planTaskError(task, state) {
|
|
818
|
+
const activityKey = task?.activityKey ?? task?._activityKey ?? task?.ownerActivityKey;
|
|
819
|
+
const activity = (state?.activities ?? []).find((item) =>
|
|
820
|
+
(activityKey && String(item?.key) === String(activityKey))
|
|
821
|
+
|| (task?.id != null && String(item?.taskId ?? '') === String(task.id)));
|
|
822
|
+
return errorDescription(
|
|
823
|
+
task?.error
|
|
824
|
+
?? task?.result?.error
|
|
825
|
+
?? task?.result?.reason
|
|
826
|
+
?? activity?.error
|
|
827
|
+
?? activity?.detail
|
|
828
|
+
?? (['failed', 'error'].includes(String(state?.status ?? '').toLowerCase()) ? state?.evaluation?.reason : null),
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function appendRuntimePlanCompletionMessages(session, state) {
|
|
833
|
+
const plan = Array.isArray(state?.plan) ? state.plan : [];
|
|
834
|
+
const runId = String(state?.runId ?? 'runtime');
|
|
835
|
+
const current = new Map(plan.map((task, index) => [planTaskKey(task, index), String(task?.status ?? 'pending').toLowerCase()]));
|
|
836
|
+
if (session._runtimePlanRunId !== runId || !(session._runtimePlanStatuses instanceof Map)) {
|
|
837
|
+
session._runtimePlanRunId = runId;
|
|
838
|
+
session._runtimePlanStatuses = current;
|
|
839
|
+
return 0;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
let appended = 0;
|
|
843
|
+
plan.forEach((task, index) => {
|
|
844
|
+
const key = planTaskKey(task, index);
|
|
845
|
+
const status = current.get(key);
|
|
846
|
+
const previous = session._runtimePlanStatuses.get(key);
|
|
847
|
+
if (!TERMINAL_PLAN_STATUSES.has(status) || TERMINAL_PLAN_STATUSES.has(previous)) return;
|
|
848
|
+
const label = String(task?.label ?? task?.description ?? task?.name ?? `Job ${key}`);
|
|
849
|
+
const failed = status === 'failed' || status === 'error';
|
|
850
|
+
const cancelled = status === 'cancelled' || status === 'canceled';
|
|
851
|
+
const error = failed ? planTaskError(task, state) : null;
|
|
852
|
+
const content = failed
|
|
853
|
+
? `✗ Job terminé en erreur : ${label}\nStatus: ${status}${error ? `\nErreur: ${error}` : ''}`
|
|
854
|
+
: cancelled
|
|
855
|
+
? `○ Job terminé : ${label}\nStatus: ${status}`
|
|
856
|
+
: `✓ Job terminé : ${label}\nStatus: ${status}`;
|
|
857
|
+
conversationMessages(session).push({ role: 'command', content });
|
|
858
|
+
appended += 1;
|
|
859
|
+
});
|
|
860
|
+
session._runtimePlanStatuses = current;
|
|
861
|
+
return appended;
|
|
862
|
+
}
|
|
863
|
+
|
|
800
864
|
export function applyRuntimeStateToShellSession(session, state) {
|
|
801
865
|
if (!state || typeof state !== 'object') return false;
|
|
866
|
+
appendRuntimePlanCompletionMessages(session, state);
|
|
802
867
|
const displayState = sanitizeRuntimeStateForDisplay(state);
|
|
803
868
|
session.agentProjection = {
|
|
804
869
|
conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
|
|
@@ -872,6 +937,33 @@ export function shouldHandleFreeTextLocally(_line, session, { llmAvailable = Boo
|
|
|
872
937
|
return { local: true, classification };
|
|
873
938
|
}
|
|
874
939
|
|
|
940
|
+
// Shared by both the one-shot `/agent <question>` path in runLine and the
|
|
941
|
+
// interactive free-text-to-runtime path below: maps a submitRuntimeRun()
|
|
942
|
+
// outcome to the conversation message(s) it produces and a short log line,
|
|
943
|
+
// so the classification → message mapping isn't duplicated per call site.
|
|
944
|
+
function applyRuntimeOutcome(session, outcome, onLog, {
|
|
945
|
+
ambiguousFallback = 'Runtime could not classify that message.',
|
|
946
|
+
} = {}) {
|
|
947
|
+
if (outcome.kind === 'accepted') {
|
|
948
|
+
onLog('runtime: run accepted');
|
|
949
|
+
} else if (outcome.kind === 'queued') {
|
|
950
|
+
conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Request added to the queue.') });
|
|
951
|
+
onLog('runtime: control queued');
|
|
952
|
+
} else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
|
|
953
|
+
conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime control message accepted.') });
|
|
954
|
+
onLog(`runtime: ${outcome.kind}`);
|
|
955
|
+
} else if (outcome.kind === 'ambiguous') {
|
|
956
|
+
conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? ambiguousFallback) });
|
|
957
|
+
onLog('runtime: ambiguous control');
|
|
958
|
+
} else if (outcome.result?.explanation) {
|
|
959
|
+
conversationMessages(session).push({ role: 'command', content: String(outcome.result.explanation) });
|
|
960
|
+
onLog(`runtime: ${outcome.kind}`);
|
|
961
|
+
} else {
|
|
962
|
+
conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
|
963
|
+
onLog(`runtime error: ${outcome.message}`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
875
967
|
export async function submitRuntimeRun(line, { runtime, session }) {
|
|
876
968
|
const workspace = session.workspace ?? null;
|
|
877
969
|
try {
|
|
@@ -1339,6 +1431,22 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
|
|
|
1339
1431
|
return { exit: false };
|
|
1340
1432
|
}
|
|
1341
1433
|
|
|
1434
|
+
const oneShotAgentInput = /^\/agent\s+(.+)$/s.exec(trimmed)?.[1]?.trim();
|
|
1435
|
+
if (oneShotAgentInput) {
|
|
1436
|
+
if (!runtime?.url) {
|
|
1437
|
+
const message = recordRuntimeUnavailableAgentInput(session, oneShotAgentInput, runtime);
|
|
1438
|
+
onStep?.(message ?? 'runtime: disconnected');
|
|
1439
|
+
onUpdate?.();
|
|
1440
|
+
return { exit: false, oneShotAgent: true };
|
|
1441
|
+
}
|
|
1442
|
+
conversationMessages(session).push({ role: 'user', content: oneShotAgentInput });
|
|
1443
|
+
onUpdate?.();
|
|
1444
|
+
const outcome = await submitRuntimeRun(oneShotAgentInput, { runtime, session });
|
|
1445
|
+
applyRuntimeOutcome(session, outcome, (msg) => onStep?.(msg));
|
|
1446
|
+
onUpdate?.();
|
|
1447
|
+
return { exit: false, oneShotAgent: true, runtimeOutcome: outcome };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1342
1450
|
if (trimmed.startsWith('/')) {
|
|
1343
1451
|
onStep?.(`Shell: ${trimmed}`);
|
|
1344
1452
|
const result = await handleSlashCommand(trimmed, { packageJson, session, onStep, runtime });
|
|
@@ -1755,28 +1863,12 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1755
1863
|
}
|
|
1756
1864
|
conversationMessages(session).push({ role: 'user', content: line });
|
|
1757
1865
|
const outcome = await submitRuntimeRun(line, { runtime, session });
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
} else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
|
|
1765
|
-
const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
|
|
1766
|
-
conversationMessages(session).push({ role: 'command', content: explanation });
|
|
1767
|
-
activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
|
|
1768
|
-
} else if (outcome.kind === 'ambiguous') {
|
|
1769
|
-
conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.') });
|
|
1770
|
-
activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
|
|
1771
|
-
} else if (outcome.result?.explanation) {
|
|
1772
|
-
// cancel / approve / modify_run and any future control kinds:
|
|
1773
|
-
// always surface the server's localized explanation.
|
|
1774
|
-
conversationMessages(session).push({ role: 'command', content: String(outcome.result.explanation) });
|
|
1775
|
-
activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
|
|
1776
|
-
} else {
|
|
1777
|
-
conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
|
1778
|
-
activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
|
|
1779
|
-
}
|
|
1866
|
+
applyRuntimeOutcome(
|
|
1867
|
+
session,
|
|
1868
|
+
outcome,
|
|
1869
|
+
(msg) => { activityLines = [...activityLines, msg].slice(-LOWER_DETAIL_ROWS); },
|
|
1870
|
+
{ ambiguousFallback: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' },
|
|
1871
|
+
);
|
|
1780
1872
|
syncRuntimeState();
|
|
1781
1873
|
} else if (!session.chatMode && !line.trim().startsWith('/')) {
|
|
1782
1874
|
const message = recordRuntimeUnavailableAgentInput(session, line, runtime);
|
package/src/shell/repl.test.js
CHANGED
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import {
|
|
7
7
|
applyRuntimeStateToShellSession,
|
|
8
|
+
appendRuntimePlanCompletionMessages,
|
|
8
9
|
chatReadTools,
|
|
9
10
|
createSession,
|
|
10
11
|
runHeadlessChatTurn,
|
|
@@ -17,6 +18,32 @@ import {
|
|
|
17
18
|
shouldHandleFreeTextLocally,
|
|
18
19
|
submitRuntimeRun,
|
|
19
20
|
} from './repl.js';
|
|
21
|
+
import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
|
|
22
|
+
|
|
23
|
+
test('ShellUI turns HTTP URLs into valid links without trailing punctuation', () => {
|
|
24
|
+
assert.deepEqual(httpLinkParts('Voir https://example.test/docs?q=ok.'), [
|
|
25
|
+
{ text: 'Voir ' },
|
|
26
|
+
{ text: 'https://example.test/docs?q=ok', url: 'https://example.test/docs?q=ok' },
|
|
27
|
+
{ text: '.' },
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('long ShellUI URLs use one short label with the complete link target', () => {
|
|
32
|
+
const url = 'https://example.test/a/very/long/document';
|
|
33
|
+
const links = wrapHttpLinks(url, 12).flat().filter((part) => part.url);
|
|
34
|
+
assert.deepEqual(links, [{ text: '[link: example.test]', url }]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('ShellUI never splits a link label at the end of a line', () => {
|
|
38
|
+
const url = 'https://example.test/a/very/long/document';
|
|
39
|
+
const rows = wrapHttpLinks(`Voir maintenant ${url}`, 20);
|
|
40
|
+
assert.equal(rows.length, 2);
|
|
41
|
+
assert.deepEqual(rows[1], [{ text: '[link: example.test]', url }]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('ShellUI leaves malformed URLs as plain text', () => {
|
|
45
|
+
assert.deepEqual(httpLinkParts('Erreur: https://'), [{ text: 'Erreur: https://' }]);
|
|
46
|
+
});
|
|
20
47
|
|
|
21
48
|
test('runtime display preserves a failed plan and its diagnostic evidence', () => {
|
|
22
49
|
const state = {
|
|
@@ -131,6 +158,47 @@ test('applyRuntimeStateToShellSession clears terminal plan and activities when r
|
|
|
131
158
|
assert.deepEqual(session.agentProjection.planPatches, []);
|
|
132
159
|
});
|
|
133
160
|
|
|
161
|
+
test('runtime plan completion is appended once to the ShellUI conversation', () => {
|
|
162
|
+
const session = createSession();
|
|
163
|
+
applyRuntimeStateToShellSession(session, {
|
|
164
|
+
runId: 'run-1',
|
|
165
|
+
status: 'running',
|
|
166
|
+
plan: [{ id: 'build', label: 'Build documentation', status: 'running' }],
|
|
167
|
+
});
|
|
168
|
+
applyRuntimeStateToShellSession(session, {
|
|
169
|
+
runId: 'run-1',
|
|
170
|
+
status: 'running',
|
|
171
|
+
plan: [{ id: 'build', label: 'Build documentation', status: 'done' }],
|
|
172
|
+
});
|
|
173
|
+
applyRuntimeStateToShellSession(session, {
|
|
174
|
+
runId: 'run-1',
|
|
175
|
+
status: 'done',
|
|
176
|
+
plan: [{ id: 'build', label: 'Build documentation', status: 'done' }],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.deepEqual(conversationMessages(session), [{
|
|
180
|
+
role: 'command',
|
|
181
|
+
content: '✓ Job terminé : Build documentation\nStatus: done',
|
|
182
|
+
}]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('runtime plan failure includes its available error description', () => {
|
|
186
|
+
const session = createSession();
|
|
187
|
+
appendRuntimePlanCompletionMessages(session, {
|
|
188
|
+
runId: 'run-2',
|
|
189
|
+
plan: [{ id: 'ingest', description: 'Ingest pages', status: 'running' }],
|
|
190
|
+
});
|
|
191
|
+
appendRuntimePlanCompletionMessages(session, {
|
|
192
|
+
runId: 'run-2',
|
|
193
|
+
plan: [{ id: 'ingest', description: 'Ingest pages', status: 'failed', result: { error: { message: 'Index unavailable' } } }],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
assert.deepEqual(conversationMessages(session), [{
|
|
197
|
+
role: 'command',
|
|
198
|
+
content: '✗ Job terminé en erreur : Ingest pages\nStatus: failed\nErreur: Index unavailable',
|
|
199
|
+
}]);
|
|
200
|
+
});
|
|
201
|
+
|
|
134
202
|
test('direct chat system prompt forbids unsolicited next steps', async () => {
|
|
135
203
|
const session = createSession();
|
|
136
204
|
let systemPrompt = '';
|
|
@@ -223,6 +291,48 @@ test('submitRuntimeRun reports a non-409 error without throwing or calling /cont
|
|
|
223
291
|
}
|
|
224
292
|
});
|
|
225
293
|
|
|
294
|
+
test('/agent <question> submits one runtime request and remains in chat mode', async () => {
|
|
295
|
+
const restore = stubFetch(async (url, init) => {
|
|
296
|
+
assert.equal(pathOf(url), '/run');
|
|
297
|
+
assert.equal(JSON.parse(String(init.body)).input, 'lance ingestion');
|
|
298
|
+
return jsonResponse(202, { accepted: true, runId: 'run-one-shot' });
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
const session = createSession();
|
|
302
|
+
session.chatMode = true;
|
|
303
|
+
const result = await runLine('/agent lance ingestion', {
|
|
304
|
+
agent: null,
|
|
305
|
+
packageJson: { version: 'test' },
|
|
306
|
+
session,
|
|
307
|
+
runtime: { url: 'http://runtime.test' },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
assert.equal(session.chatMode, true);
|
|
311
|
+
assert.equal(result.oneShotAgent, true);
|
|
312
|
+
assert.equal(result.runtimeOutcome.kind, 'accepted');
|
|
313
|
+
assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion' }]);
|
|
314
|
+
} finally {
|
|
315
|
+
restore();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('/agent <question> reports an unavailable runtime without leaving chat mode', async () => {
|
|
320
|
+
const session = createSession();
|
|
321
|
+
session.chatMode = true;
|
|
322
|
+
|
|
323
|
+
const result = await runLine('/agent lance ingestion', {
|
|
324
|
+
agent: null,
|
|
325
|
+
packageJson: { version: 'test' },
|
|
326
|
+
session,
|
|
327
|
+
runtime: { error: 'runtime stopped' },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
assert.equal(session.chatMode, true);
|
|
331
|
+
assert.equal(result.oneShotAgent, true);
|
|
332
|
+
assert.deepEqual(conversationMessages(session).map((message) => message.role), ['user', 'command']);
|
|
333
|
+
assert.match(conversationMessages(session).at(-1).content, /runtime stopped/);
|
|
334
|
+
});
|
|
335
|
+
|
|
226
336
|
test('runLine does not update workspace profile before Donna handles the request', async () => {
|
|
227
337
|
const workspacePath = mkdtempSync(join(tmpdir(), 'donna-profile-shell-'));
|
|
228
338
|
mkdirSync(join(workspacePath, '.wiki'), { recursive: true });
|
package/wiki-workspace
CHANGED
|
@@ -17,7 +17,12 @@ WORKSPACES_DIR="${WIKI_WORKSPACES_DIR:-$DEFAULT_MANAGER_DIR/workspaces}"
|
|
|
17
17
|
MANAGER_ENV_FILE="${WIKI_MANAGER_ENV_FILE:-$DEFAULT_MANAGER_DIR/.env}"
|
|
18
18
|
MANAGER_ENDPOINTS_FILE="${WIKI_MANAGER_ENDPOINTS_FILE:-$DEFAULT_MANAGER_DIR/mcp.endpoints.json}"
|
|
19
19
|
MANAGER_STATE_DIR="$(cd "$(dirname "$MANAGER_ENV_FILE")" && pwd)"
|
|
20
|
-
MANAGER_RUNTIME_DIR="$MANAGER_STATE_DIR/.wiki
|
|
20
|
+
MANAGER_RUNTIME_DIR="$MANAGER_STATE_DIR/.wiki/runtime"
|
|
21
|
+
LEGACY_MANAGER_RUNTIME_DIR="$MANAGER_STATE_DIR/.wiki-manager"
|
|
22
|
+
if [[ ! -e "$MANAGER_RUNTIME_DIR" && -d "$LEGACY_MANAGER_RUNTIME_DIR" ]]; then
|
|
23
|
+
mkdir -p "$MANAGER_STATE_DIR/.wiki"
|
|
24
|
+
mv "$LEGACY_MANAGER_RUNTIME_DIR" "$MANAGER_RUNTIME_DIR"
|
|
25
|
+
fi
|
|
21
26
|
CACERT_PATH="${WIKI_MANAGER_CACERT_PATH:-}"
|
|
22
27
|
|
|
23
28
|
normalize_path() {
|