@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 +15 -1
- package/README.md +42 -2
- package/agents.docker-compose.yml +4 -5
- package/mcp.endpoints.example.json +2 -2
- package/package.json +2 -2
- package/src/agent/graph.js +8 -5
- package/src/agent/graph.test.js +4 -1
- package/src/cli/wiki-manager.js +73 -23
- 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/dockerCompose.test.js +3 -0
- package/src/core/dockerImages.js +22 -0
- package/src/core/env.js +27 -3
- package/src/core/env.test.js +72 -0
- package/src/core/mcp.js +1 -1
- package/src/core/startupCheck.js +340 -7
- package/src/core/startupCheck.test.js +190 -3
- package/src/core/wikiSetup.js +26 -2
- package/src/core/wikiWorkspace.test.js +9 -0
- package/src/runtime/lifecycle.js +1 -1
- package/src/shell/LeftPane.tsx +17 -2
- package/src/shell/RightPane.tsx +34 -17
- package/src/shell/SetupWizard.tsx +53 -10
- package/src/shell/StartupScreen.tsx +42 -9
- package/src/shell/externalLinks.js +67 -0
- package/src/shell/repl.js +149 -29
- package/src/shell/repl.test.js +187 -0
- package/src/shell/tui.tsx +60 -3
- package/wiki-workspace +20 -17
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.
|
|
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.
|
|
67
|
-
- DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-
|
|
68
|
-
- DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_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":
|
|
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.
|
|
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",
|
package/src/agent/graph.js
CHANGED
|
@@ -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: {
|
|
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 ?? '');
|
package/src/agent/graph.test.js
CHANGED
|
@@ -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 },
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
1195
|
-
|
|
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
|
|
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) {
|
|
@@ -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
|
+
}
|