@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 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, wiki-sync
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 (diagnose, pipeline, status, wiki-sync…)
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, and
333
- > `/skills run pipeline` runs the whole chain end to end.
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.41",
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",
@@ -18,18 +18,17 @@ import {
18
18
  resolveToolCallName,
19
19
  truncateToolResult,
20
20
  } from '../core/mcp.js';
21
- import { formatSkillsForAgent, readOptionalText } from '../core/skills.js';
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
- function loadWorkspaceProfile(workspacePath) {
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';
@@ -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', turnId, workspace, payload: { content: String(response) },
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,
@@ -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
- finalizeCreatedWorkspace(workspaceName);
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
- const stopsAgents = service === 'all';
1132
- const stopTarget = service === 'services' ? undefined : service;
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 agentsResult = await runAgentCommand(stopAgents, 'stop');
1138
- if (agentsResult?.failed) return agentsResult;
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);
@@ -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
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.41",
3
- "commit": "d0180aa"
2
+ "version": "0.15.43",
3
+ "commit": "513dff5"
4
4
  }
@@ -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.41';
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
+ });
@@ -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
- assert.match(slash, /if \(stopsAgents\) \{\s*\n\s*const agentsResult = await runAgentCommand\(stopAgents, 'stop'\);/);
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 () => {
@@ -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
- const result = await llm.completeWithTools({
30
- system,
31
- tools,
32
- messages: convo,
33
- toolChoice: 'auto',
34
- signal,
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 ?? '',