@dotdrelle/wiki-manager 0.15.41 → 0.15.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -4
- package/package.json +2 -2
- package/src/agent/graph.js +3 -8
- package/src/cli/wiki-manager.js +35 -2
- package/src/commands/slash.js +36 -8
- package/src/core/agentEvents.js +23 -0
- package/src/core/agentEvents.test.js +32 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +32 -0
- package/src/core/dockerCompose.test.js +32 -0
- package/src/core/mcp.js +1 -1
- package/src/core/otherWorkspacesRunning.test.js +51 -0
- package/src/core/profile.js +19 -0
- package/src/core/profileServiceStatus.test.js +26 -2
- package/src/core/toolLoop.js +32 -7
- package/src/core/toolLoop.test.js +95 -0
- package/src/core/wikiSetup.js +50 -7
- package/src/core/wikiWorkspace.test.js +64 -0
- package/src/core/wikirc.test.js +121 -2
- package/src/core/workspaceInherit.js +149 -0
- package/src/core/workspaceInherit.test.js +181 -0
- package/src/core/workspaceProfile.test.js +55 -0
- package/src/runtime/server.js +15 -5
- package/src/runtime/server.test.js +31 -2
- package/src/runtime/workspaceIsolation.test.js +178 -0
- package/src/shell/repl.js +28 -7
- package/src/shell/setupWizardModality.test.js +55 -0
- package/src/shell/tui.tsx +29 -9
- package/wiki-workspace +147 -5
package/README.md
CHANGED
|
@@ -210,7 +210,7 @@ Slash primitives (shell):
|
|
|
210
210
|
|
|
211
211
|
```text
|
|
212
212
|
/wiki # inspect the wiki
|
|
213
|
-
/skills # bundled examples: pipeline, diagnose, status
|
|
213
|
+
/skills # bundled examples: pipeline, wiki-sync, wiki-build, deliver, diagnose, status
|
|
214
214
|
/skills run pipeline # run the shipped end-to-end example
|
|
215
215
|
```
|
|
216
216
|
|
|
@@ -273,7 +273,7 @@ just reopens the web page.)*
|
|
|
273
273
|
The scaffold ships **ready-to-use examples**. In the shell, explore them:
|
|
274
274
|
|
|
275
275
|
```text
|
|
276
|
-
/skills list the bundled examples (
|
|
276
|
+
/skills list the bundled examples (pipeline, wiki-sync, wiki-build, deliver, diagnose, status…)
|
|
277
277
|
/skills show <name> see what an example does
|
|
278
278
|
/skills run <name> run it to see the result
|
|
279
279
|
```
|
|
@@ -329,8 +329,12 @@ At each step, either you **ask for it in plain language**, or you **run the skil
|
|
|
329
329
|
rendering.
|
|
330
330
|
→ *"Export and polish the deliverables"*
|
|
331
331
|
|
|
332
|
-
> 💡 Even simpler: `/skills run wiki-sync` chains export + ingestion,
|
|
333
|
-
> `/skills run
|
|
332
|
+
> 💡 Even simpler: `/skills run wiki-sync` chains export + ingestion,
|
|
333
|
+
> `/skills run wiki-build` regenerates the deliverables, `/skills run deliver`
|
|
334
|
+
> publishes them (add `polish` to refine the rendering), and
|
|
335
|
+
> `/skills run pipeline` runs the whole chain end to end. The three step skills
|
|
336
|
+
> take an optional argument — a source name, or a template with or without its
|
|
337
|
+
> `.md` extension.
|
|
334
338
|
|
|
335
339
|
### Entry point B — from a simple PDF (the fastest)
|
|
336
340
|
|
|
@@ -556,6 +560,14 @@ including under `"*"`. Multi-step work belongs to `/agent`.
|
|
|
556
560
|
An `allowActions` key written by an older manager is folded into `allow` on
|
|
557
561
|
read and removed on the next `agents up`.
|
|
558
562
|
|
|
563
|
+
`chatAccess` is not how workspace context reaches chat. The workspace profile
|
|
564
|
+
(`.wiki/profile.md`) is read from disk and injected into the system prompt of
|
|
565
|
+
both modes, so durable preferences — tone, formatting, notification recipient —
|
|
566
|
+
shape every reply without a tool call and without an allow-list entry. Adding
|
|
567
|
+
`profile_read` here would help no existing install anyway: the scaffold's
|
|
568
|
+
additive merge only fills missing top-level keys and never edits an allow-list
|
|
569
|
+
you already have.
|
|
570
|
+
|
|
559
571
|
### Adding a connector from the served chat UI
|
|
560
572
|
|
|
561
573
|
`mcp.endpoints.json` stays hand-editable, but the Connectors panel of
|
|
@@ -844,6 +856,38 @@ wiki-workspace wiki my-project build --plan
|
|
|
844
856
|
wiki-workspace wiki my-project build
|
|
845
857
|
```
|
|
846
858
|
|
|
859
|
+
### Resetting a workspace
|
|
860
|
+
|
|
861
|
+
```bash
|
|
862
|
+
wiki-workspace wiki my-project down # the services must be stopped
|
|
863
|
+
wiki-workspace wiki my-project reset --dry-run # what would go, what stays
|
|
864
|
+
wiki-workspace wiki my-project reset
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
`reset` empties a workspace while keeping the **method**: `.wikirc*` (provider,
|
|
868
|
+
model, retrieval, per-profile variants), `templates/` and `build-context/` —
|
|
869
|
+
plus `.env`, which holds the workspace's ports and MCP tokens and without which
|
|
870
|
+
nothing could be restarted.
|
|
871
|
+
Everything the workspace produced, cached or logged goes — `wiki/`,
|
|
872
|
+
`deliverables/`, `raw/untracked/`, `raw/ingested/`, `.wiki/` (vector index,
|
|
873
|
+
cache, logs, tmp, build state, skills, profile, system prompt), `CLAUDE.md`,
|
|
874
|
+
`.gitignore` — then `wiki init` puts the empty structure back.
|
|
875
|
+
|
|
876
|
+
Three things worth knowing:
|
|
877
|
+
|
|
878
|
+
- `.git/` is kept when present, so the state from before the reset stays
|
|
879
|
+
reachable through `wiki restore`. It is the only undo there is.
|
|
880
|
+
- The command refuses to run while workspace services are up: a container
|
|
881
|
+
writing into the bind mount would recreate part of what was erased and leave
|
|
882
|
+
files owned by another UID behind.
|
|
883
|
+
- It stops there. Nothing is re-synced and nothing is rebuilt — refilling the
|
|
884
|
+
workspace is a decision, not a side effect of emptying it.
|
|
885
|
+
|
|
886
|
+
It is available **only** here: there is no `wiki reset` CLI subcommand, no
|
|
887
|
+
production job type, no MCP tool and no skill for it. Nothing Donna can call
|
|
888
|
+
may erase a workspace. Confirmation is interactive (retype the workspace name)
|
|
889
|
+
unless you pass `--yes`.
|
|
890
|
+
|
|
847
891
|
## Services
|
|
848
892
|
|
|
849
893
|
The shared `docker-compose.yml` starts one workspace stack:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.43",
|
|
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/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.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/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.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/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.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/delegation.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/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.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/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.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/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.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
|
@@ -18,18 +18,17 @@ import {
|
|
|
18
18
|
resolveToolCallName,
|
|
19
19
|
truncateToolResult,
|
|
20
20
|
} from '../core/mcp.js';
|
|
21
|
-
import { formatSkillsForAgent
|
|
21
|
+
import { formatSkillsForAgent } from '../core/skills.js';
|
|
22
22
|
import { handleSlashCommand } from '../commands/slash.js';
|
|
23
23
|
import { extractActivity, formatActivitySummary, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
24
24
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
25
25
|
import { enqueueProductionJob, ensureJobQueue, formatQueue, productionLockBusy } from '../core/jobQueue.js';
|
|
26
|
-
import { updateWorkspaceProfilePreference } from '../core/profile.js';
|
|
26
|
+
import { loadWorkspaceProfile, updateWorkspaceProfilePreference } from '../core/profile.js';
|
|
27
27
|
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
28
28
|
import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeDelegate, postRuntimeKill } from '../runtime/client.js';
|
|
29
29
|
|
|
30
30
|
const MAX_TOOL_ITERATIONS = 80;
|
|
31
31
|
const MAX_SPINNER_ARG_LENGTH = 96;
|
|
32
|
-
const MAX_PROFILE_CHARS = 4000;
|
|
33
32
|
|
|
34
33
|
// Pseudo-servers handled directly by the tool executor (not present in
|
|
35
34
|
// session.mcp). Listed so unqualified names like "plan_set" resolve the same
|
|
@@ -1002,11 +1001,7 @@ function slugStepId(description, index) {
|
|
|
1002
1001
|
// relying on the model proactively calling wiki__profile_read — profile
|
|
1003
1002
|
// content (tutoiement, formatting preferences, etc.) is meant to shape every
|
|
1004
1003
|
// reply, not just ones where the model happens to think to check it.
|
|
1005
|
-
|
|
1006
|
-
if (!workspacePath) return null;
|
|
1007
|
-
const content = readOptionalText(join(workspacePath, '.wiki', 'profile.md'));
|
|
1008
|
-
return content ? content.slice(0, MAX_PROFILE_CHARS) : null;
|
|
1009
|
-
}
|
|
1004
|
+
// Loader shared with chat mode — see core/profile.js.
|
|
1010
1005
|
|
|
1011
1006
|
export function buildAgentSystemPrompt(state) {
|
|
1012
1007
|
const workspace = state.session.workspace ?? 'no workspace selected';
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -306,11 +306,28 @@ export function createInteractiveSession(context, { runtimeUrl, turnId, signal =
|
|
|
306
306
|
return session;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/*
|
|
310
|
+
Un tour interactif se termine TOUJOURS par un assistant_message.
|
|
311
|
+
|
|
312
|
+
C'est la condition de fin que les deux interfaces attendent : côté `serve`, la
|
|
313
|
+
bulle « Request received · Donna is preparing… » n'est retirée que lorsqu'un
|
|
314
|
+
message assistant non vide arrive. La garde `!content` renvoyait donc `false`
|
|
315
|
+
en silence quand le tour ne produisait rien — modèle qui répond vide, boucle
|
|
316
|
+
d'outils qui s'arrête sans conclure — et le point d'attente tournait
|
|
317
|
+
indéfiniment, sans erreur nulle part.
|
|
318
|
+
|
|
319
|
+
Une réponse vide est un résultat, pas une raison de ne rien dire.
|
|
320
|
+
*/
|
|
309
321
|
export function ensureInteractiveAssistantMessage(session, response, { turnId, workspace } = {}) {
|
|
322
|
+
if (session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
|
|
310
323
|
const content = String(response ?? '').trim();
|
|
311
|
-
if (!content || session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
|
|
312
324
|
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
313
|
-
origin: 'runtime_turn',
|
|
325
|
+
origin: 'runtime_turn',
|
|
326
|
+
turnId,
|
|
327
|
+
workspace,
|
|
328
|
+
payload: {
|
|
329
|
+
content: content || 'No answer was produced for this turn. The model returned nothing — try rephrasing, or switch to /agent if the request needs an action.',
|
|
330
|
+
},
|
|
314
331
|
}));
|
|
315
332
|
return true;
|
|
316
333
|
}
|
|
@@ -1365,6 +1382,22 @@ async function runRuntime(argv, agent) {
|
|
|
1365
1382
|
response = await runHeadlessChatTurn(ephemeral, input, {
|
|
1366
1383
|
history,
|
|
1367
1384
|
onStep: ephemeral._onStep,
|
|
1385
|
+
// Fragments de réponse publiés au fil de l'eau. Le réducteur les
|
|
1386
|
+
// agrège dans la dernière entrée de conversation (`assistant_delta`),
|
|
1387
|
+
// que `assistant_message` vient ensuite figer : les deux interfaces
|
|
1388
|
+
// voient la réponse s'écrire, au lieu d'attendre le tour complet.
|
|
1389
|
+
onTextDelta: (delta) => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta', {
|
|
1390
|
+
origin: 'runtime_turn',
|
|
1391
|
+
turnId,
|
|
1392
|
+
workspace: context.workspace ?? null,
|
|
1393
|
+
payload: { delta },
|
|
1394
|
+
})),
|
|
1395
|
+
onTextReset: () => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta_reset', {
|
|
1396
|
+
origin: 'runtime_turn',
|
|
1397
|
+
turnId,
|
|
1398
|
+
workspace: context.workspace ?? null,
|
|
1399
|
+
payload: {},
|
|
1400
|
+
})),
|
|
1368
1401
|
// UI context from `wiki serve`: up to five selected wiki or raw
|
|
1369
1402
|
// documents. Only paths are prompted; Donna reads through tools.
|
|
1370
1403
|
openWikiPages: body.context?.openWikiPages ?? body.context?.openWikiPage,
|
package/src/commands/slash.js
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
12
12
|
import { openExternalUrl } from '../shell/openExternal.js';
|
|
13
13
|
import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
|
-
import { composeServices, listServices, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
|
|
15
|
+
import { composeServices, listServices, otherWorkspacesRunning, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
|
|
16
16
|
import { agentServiceNames, profileServiceStatus } from '../core/agentsCompose.js';
|
|
17
17
|
import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from '../core/googleGrants.js';
|
|
18
18
|
import {
|
|
@@ -568,12 +568,23 @@ async function createWorkspaceCommand(context, workspaceName, targetPath) {
|
|
|
568
568
|
try {
|
|
569
569
|
context.onStep?.(`Workspace: creating ${workspaceName}…`);
|
|
570
570
|
const output = await createWorkspace(workspaceName, targetPath, { timeout: 600_000 });
|
|
571
|
-
|
|
571
|
+
// Seed the new workspace from the one in use: the LLM endpoint, key and
|
|
572
|
+
// model are almost always the same, and re-entering them by hand was the
|
|
573
|
+
// first thing to do after every /new.
|
|
574
|
+
const { inherited } = await finalizeCreatedWorkspace(workspaceName, {
|
|
575
|
+
inheritFrom: context.session?.workspace ?? null,
|
|
576
|
+
});
|
|
572
577
|
return {
|
|
573
578
|
output: [
|
|
574
579
|
output,
|
|
575
580
|
'',
|
|
576
581
|
`Workspace created: ${workspaceName}`,
|
|
582
|
+
// State what was carried over. Inheriting silently would make a wrong
|
|
583
|
+
// endpoint look like a scaffold default and send the operator hunting
|
|
584
|
+
// in the wrong file.
|
|
585
|
+
inherited.length > 0
|
|
586
|
+
? `Inherited from ${context.session.workspace}: ${inherited.join(', ')}`
|
|
587
|
+
: null,
|
|
577
588
|
`Use /use ${workspaceName} to load it.`,
|
|
578
589
|
].filter(Boolean).join('\n'),
|
|
579
590
|
};
|
|
@@ -725,7 +736,7 @@ ${helpPair('/use <workspace>', 'Use workspace', '/status', 'Session status')}
|
|
|
725
736
|
${helpPair('/config list', 'Config profiles', '/config use <n>', 'Use config')}
|
|
726
737
|
${helpPair('/config edit <n>', 'Edit config', '/workspace delete <n>', 'Delete workspace')}
|
|
727
738
|
${helpPair('/services', 'Services', '/start [all|agents|services]', 'all = services + agents')}
|
|
728
|
-
${helpPair('/stop [all|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
|
|
739
|
+
${helpPair('/stop [all|everything|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
|
|
729
740
|
${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
|
|
730
741
|
${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
|
|
731
742
|
${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
|
|
@@ -1128,14 +1139,31 @@ export async function handleSlashCommand(line, context) {
|
|
|
1128
1139
|
// compris. Il ne stoppait que les services du workspace et laissait les
|
|
1129
1140
|
// agents debout — donc `/start all` puis `/stop all` ne revenait pas à
|
|
1130
1141
|
// l'état de départ.
|
|
1131
|
-
|
|
1132
|
-
|
|
1142
|
+
//
|
|
1143
|
+
// Cette symétrie ne tient que tant qu'un seul workspace tourne. Les
|
|
1144
|
+
// agents externes sont UNE pile partagée : les arrêter depuis un
|
|
1145
|
+
// workspace coupait les autres, qui n'avaient rien demandé et ne
|
|
1146
|
+
// voyaient qu'une panne. « all » reste donc « toute ma pile », et les
|
|
1147
|
+
// agents ne tombent que s'ils ne servent plus personne. `/stop
|
|
1148
|
+
// everything` garde la coupure franche, explicitement demandée.
|
|
1149
|
+
const stopsEverything = service === 'everything';
|
|
1150
|
+
const stopsAgents = service === 'all' || stopsEverything;
|
|
1151
|
+
const stopTarget = service === 'services' || stopsEverything ? undefined : service;
|
|
1133
1152
|
try {
|
|
1134
|
-
step(`Services: stopping ${service ?? 'workspace services'}…`);
|
|
1153
|
+
step(`Services: stopping ${stopsEverything ? 'all workspaces and agents' : (service ?? 'workspace services')}…`);
|
|
1135
1154
|
await stopService(context.session, stopTarget);
|
|
1136
1155
|
if (stopsAgents) {
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1156
|
+
const busy = stopsEverything
|
|
1157
|
+
? []
|
|
1158
|
+
: await otherWorkspacesRunning(context.session, listWorkspaces());
|
|
1159
|
+
if (busy.length > 0) {
|
|
1160
|
+
// Say who is holding them, and how to override. A silent skip
|
|
1161
|
+
// would look exactly like the bug we just fixed.
|
|
1162
|
+
step(`Services: agents left running for ${busy.join(', ')} — use /stop everything to stop them anyway.`);
|
|
1163
|
+
} else {
|
|
1164
|
+
const agentsResult = await runAgentCommand(stopAgents, 'stop');
|
|
1165
|
+
if (agentsResult?.failed) return agentsResult;
|
|
1166
|
+
}
|
|
1139
1167
|
}
|
|
1140
1168
|
step('Services: refreshing MCP runtime…');
|
|
1141
1169
|
await refreshMcpRuntimeStatus(context.session);
|
package/src/core/agentEvents.js
CHANGED
|
@@ -268,6 +268,9 @@ function applyEvent(state, event) {
|
|
|
268
268
|
case 'assistant_delta':
|
|
269
269
|
appendAssistantDelta(state, String(event.payload?.delta ?? ''));
|
|
270
270
|
return;
|
|
271
|
+
case 'assistant_delta_reset':
|
|
272
|
+
discardStreamingAssistantMessage(state);
|
|
273
|
+
return;
|
|
271
274
|
case 'tool_call_started':
|
|
272
275
|
state.chain.push({
|
|
273
276
|
type: 'tool',
|
|
@@ -666,6 +669,26 @@ function appendAssistantDelta(state, delta) {
|
|
|
666
669
|
}
|
|
667
670
|
}
|
|
668
671
|
|
|
672
|
+
/*
|
|
673
|
+
Jeter une réponse en cours d'écriture, sans retirer son entrée.
|
|
674
|
+
|
|
675
|
+
Une itération de la boucle d'outils peut produire du texte puis décider
|
|
676
|
+
d'appeler un outil : ce texte est un raisonnement intermédiaire que le tour
|
|
677
|
+
suivant remplace, il ne doit pas rester à l'écran.
|
|
678
|
+
|
|
679
|
+
L'entrée est vidée, jamais dépilée. La réconciliation de `serve`
|
|
680
|
+
(`chatHtml.ts`) suppose une conversation en ajout seul — « le serveur ne mute
|
|
681
|
+
que la dernière entrée, tout ce qui précède est acquis » — et n'indexe la
|
|
682
|
+
boucle que sur la longueur croissante. Un `pop` la ferait passer sous le
|
|
683
|
+
nombre de références déjà rendues : l'élément DOM en trop resterait affiché
|
|
684
|
+
avec le texte qu'on voulait justement effacer, et tous les messages suivants
|
|
685
|
+
se décaleraient d'un cran.
|
|
686
|
+
*/
|
|
687
|
+
function discardStreamingAssistantMessage(state) {
|
|
688
|
+
const last = state.conversation.at(-1);
|
|
689
|
+
if (last?.role === 'assistant' && last.streaming) last.content = '';
|
|
690
|
+
}
|
|
691
|
+
|
|
669
692
|
function finalizeAssistantMessage(state, content) {
|
|
670
693
|
const last = state.conversation.at(-1);
|
|
671
694
|
if (last?.role === 'assistant' && last.streaming) {
|
|
@@ -591,3 +591,35 @@ test('reduceAgentEvents: les alias de statut tombent sur le canonique', () => {
|
|
|
591
591
|
assert.equal(projection.plan[2].status, 'done');
|
|
592
592
|
assert.equal(projection.logs.some((line) => /unknown status/.test(line)), false);
|
|
593
593
|
});
|
|
594
|
+
|
|
595
|
+
// La bulle « Request received · Donna is preparing… » n'est retirée côté serve
|
|
596
|
+
// que lorsqu'un message assistant NON VIDE arrive. Un tour sans réponse ne
|
|
597
|
+
// publiait rien : le point d'attente tournait jusqu'au rechargement de la page.
|
|
598
|
+
test('a discarded stream is emptied, never popped', () => {
|
|
599
|
+
const projection = reduceAgentEvents([
|
|
600
|
+
createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }),
|
|
601
|
+
createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Je vais regarder…' } }),
|
|
602
|
+
createAgentEvent('assistant_delta_reset', { origin: 'runtime', payload: {} }),
|
|
603
|
+
]);
|
|
604
|
+
|
|
605
|
+
// La réconciliation de serve suppose une conversation en ajout seul : dépiler
|
|
606
|
+
// la ferait passer sous le nombre d'éléments déjà rendus, laissant à l'écran
|
|
607
|
+
// le texte qu'on voulait effacer et décalant tous les messages suivants.
|
|
608
|
+
assert.equal(projection.conversation.length, 2);
|
|
609
|
+
assert.deepEqual(projection.conversation[1], { role: 'assistant', content: '', streaming: true });
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('a stream resumed after a discard carries only the final text', () => {
|
|
613
|
+
const projection = reduceAgentEvents([
|
|
614
|
+
createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }),
|
|
615
|
+
createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Je vais regarder…' } }),
|
|
616
|
+
createAgentEvent('assistant_delta_reset', { origin: 'runtime', payload: {} }),
|
|
617
|
+
createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: '12 ' } }),
|
|
618
|
+
createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'pages.' } }),
|
|
619
|
+
createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: '12 pages.' } }),
|
|
620
|
+
]);
|
|
621
|
+
|
|
622
|
+
assert.equal(projection.conversation.length, 2);
|
|
623
|
+
assert.equal(projection.conversation[1].content, '12 pages.');
|
|
624
|
+
assert.equal(projection.conversation[1].streaming, undefined, 'le message doit être figé');
|
|
625
|
+
});
|
package/src/core/buildInfo.json
CHANGED
package/src/core/compose.js
CHANGED
|
@@ -321,6 +321,38 @@ export async function serviceStates(session) {
|
|
|
321
321
|
return states;
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Names of OTHER registered workspaces that still have containers running.
|
|
326
|
+
*
|
|
327
|
+
* The external agents (cme, documents, connectors, mailer) are a single
|
|
328
|
+
* stack shared by every workspace — one Compose project, one set of
|
|
329
|
+
* containers. `/stop all` took them down unconditionally, so stopping one
|
|
330
|
+
* workspace silently cut the agents out from under all the others.
|
|
331
|
+
*
|
|
332
|
+
* Best effort by design: a workspace whose Compose project cannot be queried
|
|
333
|
+
* (registry entry pointing at a path that no longer exists, docker refusing)
|
|
334
|
+
* is reported as NOT running. Erring the other way would make the agents
|
|
335
|
+
* impossible to stop as soon as one stale registry entry existed.
|
|
336
|
+
*/
|
|
337
|
+
export async function otherWorkspacesRunning(session, workspaces = []) {
|
|
338
|
+
const current = session?.workspace ?? null;
|
|
339
|
+
const others = workspaces.filter((workspace) => workspace?.name && workspace.name !== current);
|
|
340
|
+
const results = await Promise.all(others.map(async (workspace) => {
|
|
341
|
+
try {
|
|
342
|
+
const states = await serviceStates({
|
|
343
|
+
workspace: workspace.name,
|
|
344
|
+
workspacePath: workspace.workspacePath,
|
|
345
|
+
workspaceEnvFile: workspace.envFile,
|
|
346
|
+
workspaceEnv: workspace.env,
|
|
347
|
+
});
|
|
348
|
+
return Object.values(states).some((state) => state.running) ? workspace.name : null;
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}));
|
|
353
|
+
return results.filter(Boolean);
|
|
354
|
+
}
|
|
355
|
+
|
|
324
356
|
export async function missingServiceImages(service) {
|
|
325
357
|
const aliases = serviceAliases();
|
|
326
358
|
const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
|
|
@@ -150,3 +150,35 @@ test('missing-image checks ignore agents behind inactive Compose profiles', () =
|
|
|
150
150
|
['example/cme:latest', 'example/connectors:latest'],
|
|
151
151
|
);
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
// Trois variables DOCUMENT_* pour deux conteneurs, chacun n'en déclarant que
|
|
155
|
+
// deux : la question « à quoi ça sert » revient à chaque lecture. Ce test fige
|
|
156
|
+
// le partage pour que la doc reste vraie.
|
|
157
|
+
test('the document handoff keeps input shared and the rest separate', async () => {
|
|
158
|
+
const workspace = YAML.parse(await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8'));
|
|
159
|
+
const agents = YAML.parse(await readFile(new URL('../../agents.docker-compose.yml', import.meta.url), 'utf8'));
|
|
160
|
+
const envOf = (service) => Object.fromEntries(
|
|
161
|
+
(service.environment ?? []).map((entry) => String(entry).split('=', 2)),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const serve = envOf(workspace.services.serve);
|
|
165
|
+
const documents = envOf(agents.services.documents);
|
|
166
|
+
|
|
167
|
+
// `input` est le point de passage : serve y écrit, l'agent y lit.
|
|
168
|
+
assert.equal(serve.DOCUMENT_INPUT_DIR, '/documents/input');
|
|
169
|
+
assert.equal(documents.DOCUMENT_INPUT_DIR, '/documents/input');
|
|
170
|
+
assert.ok(workspace.services.serve.volumes.some((v) => String(v).endsWith(':/documents/input')));
|
|
171
|
+
assert.ok(agents.services.documents.volumes.some((v) => String(v).endsWith(':/documents/input')));
|
|
172
|
+
|
|
173
|
+
// Le manifeste des téléversements n'appartient qu'à serve ; la sortie de
|
|
174
|
+
// conversion n'appartient qu'à l'agent. Déclarer l'un chez l'autre laisserait
|
|
175
|
+
// croire à un partage qui n'existe pas.
|
|
176
|
+
assert.equal(serve.DOCUMENT_UPLOADS_DIR, '/documents/uploads');
|
|
177
|
+
assert.equal(documents.DOCUMENT_UPLOADS_DIR, undefined);
|
|
178
|
+
assert.equal(documents.DOCUMENT_OUTPUT_DIR, '/documents/output');
|
|
179
|
+
assert.equal(serve.DOCUMENT_OUTPUT_DIR, undefined);
|
|
180
|
+
|
|
181
|
+
// Le plafond est vérifié des deux côtés, donc déclaré des deux côtés.
|
|
182
|
+
assert.ok(serve.DOCUMENT_MAX_UPLOAD_BYTES);
|
|
183
|
+
assert.ok(documents.DOCUMENT_MAX_UPLOAD_BYTES);
|
|
184
|
+
});
|
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.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.43';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { otherWorkspacesRunning } from './compose.js';
|
|
7
|
+
|
|
8
|
+
// `otherWorkspacesRunning` shells out to `docker compose ps` per workspace.
|
|
9
|
+
// Docker is not available in the test environment, so every probe throws —
|
|
10
|
+
// which is itself the behaviour worth pinning: a workspace that cannot be
|
|
11
|
+
// queried must read as NOT running, or one stale registry entry would make
|
|
12
|
+
// the shared agents impossible to stop for good.
|
|
13
|
+
|
|
14
|
+
function workspaceEntry(root, name) {
|
|
15
|
+
const registryPath = join(root, name);
|
|
16
|
+
mkdirSync(registryPath, { recursive: true });
|
|
17
|
+
const envFile = join(registryPath, '.env');
|
|
18
|
+
writeFileSync(envFile, `WORKSPACE_NAME=${name}\nWIKI_WORKSPACE_PATH=${registryPath}\n`, 'utf8');
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
registryPath,
|
|
22
|
+
envFile,
|
|
23
|
+
workspacePath: registryPath,
|
|
24
|
+
env: { WORKSPACE_NAME: name, WIKI_WORKSPACE_PATH: registryPath },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('the current workspace is never counted as another one', async () => {
|
|
29
|
+
const root = mkdtempSync(join(tmpdir(), 'ws-running-self-'));
|
|
30
|
+
const acpi = workspaceEntry(root, 'acpi');
|
|
31
|
+
|
|
32
|
+
const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, [acpi]);
|
|
33
|
+
|
|
34
|
+
assert.deepEqual(busy, [], 'stopping a workspace must not be blocked by itself');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('an unqueryable workspace does not hold the shared agents hostage', async () => {
|
|
38
|
+
const root = mkdtempSync(join(tmpdir(), 'ws-running-unknown-'));
|
|
39
|
+
const workspaces = [workspaceEntry(root, 'acpi'), workspaceEntry(root, 'stale')];
|
|
40
|
+
|
|
41
|
+
const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, workspaces);
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(busy, []);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a single workspace, or none at all, never blocks', async () => {
|
|
47
|
+
assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, []), []);
|
|
48
|
+
assert.deepEqual(await otherWorkspacesRunning({}, []), []);
|
|
49
|
+
// Entries without a name are registry noise, not workspaces.
|
|
50
|
+
assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, [{}, null]), []);
|
|
51
|
+
});
|
package/src/core/profile.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { readOptionalText } from './skills.js';
|
|
4
|
+
|
|
5
|
+
export const MAX_PROFILE_CHARS = 4000;
|
|
3
6
|
|
|
4
7
|
const DEFAULT_PROFILE = `# Workspace Profile
|
|
5
8
|
|
|
@@ -22,6 +25,22 @@ function profilePathForWorkspace(workspacePath) {
|
|
|
22
25
|
return join(workspacePath, '.wiki', 'profile.md');
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
// Durable per-workspace user preferences, injected into the system prompt of
|
|
29
|
+
// BOTH shell modes. Agent mode used to own this loader; chat mode had nothing,
|
|
30
|
+
// so the same workspace answered with a different tone depending on the mode,
|
|
31
|
+
// and a skill running in chat could not know who it was talking to. `serve`
|
|
32
|
+
// already injects the profile the same way (llm-wiki chatRoutes), so this keeps
|
|
33
|
+
// the three surfaces aligned. Injection is deliberately preferred over exposing
|
|
34
|
+
// `profile_read` through `chatAccess`: no allow-list entry to migrate on
|
|
35
|
+
// existing installs, and no tool round-trip for a file we can always read.
|
|
36
|
+
// Returns null when there is no workspace or no readable profile — never throws,
|
|
37
|
+
// since a missing profile must not degrade a reply.
|
|
38
|
+
export function loadWorkspaceProfile(workspacePath) {
|
|
39
|
+
if (!workspacePath) return null;
|
|
40
|
+
const content = readOptionalText(profilePathForWorkspace(workspacePath));
|
|
41
|
+
return content ? content.slice(0, MAX_PROFILE_CHARS) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
function formatPreference(preference) {
|
|
26
45
|
const clean = String(preference ?? '').trim();
|
|
27
46
|
if (!clean) return '';
|
|
@@ -82,8 +82,32 @@ test('"all" means the same thing to start and to stop', () => {
|
|
|
82
82
|
// `/start all` démarrait les agents, `/stop all` ne les arrêtait pas : la
|
|
83
83
|
// séquence start/stop ne revenait donc pas à l'état de départ.
|
|
84
84
|
assert.match(slash, /const startsAgents = service === 'all';/);
|
|
85
|
-
assert.match(slash, /const stopsAgents = service === 'all';/);
|
|
86
|
-
|
|
85
|
+
assert.match(slash, /const stopsAgents = service === 'all' \|\| stopsEverything;/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('stopping one workspace never takes the shared agents from another', () => {
|
|
89
|
+
const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
|
|
90
|
+
// Les agents externes sont UNE pile partagée. `/stop all` les arrêtait sans
|
|
91
|
+
// condition : couper un workspace coupait tous les autres, qui ne voyaient
|
|
92
|
+
// qu'une panne.
|
|
93
|
+
assert.match(slash, /await otherWorkspacesRunning\(context\.session, listWorkspaces\(\)\)/);
|
|
94
|
+
assert.match(slash, /if \(busy\.length > 0\) \{/);
|
|
95
|
+
// Le renoncement doit se dire : un saut silencieux ressemble exactement au
|
|
96
|
+
// bug d'origine.
|
|
97
|
+
assert.match(slash, /agents left running for \$\{busy\.join\(', '\)\}/);
|
|
98
|
+
assert.match(slash, /use \/stop everything to stop them anyway/);
|
|
99
|
+
// `everything` court-circuite la garde, et c'est son seul intérêt.
|
|
100
|
+
assert.match(slash, /const busy = stopsEverything\s*\n\s*\? \[\]/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the everything escape hatch is discoverable', () => {
|
|
104
|
+
const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
|
|
105
|
+
const repl = readFileSync(fileURLToPath(new URL('../shell/repl.js', import.meta.url)), 'utf8');
|
|
106
|
+
// Une porte de sortie qui n'apparaît ni dans l'aide ni dans la complétion
|
|
107
|
+
// n'existe pas pour l'opérateur qui en a besoin.
|
|
108
|
+
assert.match(slash, /\/stop \[all\|everything\|service\|agents\]/);
|
|
109
|
+
assert.match(repl, /command === '\/stop' && tokenIndex === 1\) return \['all', 'everything'/);
|
|
110
|
+
assert.match(repl, /if \(value === 'everything'\)/);
|
|
87
111
|
});
|
|
88
112
|
|
|
89
113
|
test('the addressable agents are read from the compose file, never hard-coded', async () => {
|
package/src/core/toolLoop.js
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
//
|
|
13
13
|
// `executeCall` may throw to abort the whole loop (e.g. an AbortError on
|
|
14
14
|
// cancel); anything it returns is treated as the tool result for that call.
|
|
15
|
+
/**
|
|
16
|
+
* @param onTextDelta appelé au fil de la génération. Une itération qui finit
|
|
17
|
+
* par des appels d'outils ne produit pas de réponse lisible : ses fragments
|
|
18
|
+
* sont donc rejetés a posteriori via `onTextReset`, pour ne pas afficher un
|
|
19
|
+
* raisonnement intermédiaire que le tour suivant remplacera.
|
|
20
|
+
* @param onTextReset appelé quand les fragments déjà émis sont à jeter.
|
|
21
|
+
*/
|
|
15
22
|
export async function runBoundedToolLoop({
|
|
16
23
|
llm,
|
|
17
24
|
system,
|
|
@@ -21,19 +28,37 @@ export async function runBoundedToolLoop({
|
|
|
21
28
|
maxIterations = 4,
|
|
22
29
|
signal,
|
|
23
30
|
onStep,
|
|
31
|
+
onTextDelta,
|
|
32
|
+
onTextReset,
|
|
24
33
|
} = {}) {
|
|
25
34
|
const cap = Math.max(1, Math.floor(maxIterations) || 1);
|
|
26
35
|
const convo = [...(messages ?? [])];
|
|
36
|
+
// `streamWithTools` accumule les appels d'outils exactement comme
|
|
37
|
+
// `completeWithTools` et renvoie la même forme : le seul écart est qu'il
|
|
38
|
+
// livre le texte au fil de l'eau. Sans lui, la réponse finale n'apparaissait
|
|
39
|
+
// qu'une fois complète — le tour paraissait figé pendant toute sa durée.
|
|
40
|
+
const canStream = typeof onTextDelta === 'function' && typeof llm?.streamWithTools === 'function';
|
|
27
41
|
for (let i = 0; i < cap; i += 1) {
|
|
28
42
|
onStep?.(i + 1, cap);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
let streamedText = false;
|
|
44
|
+
const result = canStream
|
|
45
|
+
? await llm.streamWithTools({
|
|
46
|
+
system,
|
|
47
|
+
tools,
|
|
48
|
+
messages: convo,
|
|
49
|
+
toolChoice: 'auto',
|
|
50
|
+
onTextDelta: (delta) => { streamedText = true; onTextDelta(delta); },
|
|
51
|
+
signal,
|
|
52
|
+
})
|
|
53
|
+
: await llm.completeWithTools({
|
|
54
|
+
system,
|
|
55
|
+
tools,
|
|
56
|
+
messages: convo,
|
|
57
|
+
toolChoice: 'auto',
|
|
58
|
+
signal,
|
|
59
|
+
});
|
|
36
60
|
const calls = result?.tool_calls ?? [];
|
|
61
|
+
if (calls.length > 0 && streamedText) onTextReset?.();
|
|
37
62
|
if (calls.length === 0) {
|
|
38
63
|
return {
|
|
39
64
|
content: result?.content ?? result?.message?.content ?? '',
|