@dotdrelle/wiki-manager 0.14.9 → 0.14.12

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 CHANGED
@@ -16,6 +16,20 @@ 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
+ # Optional organization-approved HTTPS endpoint for the ShellUI startup probe.
31
+ # WIKI_MANAGER_CONNECTIVITY_URL=https://registry.npmjs.org/-/ping
32
+
19
33
  # ── Agent auth tokens ──────────────────────────────────────────────────────────
20
34
  #
21
35
  # Each agent has its own bearer token. Set the matching Authorization header
@@ -39,7 +53,7 @@ DOCUMENTS_MCP_AUTH_TOKEN=
39
53
  # All image OCR is delegated to an OpenAI-compatible vision LLM. The default
40
54
  # Default target is the Albert API (etalab); set DOCUMENT_LLM_API_KEY.
41
55
  # DOCUMENT_LLM_BASE_URL=https://albert.api.etalab.gouv.fr/v1
42
- # DOCUMENT_LLM_MODEL=LightOnOCR-2-1B
56
+ # DOCUMENT_LLM_MODEL=lightonai/LightOnOCR-2-1B
43
57
  # DOCUMENT_LLM_API_KEY=
44
58
  # DOCUMENT_LLM_TIMEOUT_SECONDS=120
45
59
 
package/README.md CHANGED
@@ -8,7 +8,7 @@ endpoints, and provides the `donna` shell: an agent-first terminal UI that can
8
8
  inspect workspaces, run safe manager commands, call MCP tools, guide production
9
9
  jobs, and run one-shot headless tasks.
10
10
 
11
- Current coordinated release: **0.14.5**. Managed `llm-wiki` services expose
11
+ Current coordinated release: **0.14.11**. Managed `llm-wiki` services expose
12
12
  the Wiki Graph v2 browser and APIs; rebuild the `llm-wiki` image when deploying
13
13
  this release through Docker.
14
14
 
@@ -86,6 +86,27 @@ npm install --global --allow-scripts=bun @dotdrelle/wiki-manager
86
86
  In both modes, launch the commands from the directory that should hold the
87
87
  manager state (`workspaces/`, `.env`, and `mcp.endpoints.json`).
88
88
 
89
+ At interactive ShellUI startup, the manager checks Docker availability first,
90
+ then outbound HTTPS connectivity, followed by agent and workspace
91
+ configuration, workspace containers, MCP handshakes (`tools/list`), and the
92
+ Donna runtime. The connectivity probe uses the configured HTTP(S) proxy and
93
+ custom CA settings. MCP diagnostics distinguish configuration, authentication,
94
+ reachability, and protocol failures. Remote MCPs are skipped when Internet is
95
+ offline, while local endpoints are still checked; MCPs are also checked when
96
+ Docker is unavailable because they may run elsewhere.
97
+
98
+ The startup screen reports **Ready**, **Degraded**, or **Setup required** and
99
+ keeps the shell usable in degraded mode. It offers **Retry pending checks**,
100
+ **Start services**, and **Open diagnostics** (service logs plus detailed MCP
101
+ status). Set
102
+ `WIKI_MANAGER_CONNECTIVITY_URL` only when the default npm-registry ping endpoint
103
+ must be replaced by an organization-approved HTTPS endpoint.
104
+
105
+ After a workspace is loaded, ShellUI displays `/status` automatically as the
106
+ first operational view. When **Open workspace** also starts services, the status
107
+ snapshot is produced afterwards so it reflects their current container and MCP
108
+ state.
109
+
89
110
  ## Functional overview
90
111
 
91
112
  The diagram below shows the whole picture at a glance: how **inputs** (external
@@ -664,7 +685,18 @@ Original files are stored under
664
685
  the documents agent to `<workspace>/raw/untracked/`. If the documents agent is
665
686
  down, the upload remains stored and can be converted later.
666
687
  Image files, scanned PDFs, and images detected inside PDF or Office documents
667
- are sent through LLM OCR automatically.
688
+ are sent through LLM OCR automatically. The bundled agent stack defaults to
689
+ the Albert-compatible endpoint and
690
+ [`lightonai/LightOnOCR-2-1B`](https://huggingface.co/lightonai/LightOnOCR-2-1B);
691
+ configure it with `DOCUMENT_LLM_BASE_URL`, `DOCUMENT_LLM_MODEL`, and the
692
+ dedicated `DOCUMENT_LLM_API_KEY` (an ambient `OPENAI_API_KEY` is not used).
693
+
694
+ In the served web Chat, a successful conversion also adds the new
695
+ `raw/untracked/*.md` path as a document-context badge. Up to five wiki or
696
+ pending Markdown documents can be selected. Only their paths are sent to
697
+ Donna; she reads the relevant documents through the configured read-only MCP
698
+ tools when the question refers to them. This does not ingest the document:
699
+ `wiki ingest` remains the explicit transition into the durable wiki.
668
700
 
669
701
  ## The `donna` Shell
670
702
 
@@ -701,6 +733,14 @@ The TUI uses a two-pane layout:
701
733
  `Ctrl+Q` toggles the tabs; clicking `Plan` or `Queue (N)` selects that tab
702
734
  directly. MCP connection details remain available through `/mcp status`.
703
735
 
736
+ In the served browser Activity panel, `Clear` is a per-tab display cleanup and
737
+ `Clear all` applies it to Plan, Local activity, Runtime activity, and Logs. It
738
+ does not delete a plan. **Reset plan** is the confirmed destructive operation:
739
+ it stops active work and purges the workspace runtime plan, activities, logs,
740
+ queue, and persisted runtime state. Donna can perform the same operation when
741
+ the user explicitly asks to delete, reset, abandon, or replace the current
742
+ plan. A request that only says to stop or cancel remains non-purging.
743
+
704
744
  Useful primitives:
705
745
 
706
746
  ```text
@@ -13,7 +13,7 @@
13
13
  # CME_MCP_AUTH_TOKEN — bearer token for cme agent (empty = no auth)
14
14
  # DOCUMENTS_MCP_AUTH_TOKEN — bearer token for documents agent (empty = no auth)
15
15
  # DOCUMENT_LLM_BASE_URL — https://albert.api.etalab.gouv.fr/v1
16
- # DOCUMENT_LLM_MODEL — LightOnOCR-2-1B
16
+ # DOCUMENT_LLM_MODEL — lightonai/LightOnOCR-2-1B
17
17
  # DOCUMENT_LLM_API_KEY — OpenAI/OpenAI-compatible API key for document OCR
18
18
  # DOCUMENT_LLM_TIMEOUT_SECONDS — 120
19
19
  #
@@ -63,9 +63,9 @@ services:
63
63
  - DOCUMENT_OUTPUT_DIR=/documents/output
64
64
  - WORKSPACES_ROOT=/workspaces
65
65
  - DOCUMENT_MAX_UPLOAD_BYTES=${DOCUMENT_MAX_UPLOAD_BYTES:-52428800}
66
- - DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://api.openai.com/v1}
67
- - DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-gpt-5.4-mini}
68
- - DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-${OPENAI_API_KEY:-}}
66
+ - DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://albert.api.etalab.gouv.fr/v1}
67
+ - DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-lightonai/LightOnOCR-2-1B}
68
+ - DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-}
69
69
  - DOCUMENT_LLM_TIMEOUT_SECONDS=${DOCUMENT_LLM_TIMEOUT_SECONDS:-120}
70
70
  # HTTPS — uncomment and place cert files in AGENTS_DATA_DIR/certs/
71
71
  #- MCP_SSL_CERTFILE=/certs/server.crt
@@ -76,4 +76,3 @@ services:
76
76
  - ${WORKSPACES_ROOT:?Set WORKSPACES_ROOT to the directory containing all workspace folders}:/workspaces
77
77
  #- ${AGENTS_DATA_DIR:-./.agents-data}/certs:/certs:ro
78
78
  restart: unless-stopped
79
-
@@ -27,9 +27,9 @@
27
27
  }
28
28
  },
29
29
  "chatAccess": {
30
- "maxToolIterations": 6,
30
+ "maxToolIterations": 8,
31
31
  "servers": {
32
- "wiki": { "allow": ["help_list", "help_read"] },
32
+ "wiki": { "allow": ["help_list", "help_read", "wiki_workspace_status", "wiki_list_pages", "wiki_read_page", "wiki_read_pages", "wiki_search_context", "wiki_collect_context", "wiki_read_ingested_source"] },
33
33
  "production": { "allow": ["production_job_status", "production_jobs_list"] },
34
34
  "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] }
35
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.14.9",
3
+ "version": "0.14.12",
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/env.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",
@@ -120,7 +120,10 @@ const RUNTIME_KILL_TOOL = {
120
120
  function: {
121
121
  name: 'runtime__kill',
122
122
  description: 'Hard-stop the workspace runtime: abort the active run, cancel its agent jobs, mark persisted runs interrupted and purge the control queue. Use when the user asks to remove/kill/clean the current run, its jobs or the queue.',
123
- parameters: { type: 'object', additionalProperties: false, properties: { runId: { type: 'string', description: 'Optional specific run id; omit to kill everything active in the workspace.' } } },
123
+ parameters: { type: 'object', additionalProperties: false, properties: {
124
+ runId: { type: 'string', description: 'Optional specific run id; omit to kill everything active in the workspace.' },
125
+ purge: { type: 'boolean', description: 'Set true only when the user explicitly asks to delete/reset/replace the current plan and runtime history.' },
126
+ } },
124
127
  },
125
128
  };
126
129
 
@@ -694,8 +697,8 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
694
697
  const workspace = session.workspace ?? null;
695
698
  try {
696
699
  if (tool === 'kill') {
697
- const result = await postRuntimeKill({ url, workspace, runId: args.runId ?? null });
698
- return `Runtime killed: ${result.runs ?? 0} run(s) interrupted, ${result.tasks ?? 0} task(s) cancelled, ${result.queued ?? 0} queued control request(s) purged.`;
700
+ const result = await postRuntimeKill({ url, workspace, runId: args.runId ?? null, purge: args.purge === true });
701
+ return `Runtime killed: ${result.runs ?? 0} run(s) interrupted, ${result.tasks ?? 0} task(s) cancelled, ${result.queued ?? 0} queued control request(s) purged${result.purged ? `; runtime state reset (${result.purged.events ?? 0} event(s))` : ''}.`;
699
702
  }
700
703
  if (tool === 'cancel') {
701
704
  const result = await postRuntimeCancel({ url, workspace });
@@ -942,7 +945,7 @@ export function buildAgentSystemPrompt(state) {
942
945
  workspaceProfile
943
946
  ? `Workspace profile (.wiki/profile.md) — durable user preferences, apply these to every reply (tone, tutoiement/vouvoiement, formatting, etc.):\n${workspaceProfile}`
944
947
  : null,
945
- 'Runtime control: you have runtime__status, runtime__cancel, runtime__kill, runtime__approve and runtime__enqueue. When the user asks to stop, remove, clean or kill the current run, its jobs or the queue ("supprime le job et la queue", "arr\u00eate tout"), call runtime__kill (or runtime__cancel for a soft stop of just the run) and confirm what was stopped. For questions about what is running or queued, call runtime__status and answer from its data. When the user consents to a pending approval in any phrasing ("vas-y", "ok pour l\'export"), call runtime__approve. When the user asks for a NEW action while a run is active, do not execute it: propose runtime__enqueue (run it after) or, if they insist it replaces the current work, runtime__kill then the new action.',
948
+ 'Runtime control: you have runtime__status, runtime__cancel, runtime__kill, runtime__approve and runtime__enqueue. When the user asks to stop, remove, clean or kill the current run, its jobs or the queue ("supprime le job et la queue", "arr\u00eate tout"), call runtime__kill (or runtime__cancel for a soft stop of just the run) and confirm what was stopped. When the user explicitly asks to delete, reset, abandon or replace the current plan, call runtime__kill with purge=true; never set purge=true for a simple stop. For questions about what is running or queued, call runtime__status and answer from its data. When the user consents to a pending approval in any phrasing ("vas-y", "ok pour l\'export"), call runtime__approve. When the user asks for a NEW action while a run is active, do not execute it: propose runtime__enqueue (run it after) or, if they insist it replaces the current work, runtime__kill then the new action.',
946
949
  'Report every runtime control outcome exactly as the tool returned it \u2014 never embellish. If runtime__kill reports 0 run(s)/0 task(s)/0 purged, say there was nothing active to stop or purge; do NOT claim a run, plan, pending approval or queue item was removed. If runtime__status returns an error or could not be read, say the runtime state could not be retrieved and do not describe a state you never obtained. Never assert that something was cleaned, cancelled, approved or purged unless that specific tool result confirms it.',
947
950
  'Durable profile updates are actions in this stabilized version: delegate them instead of writing directly.',
948
951
  ].filter(Boolean).join('\n');
@@ -1005,7 +1008,7 @@ function toolsForClassification(classification, writeTools, session = null) {
1005
1008
  return [SHELL_READ_COMMAND_TOOL, ...controlTools, ...capabilityRunTools, ...writeTools];
1006
1009
  }
1007
1010
 
1008
- const DONNA_READ_VERBS = new Set(['status', 'list', 'search', 'read', 'get', 'fetch']);
1011
+ const DONNA_READ_VERBS = new Set(['status', 'list', 'search', 'read', 'get', 'fetch', 'collect']);
1009
1012
 
1010
1013
  export function isDonnaReadTool(item) {
1011
1014
  const name = String(item?.function?.name ?? '');
@@ -1349,11 +1349,14 @@ test('Donna interprets a cleanup request and calls runtime__kill herself', async
1349
1349
  runtime: { url: 'http://runtime.test' },
1350
1350
  agentProjection: { status: 'running', activities: [], conversation: [] },
1351
1351
  llm: {
1352
- async completeWithTools({ tools }) {
1352
+ async completeWithTools({ tools, system }) {
1353
1353
  calls += 1;
1354
1354
  if (calls === 1) {
1355
1355
  const names = tools.map((tool) => tool.function.name);
1356
1356
  assert.ok(names.includes('runtime__kill'), 'control tools must be bound during an active run');
1357
+ const killTool = tools.find((tool) => tool.function.name === 'runtime__kill');
1358
+ assert.equal(killTool.function.parameters.properties.purge.type, 'boolean');
1359
+ assert.match(system, /delete, reset, abandon or replace the current plan/);
1357
1360
  return {
1358
1361
  content: null,
1359
1362
  message: { role: 'assistant', content: null },
@@ -7,7 +7,7 @@ loadManagerEnv();
7
7
  import { createAgentGraph } from '../agent/graph.js';
8
8
  import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
9
9
  import { runShell, runHeadlessChatTurn } from '../shell/repl.js';
10
- import { runChecks } from '../core/startupCheck.js';
10
+ import { runPreflightChecks, withRuntimePreflight } from '../core/startupCheck.js';
11
11
  import { applySessionWikircProfile } from '../core/sessionConfig.js';
12
12
  import { listWikircProfiles } from '../core/wikirc.js';
13
13
  import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
@@ -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 };
@@ -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
- const selection = await resolveObjective(objective, session);
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
- const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(
793
- session.mcp,
794
- provider.serverName,
795
- 'agent_plan',
796
- {
797
- capability: selection.capability,
798
- operation: selection.operation,
799
- objective,
800
- workspace: { revision: String(Date.now()) },
801
- constraints: {
802
- maxConcurrency: resolveCapabilityConcurrency(
803
- provider,
804
- undefined,
805
- process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
806
- ),
807
- requireApprovalForMutations: true,
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
  }
@@ -1054,6 +1085,9 @@ async function runRuntime(argv, agent) {
1054
1085
  response = await runHeadlessChatTurn(ephemeral, input, {
1055
1086
  history,
1056
1087
  onStep: ephemeral._onStep,
1088
+ // UI context from `wiki serve`: up to five selected wiki or raw
1089
+ // documents. Only paths are prompted; Donna reads through tools.
1090
+ openWikiPages: body.context?.openWikiPages ?? body.context?.openWikiPage,
1057
1091
  });
1058
1092
  } else {
1059
1093
  response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
@@ -1191,8 +1225,23 @@ export async function runCli(argv) {
1191
1225
  // in a random cwd must not litter files.
1192
1226
  const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
1193
1227
  if (scaffolded.length > 0) loadManagerEnv();
1194
- const gaps = await runChecks();
1195
- if (gaps.length > 0) await runStartupWizard(gaps);
1228
+ const reportCheck = ({ kind, ok, detail, context, skipped, pending }) => {
1229
+ const labels = { docker: 'Docker', internet: 'Internet', agents: 'Agent containers', workspace: 'Workspaces', containers: 'Workspace containers', mcp: 'MCP' };
1230
+ const label = labels[kind] ?? kind;
1231
+ const instruction = !ok && context?.command ? ` — command: ${context.command}` : '';
1232
+ const suffix = detail ? ` — ${detail}` : ` — ${context?.error ?? context?.dockerError ?? 'waiting'}`;
1233
+ const color = ok ? '\x1b[32m' : '\x1b[33m';
1234
+ const state = ok ? 'ready' : pending || skipped ? 'waiting' : 'needs attention';
1235
+ const icon = ok ? '✓' : pending || skipped ? '◐' : '✗';
1236
+ console.log(`${color}${icon} configuration: ${label} ${state}${suffix}${instruction}\x1b[0m`);
1237
+ };
1238
+ let preflight = await runPreflightChecks({ onCheck: reportCheck });
1239
+ if (preflight.gaps.length > 0) {
1240
+ await runStartupWizard(preflight.gaps);
1241
+ // The wizard may have created a workspace, started agents or repaired
1242
+ // configuration. Re-read everything before drawing the home screen.
1243
+ preflight = await runPreflightChecks();
1244
+ }
1196
1245
  let runtime = null;
1197
1246
  try {
1198
1247
  const { ensureRuntime } = await import('../runtime/lifecycle.js');
@@ -1203,11 +1252,12 @@ export async function runCli(argv) {
1203
1252
  runtime = unavailableRuntime(err);
1204
1253
  console.error(`Runtime unavailable: ${runtime.error}`);
1205
1254
  }
1255
+ preflight = withRuntimePreflight(preflight, runtime);
1206
1256
  // The owned-runtime shutdown happens inside the TUI's own exit paths
1207
1257
  // (see tui.tsx onShellExit): render() resolves at MOUNT, so anything
1208
1258
  // after this await would run while the shell is still on screen —
1209
1259
  // 0.12.9 shipped exactly that bug and killed the runtime under the user.
1210
- await runOpenTuiShell({ agent, packageJson, runtime });
1260
+ await runOpenTuiShell({ agent, packageJson, runtime, preflight });
1211
1261
  return;
1212
1262
  }
1213
1263
 
@@ -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
- export function localizedOperationResult({ operation, target, status = 'succeeded' }) {
682
- const facts = JSON.stringify({ operation, target, status });
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');
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.9",
3
- "commit": "768fb25"
2
+ "version": "0.14.12",
3
+ "commit": "621830d"
4
4
  }
@@ -40,18 +40,15 @@ function composeOverrideContent(cacertPath, services) {
40
40
  services: serviceEntries,
41
41
  };
42
42
  return [
43
- '# Generated by wiki-manager. Edit freely; delete to regenerate.',
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 (_cacertOverrideCache = null);
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
- if (!existsSync(overridePath)) {
64
- writeFileSync(overridePath, composeOverrideContent(cacertPath, services), 'utf8');
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 (_cacertOverrideCache = overridePath);
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
+ });
@@ -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 startService(session, service) {
285
+ export async function missingServiceImages(service) {
285
286
  const aliases = serviceAliases();
286
287
  const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
287
- const output = await runCompose(session, ['up', '-d', ...targets], { timeout: 180_000 });
288
- return [`Started: ${targets.join(', ')}`, output].filter(Boolean).join('\n');
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) {
@@ -22,6 +22,9 @@ test('agent compose services run as the host uid and gid', async () => {
22
22
  const agentsCompose = YAML.parse(agentsRaw);
23
23
  assert.equal(agentsCompose.services.cme.user, '${UID:-1000}:${GID:-1000}');
24
24
  assert.equal(agentsCompose.services.documents.user, '${UID:-1000}:${GID:-1000}');
25
+ assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://albert.api.etalab.gouv.fr/v1}'), true);
26
+ assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-lightonai/LightOnOCR-2-1B}'), true);
27
+ assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-}'), true);
25
28
  // MailerSend was an external-connector experiment — removed from the
26
29
  // default stack (the agent repo still exists for ad-hoc use).
27
30
  assert.equal(agentsCompose.services.mailer, undefined);
@@ -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
+ }