@dotdrelle/wiki-manager 0.14.7 → 0.14.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.14.7",
3
+ "version": "0.14.9",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -403,6 +403,23 @@ function summarizeToolArguments(rawArguments) {
403
403
  }
404
404
  }
405
405
 
406
+ function googleOAuthUrlFromMessages(messages) {
407
+ for (const message of [...(messages ?? [])].reverse()) {
408
+ if (message?.role !== 'tool') continue;
409
+ const match = String(message.content ?? '').match(/https:\/\/accounts\.google\.com\/[^\s<>"')\]]+/i);
410
+ if (match) return match[0];
411
+ }
412
+ return null;
413
+ }
414
+
415
+ function preserveRequiredOAuthUrl(content, messages) {
416
+ const text = String(content ?? '');
417
+ const url = googleOAuthUrlFromMessages(messages);
418
+ if (!url || text.includes(url)) return text;
419
+ const prefix = text.trimEnd();
420
+ return `${prefix}${prefix ? '\n\n' : ''}Lien d’autorisation Google : ${url}`;
421
+ }
422
+
406
423
  function buildQueuedResult(session, item, activeJobId = null) {
407
424
  const message = activeJobId != null
408
425
  ? `Production job queued as ${item.id}; waiting for ${activeJobId}.`
@@ -709,6 +726,13 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
709
726
  if (tool === 'delegate') {
710
727
  const objective = String(args.objective ?? '').trim();
711
728
  if (!objective) return 'Delegation rejected: missing objective.';
729
+ const connectorConfig = connectorConfigurationTarget(session, objective);
730
+ if (connectorConfig?.setupTool) {
731
+ return `Delegation rejected: configuring or authenticating ${connectorConfig.serverName} is not an orchestrated export. Call the offered ${connectorConfig.serverName}__${connectorConfig.setupTool} tool directly and present its authorization instructions or URL to the user.`;
732
+ }
733
+ if (connectorConfig) {
734
+ return `Delegation rejected: ${connectorConfig.serverName} advertises no setup or authentication tool. Do not call an unrelated data tool and do not delegate to export. Explain conversationally that authentication must be completed outside MCP, using only configuration instructions already available in the current context.`;
735
+ }
712
736
  const result = await postRuntimeDelegate(objective, { url, workspace });
713
737
  return result?.runId
714
738
  ? JSON.stringify({
@@ -746,6 +770,24 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
746
770
  }
747
771
  }
748
772
 
773
+ function connectorConfigurationTarget(session, objective) {
774
+ const text = String(objective ?? '').toLowerCase();
775
+ if (!/(?:configur|connect|authent|oauth|setup|sign[ -]?in)/i.test(text)) return null;
776
+ for (const [serverName, server] of Object.entries(session?.mcp ?? {})) {
777
+ if (server?.status !== 'connected' || !Array.isArray(server.tools) || server.tools.length === 0) continue;
778
+ const aliases = String(serverName).toLowerCase().split(/[^a-z0-9]+/).filter((part) => part.length >= 3);
779
+ if (!aliases.some((alias) => text.includes(alias))) continue;
780
+ const setupTool = server.tools.find((tool) => {
781
+ const name = String(tool?.name ?? '').toLowerCase();
782
+ const description = String(tool?.description ?? '').toLowerCase();
783
+ return /(?:^|_)(?:setup|config|configure|auth|authenticate|oauth|connect)(?:_|$)/.test(name)
784
+ || /(?:initiat|start|configure|authenticate).{0,30}(?:oauth|authentication)/.test(description);
785
+ })?.name ?? null;
786
+ return { serverName, setupTool };
787
+ }
788
+ return null;
789
+ }
790
+
749
791
  function handleWikiTool(session, tool, args) {
750
792
  if (tool === 'plan_set') {
751
793
  const steps = Array.isArray(args.steps) ? args.steps : [];
@@ -888,10 +930,10 @@ export function buildAgentSystemPrompt(state) {
888
930
  'For service actions, recommend only available service primitives from Available primitives, with the exact service name when the primitive supports one.',
889
931
  'Scope discipline: execute ONLY the action(s) the user explicitly requested. Never chain additional mutating operations (ingest, build, export, polish, delete, send…) that the user did not ask for — even when diagnostics or recommendations suggest them. Finish with the requested result and stop. Example: "applique les recommandations de config" means apply the config; it does NOT authorize launching the ingest those recommendations mention.',
890
932
  state.session.runtime?.url
891
- ? 'The runtime is connected and runtime__delegate is bound and available to you right now — it is a tool you call directly, not a slash command or a missing primitive. It is the ONLY way to execute an action (ingest, build, export, configure, send…). Never tell the user that delegation or the runtime is unavailable while it is connected; call runtime__delegate instead.'
933
+ ? 'The runtime is connected and runtime__delegate is bound and available for heavy orchestrated operations (ingest, build, export, polish, pipeline). Single-step connector actions such as configure, authenticate, add a source, convert, search, or send use the connected MCP tool directly. Never delegate connector configuration to an export capability.'
892
934
  : 'No runtime is connected, so you cannot execute actions. State that plainly and name the runtime connection as the missing capability — do not invent a workaround.',
893
935
  'If the connector or service needed for a requested read or action is absent from the Connected MCP tools above (its service is not running — e.g. CME, documents, or production), say plainly that this service is not connected and name it as the missing capability. Never redirect a simple read (e.g. "give me the CME config") to an "agent action", never invent its result, and never propose a workaround. Only requests you can actually serve with a listed tool are answered with data.',
894
- 'For any requested action, call runtime__delegate with the user objective only. Never choose a capability, operation, agent, plan, or implementation yourself. The runtime resolves the registry and validates the provider plan before accepting. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
936
+ 'For heavy orchestrated operations only (ingest, build, export, polish, pipeline), call runtime__delegate with the user objective only. For a single-step connector action, call the offered connector tool directly. Never choose a capability, operation, agent, plan, or implementation yourself. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
895
937
  'Do not ask the user which sources, files, connectors, or templates to use for an ingest, build, or export: the specialized agent discovers them from the workspace. When the objective is clear (e.g. "lance une ingestion"), delegate it as stated, without a clarifying question.',
896
938
  'Promise only what the resolved capability actually exposes in its declared contract (the input schema the specialized agent publishes for that capability). When the user requests an execution parameter — a batch or chunk size, a count "N at a time", concurrency, ordering, priority, or any tuning knob — apply it only if that parameter exists in the target capability\'s published input schema. Otherwise do not confirm or promise it: delegate the objective, and if the user explicitly asked for that parameter, say plainly in one line that you started the work but do not control that aspect (the runtime and the specialized agent decide it). Never state or imply a parameter was applied when the agent contract cannot enforce it.',
897
939
  'If runtime__delegate returns a blocker or no specialized provider is available, report only that concrete blocker concisely. Never replace the missing execution path with a suggested slash command, skill, MCP tool name, manual file move, administrator escalation, or alternative workflow unless the user explicitly asks for alternatives.',
@@ -963,15 +1005,18 @@ function toolsForClassification(classification, writeTools, session = null) {
963
1005
  return [SHELL_READ_COMMAND_TOOL, ...controlTools, ...capabilityRunTools, ...writeTools];
964
1006
  }
965
1007
 
1008
+ const DONNA_READ_VERBS = new Set(['status', 'list', 'search', 'read', 'get', 'fetch']);
1009
+
966
1010
  export function isDonnaReadTool(item) {
967
1011
  const name = String(item?.function?.name ?? '');
968
1012
  if (!name || name.startsWith('shell__') || name === 'wiki__plan_set' || name === 'wiki__plan_done') return false;
969
1013
  if (item?.readOnly === true) return true;
970
1014
  const tool = name.includes('__') ? name.slice(name.indexOf('__') + 2) : name;
971
- return tool === 'wiki_workspace_status'
972
- || tool === 'agent_describe'
973
- || tool === 'agent_status'
974
- || /(?:^|_)(?:status|list|search|read|get)$/.test(tool);
1015
+ if (tool === 'wiki_workspace_status' || tool === 'agent_describe' || tool === 'agent_status') return true;
1016
+ // Match a read verb anywhere in the underscore-tokenized name, not just as
1017
+ // a trailing suffix — third-party MCPs don't all name tools verb-last
1018
+ // (e.g. exa's "web_search_exa"/"web_fetch_exa" put the verb in the middle).
1019
+ return tool.split('_').some((segment) => DONNA_READ_VERBS.has(segment));
975
1020
  }
976
1021
 
977
1022
  // Two-tier tool policy. Donna may call any connected MCP tool directly
@@ -1192,6 +1237,15 @@ export function createAgentGraph(options = {}) {
1192
1237
  };
1193
1238
  }
1194
1239
 
1240
+ // Authentication URLs are execution outputs, not optional prose. Small
1241
+ // models sometimes summarize an OAuth tool result as "open the supplied
1242
+ // link" while dropping the link itself, leaving ShellUI unusable even
1243
+ // though the MCP call succeeded. Preserve that exact URL deterministically.
1244
+ const finalContent = preserveRequiredOAuthUrl(result.content, conversationMessages);
1245
+ if (finalContent !== String(result.content ?? '')) {
1246
+ result.content = finalContent;
1247
+ result.message = { ...(result.message ?? { role: 'assistant' }), content: finalContent };
1248
+ }
1195
1249
  const invalidCommands = invalidSuggestedSlashCommands(result.content, state.session);
1196
1250
  const leakedTools = invalidUserFacingToolNames(result.content, state.session);
1197
1251
  if (invalidCommands.length > 0 || leakedTools.length > 0) {
@@ -1246,7 +1300,7 @@ export function createAgentGraph(options = {}) {
1246
1300
 
1247
1301
  // Fallback path (streamWithTools unavailable): hand off to runLine for streaming.
1248
1302
  state.session._onStep?.('Agent: streaming final answer…');
1249
- if (typeof llm.stream === 'function') {
1303
+ if (typeof llm.stream === 'function' && !googleOAuthUrlFromMessages(conversationMessages)) {
1250
1304
  return {
1251
1305
  response: null,
1252
1306
  pendingToolCalls: null,
@@ -395,6 +395,110 @@ test('Donna delegates the objective without choosing technical identifiers', asy
395
395
  }
396
396
  });
397
397
 
398
+ test('Donna refuses to delegate connector authentication to an export capability', async () => {
399
+ const originalFetch = globalThis.fetch;
400
+ const fetchedUrls = [];
401
+ globalThis.fetch = async (url, options = {}) => {
402
+ fetchedUrls.push(String(url));
403
+ const body = JSON.parse(String(options.body ?? '{}'));
404
+ assert.equal(body.params?.name, 'start_google_auth');
405
+ return {
406
+ ok: true,
407
+ status: 200,
408
+ headers: { get: () => null },
409
+ text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: 'ACTION REQUIRED: authorize at https://accounts.google.com/o/oauth2/auth?client_id=test&state=abc' }] } }),
410
+ };
411
+ };
412
+ let turn = 0;
413
+ const session = sessionBase({
414
+ runtime: { url: 'http://runtime.test' },
415
+ mcp: {
416
+ 'google-workspace': {
417
+ status: 'connected',
418
+ url: 'http://google.test/mcp',
419
+ tools: [
420
+ {
421
+ name: 'start_google_auth',
422
+ description: 'Manually initiate Google OAuth authentication flow.',
423
+ inputSchema: { type: 'object', additionalProperties: true },
424
+ },
425
+ { name: 'search_gmail_messages', inputSchema: { type: 'object', additionalProperties: true } },
426
+ ],
427
+ },
428
+ },
429
+ llm: {
430
+ async completeWithTools() {
431
+ turn += 1;
432
+ if (turn === 1) return {
433
+ content: null,
434
+ message: { role: 'assistant', content: null },
435
+ tool_calls: [{ id: 'wrong-delegate', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"je veux configurer google"}' } }],
436
+ };
437
+ if (turn === 2) return {
438
+ content: null,
439
+ message: { role: 'assistant', content: null },
440
+ tool_calls: [{ id: 'google-auth', type: 'function', function: { name: 'google-workspace__start_google_auth', arguments: '{}' } }],
441
+ };
442
+ return {
443
+ content: 'J’ai lancé l’authentification. Ouvre le lien fourni.',
444
+ message: { role: 'assistant', content: 'J’ai lancé l’authentification. Ouvre le lien fourni.' },
445
+ tool_calls: null,
446
+ };
447
+ },
448
+ },
449
+ });
450
+
451
+ try {
452
+ const result = await createAgentGraph().invoke({ input: 'je veux configurer google', session });
453
+ assert.match(result.response, /J’ai lancé l’authentification/);
454
+ assert.match(result.response, /https:\/\/accounts\.google\.com\/o\/oauth2\/auth\?client_id=test&state=abc/);
455
+ assert.equal(fetchedUrls.some((url) => url.includes('runtime.test')), false);
456
+ assert.equal(fetchedUrls.some((url) => url.includes('google.test')), true);
457
+ } finally {
458
+ globalThis.fetch = originalFetch;
459
+ }
460
+ });
461
+
462
+ test('Donna does not invent a setup tool when a connector advertises data tools only', async () => {
463
+ const originalFetch = globalThis.fetch;
464
+ globalThis.fetch = async () => assert.fail('neither runtime delegation nor an unrelated data tool should be called');
465
+ let turn = 0;
466
+ const session = sessionBase({
467
+ runtime: { url: 'http://runtime.test' },
468
+ mcp: {
469
+ acme: {
470
+ status: 'connected',
471
+ url: 'http://acme.test/mcp',
472
+ tools: [{ name: 'list_records', description: 'List records.', inputSchema: { type: 'object' } }],
473
+ },
474
+ },
475
+ llm: {
476
+ async completeWithTools({ messages }) {
477
+ turn += 1;
478
+ if (turn === 1) return {
479
+ content: null,
480
+ message: { role: 'assistant', content: null },
481
+ tool_calls: [{ id: 'wrong-delegate', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"configure acme"}' } }],
482
+ };
483
+ const refusal = (messages ?? []).filter((message) => message.role === 'tool').at(-1)?.content;
484
+ assert.match(String(refusal), /advertises no setup or authentication tool/);
485
+ return {
486
+ content: 'ACME doit être authentifié hors de cette interface.',
487
+ message: { role: 'assistant', content: 'ACME doit être authentifié hors de cette interface.' },
488
+ tool_calls: null,
489
+ };
490
+ },
491
+ },
492
+ });
493
+
494
+ try {
495
+ const result = await createAgentGraph().invoke({ input: 'configure acme', session });
496
+ assert.equal(result.response, 'ACME doit être authentifié hors de cette interface.');
497
+ } finally {
498
+ globalThis.fetch = originalFetch;
499
+ }
500
+ });
501
+
398
502
  test('runtime status does not manufacture a plan', async () => {
399
503
  const originalFetch = globalThis.fetch;
400
504
  globalThis.fetch = async () => ({
@@ -697,16 +801,10 @@ test('buildAgentSystemPrompt forbids inventing slash commands or arguments', ()
697
801
  assert.doesNotMatch(prompt, /executor:"/);
698
802
  });
699
803
 
700
- test('workspace package manifest is not exposed as an executable skill', () => {
804
+ test('workspace skills are discovered from the fixed .wiki/skills directory', () => {
701
805
  const root = mkdtempSync(join(tmpdir(), 'wiki-manager-skills-'));
702
806
  try {
703
807
  mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
704
- writeFileSync(join(root, 'skill.yaml'), [
705
- 'name: basic',
706
- 'description: Demo workspace package',
707
- 'entrypoints:',
708
- ' uiSkillDir: .wiki/skills',
709
- ].join('\n'));
710
808
  writeFileSync(join(root, '.wiki', 'skills', 'ingest.md'), [
711
809
  '---',
712
810
  'name: ingest',
@@ -716,7 +814,6 @@ test('workspace package manifest is not exposed as an executable skill', () => {
716
814
  ].join('\n'));
717
815
 
718
816
  const prompt = buildAgentSystemPrompt({ session: sessionBase({ workspacePath: root }) });
719
- assert.doesNotMatch(prompt, /\/basic:/);
720
817
  assert.match(prompt, /\/ingest: Ingest pending sources/);
721
818
  } finally {
722
819
  rmSync(root, { recursive: true, force: true });
@@ -6,11 +6,11 @@ import { ensureManagerScaffold, loadManagerEnv } from '../core/env.js';
6
6
  loadManagerEnv();
7
7
  import { createAgentGraph } from '../agent/graph.js';
8
8
  import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
9
- import { runShell } from '../shell/repl.js';
9
+ import { runShell, runHeadlessChatTurn } from '../shell/repl.js';
10
10
  import { runChecks } from '../core/startupCheck.js';
11
11
  import { applySessionWikircProfile } from '../core/sessionConfig.js';
12
12
  import { listWikircProfiles } from '../core/wikirc.js';
13
- import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
13
+ import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
14
14
  import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
15
15
  import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
16
16
  import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
@@ -478,7 +478,7 @@ async function runRuntime(argv, agent) {
478
478
  'Defaults:',
479
479
  ' --host 127.0.0.1',
480
480
  ' --port 7788',
481
- ' --state-dir .wiki-manager',
481
+ ' --state-dir .wiki/runtime',
482
482
  ].join('\n'));
483
483
  return;
484
484
  }
@@ -1039,7 +1039,25 @@ async function runRuntime(argv, agent) {
1039
1039
  workspace: context.workspace ?? null,
1040
1040
  payload: { content: input },
1041
1041
  }));
1042
- const response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
1042
+ // Read-only chat turn: same chatAccess policy as the Shell UI's /chat, now
1043
+ // reachable over HTTP so `wiki serve` chat mode gets read tools without
1044
+ // duplicating the loop. Anything other than mode === 'chat' stays the full
1045
+ // unrestricted agent turn.
1046
+ const chatMode = String(body.mode ?? '').toLowerCase() === 'chat';
1047
+ let response;
1048
+ if (chatMode) {
1049
+ ephemeral.chatMode = true;
1050
+ ephemeral.chatAccess = readChatAccessConfig();
1051
+ const history = messages.length && messages[messages.length - 1]?.role === 'user'
1052
+ ? messages.slice(0, -1)
1053
+ : messages;
1054
+ response = await runHeadlessChatTurn(ephemeral, input, {
1055
+ history,
1056
+ onStep: ephemeral._onStep,
1057
+ });
1058
+ } else {
1059
+ response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
1060
+ }
1043
1061
  ensureInteractiveAssistantMessage(ephemeral, response, {
1044
1062
  turnId,
1045
1063
  workspace: context.workspace ?? null,
@@ -232,8 +232,8 @@ function statLine(label, stat) {
232
232
  return `${label}: ${stat.count} (${formatBytes(stat.totalBytes)})`;
233
233
  }
234
234
 
235
- function workspaceStatsText(stats) {
236
- if (!stats) return 'No workspace loaded.';
235
+ function workspaceStatsColumns(stats) {
236
+ if (!stats) return { left: 'No workspace loaded.', right: '' };
237
237
  const hints = [];
238
238
  if (stats.untracked.count > 0) {
239
239
  hints.push(`${stats.untracked.count} raw/untracked document(s) are waiting for ingest.`);
@@ -280,11 +280,10 @@ function workspaceStatsText(stats) {
280
280
  ]);
281
281
  const hintsColumn = sectionBlock('Hints', hints.length > 0 ? hints : ['No immediate content action detected.']);
282
282
 
283
- return [
284
- twoColumns(wikiColumn, rawColumn),
285
- '',
286
- twoColumns(deliveryColumn, `${internalColumn}\n\n${hintsColumn}`),
287
- ].join('\n');
283
+ return {
284
+ left: [wikiColumn, deliveryColumn].join('\n\n'),
285
+ right: [rawColumn, internalColumn, hintsColumn].join('\n\n'),
286
+ };
288
287
  }
289
288
 
290
289
  function workspaceLoadedText(workspace, summary, session) {
@@ -530,16 +529,18 @@ async function statusText(session) {
530
529
  const runtimeColumn = sectionBlock('Runtime', (states ? serviceStatesText(states) : 'Docker runtime not available or no workspace loaded.').split('\n'));
531
530
  const mcpColumn = sectionBlock('MCP', formatMcpStatus(session.mcp).split('\n'));
532
531
  const mcpToolsColumn = sectionBlock('MCP tool summary', formatMcpToolSummary(session.mcp).split('\n'));
533
-
534
- return [
535
- twoColumns(workspaceColumn, configColumn),
536
- '',
537
- workspaceStatsText(workspaceStats),
538
- '',
539
- runtimeColumn,
540
- '',
541
- twoColumns(mcpColumn, mcpToolsColumn),
542
- ].join('\n');
532
+ const stats = workspaceStatsColumns(workspaceStats);
533
+
534
+ const leftColumn = [workspaceColumn, stats.left, runtimeColumn, mcpColumn].filter(Boolean).join('\n\n');
535
+ const rightColumn = [configColumn, stats.right, mcpToolsColumn].filter(Boolean).join('\n\n');
536
+
537
+ // Leading/trailing padding row on *both* columns so the boxed pair doesn't
538
+ // butt directly against the pane border when the view is scrolled to show
539
+ // the tail. A single space on each side (not '') keeps the row tab-joined,
540
+ // so both the left and right box render — an empty string on either side
541
+ // of the tab makes twoColumns drop the pairing and only the left box shows.
542
+ const pad = ' \t ';
543
+ return [pad, twoColumns(leftColumn, rightColumn), pad].join('\n');
543
544
  }
544
545
 
545
546
  function loadWorkspaceSystemPrompt(workspacePath) {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.7",
3
- "commit": "f17765a"
2
+ "version": "0.14.9",
3
+ "commit": "768fb25"
4
4
  }
package/src/core/env.js CHANGED
@@ -1,4 +1,4 @@
1
- import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
@@ -13,7 +13,14 @@ export function managerStateDir() {
13
13
  }
14
14
 
15
15
  export function managerRuntimeDir() {
16
- return join(managerStateDir(), '.wiki-manager');
16
+ const root = managerStateDir();
17
+ const runtimeDir = join(root, '.wiki', 'runtime');
18
+ const legacyDir = join(root, '.wiki-manager');
19
+ if (!existsSync(runtimeDir) && existsSync(legacyDir)) {
20
+ mkdirSync(join(root, '.wiki'), { recursive: true });
21
+ renameSync(legacyDir, runtimeDir);
22
+ }
23
+ return runtimeDir;
17
24
  }
18
25
 
19
26
  export function defaultRuntimeStateDir() {
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.14.7';
4
+ const WIKI_MANAGER_VERSION = '0.14.9';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -29,6 +29,27 @@ function normalizeHeaders(headers) {
29
29
  );
30
30
  }
31
31
 
32
+ // An endpoint's url/headers may reference `${VAR}` placeholders with no
33
+ // `:-default`. If that env var is unset, the placeholder interpolates to ''
34
+ // (see interpolateEnv) and the endpoint would otherwise look "configured"
35
+ // with a blank credential — then discoverMcpTools happily probes the live
36
+ // endpoint and can report it "connected" even though it has no real auth.
37
+ function hasMissingRequiredEnv(value) {
38
+ if (typeof value !== 'string') return false;
39
+ let missing = false;
40
+ value.replace(/\$\{([^}]+)\}/g, (_, expr) => {
41
+ const sep = expr.indexOf(':-');
42
+ if (sep === -1 && !envValue(expr)) missing = true;
43
+ return '';
44
+ });
45
+ return missing;
46
+ }
47
+
48
+ function endpointHasMissingCredentials(endpoint) {
49
+ const headerValues = Object.values(endpoint?.headers ?? {}).filter((value) => typeof value === 'string');
50
+ return [String(endpoint?.url ?? ''), ...headerValues].some(hasMissingRequiredEnv);
51
+ }
52
+
32
53
  function normalizeExternalUrlForRuntime(url) {
33
54
  if (process.env.WIKI_MANAGER_KEEP_DOCKER_HOST === '1') return url;
34
55
  try {
@@ -57,8 +78,14 @@ export function readChatAccessConfig() {
57
78
  if (!chatAccess || typeof chatAccess !== 'object' || Array.isArray(chatAccess)) return null;
58
79
  const servers = {};
59
80
  for (const [name, entry] of Object.entries(chatAccess.servers ?? {})) {
60
- if (entry?.allow === '*') servers[name] = { allow: '*' };
61
- else if (Array.isArray(entry?.allow)) servers[name] = { allow: entry.allow.map(String).filter(Boolean) };
81
+ // "*" is also commonly written as a one-element array (["*"]) since every
82
+ // other "allow" example in this config is an array of tool names — treat
83
+ // both forms as the same wildcard rather than silently allowing nothing.
84
+ if (entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*')) {
85
+ servers[name] = { allow: '*' };
86
+ } else if (Array.isArray(entry?.allow)) {
87
+ servers[name] = { allow: entry.allow.map(String).filter(Boolean) };
88
+ }
62
89
  }
63
90
  const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
64
91
  ? Math.floor(Number(chatAccess.maxToolIterations))
@@ -75,24 +102,27 @@ function readExternalMcpEndpoints() {
75
102
  return Object.fromEntries(
76
103
  Object.entries(servers)
77
104
  .filter(([, endpoint]) => endpoint?.url)
78
- .map(([name, endpoint]) => [
79
- name,
80
- {
81
- ...endpointStatus(true),
82
- url: normalizeExternalUrlForRuntime(interpolateEnv(String(endpoint.url))),
83
- configuredUrl: interpolateEnv(String(endpoint.url)),
84
- headers: normalizeHeaders(endpoint.headers),
85
- // Tools the endpoint marks approval-gated: Donna may still call them
86
- // directly (they are single-step tools), but toolRequiresApproval
87
- // makes the call wait for the user's confirmation first (e.g. a
88
- // destructive cme_export_run). Agent/operator owned — no hard-coded
89
- // business name in the manager.
90
- requireApproval: Array.isArray(endpoint.requireApproval)
91
- ? endpoint.requireApproval.map(String).filter(Boolean)
92
- : undefined,
93
- external: true,
94
- },
95
- ]),
105
+ .map(([name, endpoint]) => {
106
+ const missingCredentials = endpointHasMissingCredentials(endpoint);
107
+ return [
108
+ name,
109
+ {
110
+ ...endpointStatus(!missingCredentials, missingCredentials ? 'credential not set' : ''),
111
+ url: normalizeExternalUrlForRuntime(interpolateEnv(String(endpoint.url))),
112
+ configuredUrl: interpolateEnv(String(endpoint.url)),
113
+ headers: normalizeHeaders(endpoint.headers),
114
+ // Tools the endpoint marks approval-gated: Donna may still call them
115
+ // directly (they are single-step tools), but toolRequiresApproval
116
+ // makes the call wait for the user's confirmation first (e.g. a
117
+ // destructive cme_export_run). Agent/operator owned — no hard-coded
118
+ // business name in the manager.
119
+ requireApproval: Array.isArray(endpoint.requireApproval)
120
+ ? endpoint.requireApproval.map(String).filter(Boolean)
121
+ : undefined,
122
+ external: true,
123
+ },
124
+ ];
125
+ }),
96
126
  );
97
127
  }
98
128
 
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
- import { parse as parseYaml } from 'yaml';
4
3
 
5
4
  const SKILL_NAME_RE = /^[a-zA-Z0-9_-]{1,80}$/;
6
5
  const DEFAULT_UI_SKILL_DIR = '.wiki/skills';
@@ -51,33 +50,6 @@ export function readOptionalText(filePath) {
51
50
  return readFileSync(filePath, 'utf8').trim();
52
51
  }
53
52
 
54
- function safeRelativeEntry(value, fallback) {
55
- const text = String(value || fallback).trim();
56
- if (!text || text.startsWith('/') || text.includes('..')) return fallback;
57
- return text;
58
- }
59
-
60
- function readWorkspaceManifest(workspacePath) {
61
- const manifestPath = join(workspacePath, 'skill.yaml');
62
- if (!existsSync(manifestPath)) return null;
63
- try {
64
- const manifest = parseYaml(readFileSync(manifestPath, 'utf8'));
65
- if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return null;
66
- return { manifest, manifestPath };
67
- } catch {
68
- return null;
69
- }
70
- }
71
-
72
- function workspaceUiSkillDir(loadedManifest = null) {
73
- if (!loadedManifest) return DEFAULT_UI_SKILL_DIR;
74
- const { manifest } = loadedManifest;
75
- const entrypoints = manifest?.entrypoints && typeof manifest.entrypoints === 'object'
76
- ? manifest.entrypoints
77
- : {};
78
- return safeRelativeEntry(entrypoints.uiSkillDir, DEFAULT_UI_SKILL_DIR);
79
- }
80
-
81
53
  function collectDirectorySkills(dir, scope) {
82
54
  if (!existsSync(dir)) return [];
83
55
  return readdirSync(dir, { withFileTypes: true })
@@ -92,8 +64,7 @@ function collectDirectorySkills(dir, scope) {
92
64
  export function listSkills(session = {}) {
93
65
  const skills = [];
94
66
  if (session.workspacePath) {
95
- const loadedManifest = readWorkspaceManifest(session.workspacePath);
96
- skills.push(...collectDirectorySkills(join(session.workspacePath, workspaceUiSkillDir(loadedManifest)), 'workspace'));
67
+ skills.push(...collectDirectorySkills(join(session.workspacePath, DEFAULT_UI_SKILL_DIR), 'workspace'));
97
68
  }
98
69
 
99
70
  const byName = new Map();
@@ -81,7 +81,7 @@ test('recoveryManager requeues an active job by idempotencyKey when status is st
81
81
 
82
82
  function storeWithActiveTask() {
83
83
  const root = mkdtempSync(join(tmpdir(), 'wiki-manager-recovery-'));
84
- const stateDir = join(root, '.wiki-manager');
84
+ const stateDir = join(root, '.wiki', 'runtime');
85
85
  const store = openRuntimeStore({ stateDir });
86
86
  const runId = 'run-recovery';
87
87
  const taskId = 'run-recovery:build-a';
@@ -1,6 +1,6 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomUUID, timingSafeEqual } from 'node:crypto';
3
- import { createAgentEvent, dispatchAgentEvent, resetSessionProjection } from '../core/agentEvents.js';
3
+ import { createAgentEvent, dispatchAgentEvent, resetSessionProjection, reduceAgentEvents } from '../core/agentEvents.js';
4
4
  import { activeCacertPath } from '../core/cacert.js';
5
5
  import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
6
6
  import { validateContractInDev } from '../contracts/schemas.js';
@@ -284,7 +284,11 @@ export function startRuntimeServer({
284
284
  sendJson(response, 400, { error: 'Missing input.' });
285
285
  return;
286
286
  }
287
- if (context.running) {
287
+ // Read-only chat turns intentionally remain available while an agent
288
+ // run is active. Other interactive turns still become control
289
+ // messages so they cannot start a competing agent decision.
290
+ const readOnlyChat = String(body.mode ?? '').toLowerCase() === 'chat';
291
+ if (context.running && !readOnlyChat) {
288
292
  const result = await handleControlMessage(context, store, input, {
289
293
  intent: body.intent,
290
294
  startNextControlRequest,
@@ -305,7 +309,7 @@ export function startRuntimeServer({
305
309
  // A preceding serialized turn may have delegated and started a run
306
310
  // after this request was accepted. Reclassify against the fresh
307
311
  // state instead of starting another interactive decision in parallel.
308
- if (context.running) {
312
+ if (context.running && !readOnlyChat) {
309
313
  const result = await handleControlMessage(context, store, input, {
310
314
  intent: body.intent,
311
315
  startNextControlRequest,
@@ -595,10 +599,18 @@ function controlStatus(context, store) {
595
599
  };
596
600
  }
597
601
 
598
- function runtimeState(context, store, { workspace = null, session = null } = {}) {
602
+ export function runtimeState(context, store, { workspace = null, session = null } = {}) {
599
603
  const state = store.getState(context?.session ?? session ?? null, { workspace });
600
604
  return {
601
605
  ...state,
606
+ // Interactive (runtime_turn) replies are persisted as events but never
607
+ // merged into the canonical in-memory projection, so a state built from
608
+ // that projection omits them — chat mode and conversational agent turns
609
+ // then show no reply at all. Rebuild the conversation from the full
610
+ // persisted event log (the same source interactive turns use to seed their
611
+ // own history) so those replies surface. The log is a superset of the
612
+ // canonical run conversation, so run rendering is unaffected.
613
+ conversation: reduceAgentEvents(store.listEvents({ workspace })).conversation,
602
614
  status: context?.running ? 'running' : state.status ?? 'idle',
603
615
  running: Boolean(context?.running),
604
616
  runId: context?.currentRunId ?? state.runId ?? null,
@@ -2,7 +2,14 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
4
  import { createInteractiveSession, ensureInteractiveAssistantMessage } from '../cli/wiki-manager.js';
5
- import { startRuntimeServer } from './server.js';
5
+ import { runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
6
+
7
+ // Most server tests exercise endpoint behavior rather than authentication. Keep
8
+ // them independent from a developer's WIKI_MANAGER_RUNTIME_TOKEN environment;
9
+ // auth-specific tests can still override this default explicitly.
10
+ function startRuntimeServer(options) {
11
+ return startRuntimeServerImpl({ token: '', ...options });
12
+ }
6
13
 
7
14
  test('interactive runtime sessions isolate canonical run state', () => {
8
15
  const mcp = { wiki: { status: 'connected' } };
@@ -40,6 +47,35 @@ test('interactive turns publish a fallback assistant message exactly once', () =
40
47
  assert.equal(published[0].payload.content, 'Réponse concise.');
41
48
  });
42
49
 
50
+ test('runtime state rebuilds interactive conversation from persisted events', () => {
51
+ const user = {
52
+ ...createAgentEvent('user_message', { origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo', payload: { content: 'Bonjour' } }),
53
+ origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo',
54
+ };
55
+ const assistant = {
56
+ ...createAgentEvent('assistant_message', { origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo', payload: { content: 'Salut !' } }),
57
+ origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo',
58
+ };
59
+ const session = { agentProjection: { conversation: [] } };
60
+ const store = {
61
+ getState: (receivedSession) => {
62
+ assert.equal(receivedSession, session);
63
+ return { status: 'idle', conversation: [] };
64
+ },
65
+ listEvents: ({ workspace }) => {
66
+ assert.equal(workspace, 'demo');
67
+ return [user, assistant];
68
+ },
69
+ };
70
+
71
+ const state = runtimeState({ workspace: 'demo', session, running: false }, store, { workspace: 'demo' });
72
+
73
+ assert.deepEqual(state.conversation.map(({ role, content }) => ({ role, content })), [
74
+ { role: 'user', content: 'Bonjour' },
75
+ { role: 'assistant', content: 'Salut !' },
76
+ ]);
77
+ });
78
+
43
79
  test('runtime server checks bearer and x-runtime-token credentials', async (t) => {
44
80
  let handle;
45
81
  try {
@@ -1478,6 +1514,50 @@ test('runtime server accepts an interactive turn without starting a run', async
1478
1514
  }
1479
1515
  });
1480
1516
 
1517
+ test('runtime server keeps read-only chat turns available during an active run', async (t) => {
1518
+ const context = { workspace: 'demo', session: {}, running: true };
1519
+ let received = null;
1520
+ let handle;
1521
+ try {
1522
+ handle = await startRuntimeServer({
1523
+ host: '127.0.0.1',
1524
+ port: 0,
1525
+ token: 'runtime-secret',
1526
+ store: {
1527
+ dbPath: ':memory:',
1528
+ getState: () => ({ status: 'running' }),
1529
+ listEvents: () => [],
1530
+ },
1531
+ getContext: async () => context,
1532
+ run: async () => assert.fail('/turn must not start another runtime run'),
1533
+ turn: async (_context, body, meta) => { received = { body, meta }; },
1534
+ });
1535
+ } catch (err) {
1536
+ if (err?.code === 'EPERM') {
1537
+ t.skip('network listen is not permitted in this sandbox');
1538
+ return;
1539
+ }
1540
+ throw err;
1541
+ }
1542
+ try {
1543
+ const response = await fetch(`http://127.0.0.1:${handle.port}/turn`, {
1544
+ method: 'POST',
1545
+ headers: { authorization: 'Bearer runtime-secret', 'content-type': 'application/json' },
1546
+ body: JSON.stringify({ input: 'Quel est le statut CME ?', mode: 'chat', workspace: 'demo' }),
1547
+ });
1548
+ assert.equal(response.status, 202);
1549
+ const body = await response.json();
1550
+ assert.equal(body.kind, 'turn');
1551
+ await context.interactiveTurn;
1552
+ assert.equal(received.body.mode, 'chat');
1553
+ assert.equal(received.body.input, 'Quel est le statut CME ?');
1554
+ assert.equal(received.meta.turnId, body.turnId);
1555
+ assert.equal(context.running, true);
1556
+ } finally {
1557
+ await handle.close();
1558
+ }
1559
+ });
1560
+
1481
1561
  test('runtime health reports active runs across workspaces', async (t) => {
1482
1562
  let handle;
1483
1563
  try {
@@ -1261,7 +1261,10 @@ function ensureRuntimeMeta(metaPath) {
1261
1261
 
1262
1262
  function migrateLegacyRuntimeMeta(metaPath) {
1263
1263
  if (existsSync(metaPath)) return;
1264
- const legacyPath = join(dirname(metaPath), '..', '.wiki', 'meta.json');
1264
+ const stateDir = dirname(metaPath);
1265
+ const legacyPath = stateDir.endsWith(join('.wiki', 'runtime'))
1266
+ ? join(stateDir, '..', 'meta.json')
1267
+ : join(stateDir, '..', '.wiki', 'meta.json');
1265
1268
  if (!existsSync(legacyPath)) return;
1266
1269
  mkdirSync(dirname(metaPath), { recursive: true });
1267
1270
  renameSync(legacyPath, metaPath);
@@ -8,7 +8,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
8
8
  import { openRuntimeStore } from './store.js';
9
9
 
10
10
  function runtimeStateDir() {
11
- return join(mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-')), '.wiki-manager');
11
+ return join(mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-')), '.wiki', 'runtime');
12
12
  }
13
13
 
14
14
  function plannedTask(id) {
@@ -223,10 +223,10 @@ test('runtime store persists task assignments attempts and results from events',
223
223
 
224
224
  test('runtime store writes schema versions to sqlite and meta file', () => {
225
225
  const root = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
226
- const stateDir = join(root, '.wiki-manager');
226
+ const stateDir = join(root, '.wiki', 'runtime');
227
227
  const store = openRuntimeStore({ stateDir });
228
228
  const version = store.db.prepare('PRAGMA user_version').get().user_version;
229
- const meta = JSON.parse(readFileSync(join(root, '.wiki-manager', 'meta.json'), 'utf8'));
229
+ const meta = JSON.parse(readFileSync(join(root, '.wiki', 'runtime', 'meta.json'), 'utf8'));
230
230
 
231
231
  assert.equal(version, 1);
232
232
  assert.equal(meta.schemaVersion, 1);
@@ -249,7 +249,7 @@ test('runtime store refuses unknown sqlite schema versions', () => {
249
249
 
250
250
  test('runtime store refuses unknown runtime metadata versions', () => {
251
251
  const root = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
252
- const stateDir = join(root, '.wiki-manager');
252
+ const stateDir = join(root, '.wiki', 'runtime');
253
253
  mkdirSync(stateDir, { recursive: true });
254
254
  writeFileSync(join(stateDir, 'meta.json'), '{"schemaVersion":99}\n', 'utf8');
255
255
 
@@ -259,9 +259,9 @@ test('runtime store refuses unknown runtime metadata versions', () => {
259
259
  );
260
260
  });
261
261
 
262
- test('runtime store migrates legacy .wiki metadata to .wiki-manager', () => {
262
+ test('runtime store migrates legacy .wiki metadata to .wiki/runtime', () => {
263
263
  const root = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
264
- const stateDir = join(root, '.wiki-manager');
264
+ const stateDir = join(root, '.wiki', 'runtime');
265
265
  mkdirSync(join(root, '.wiki'), { recursive: true });
266
266
  writeFileSync(join(root, '.wiki', 'meta.json'), '{"schemaVersion":1,"legacy":true}\n', 'utf8');
267
267
 
@@ -312,7 +312,7 @@ function renderMarkdownLines(lines: Array<{ text: string; isCode: boolean }>, ro
312
312
  function isStatusOutput(message: { role: string; content: string }) {
313
313
  const content = String(message.content ?? '');
314
314
  return message.role === 'command'
315
- && content.startsWith('Workspace')
315
+ && content.trimStart().startsWith('Workspace')
316
316
  && content.includes('Config')
317
317
  && content.includes('MCP');
318
318
  }
package/src/shell/repl.js CHANGED
@@ -1279,6 +1279,35 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
1279
1279
  return { exit: false };
1280
1280
  }
1281
1281
 
1282
+ // Headless equivalent of runDirectChatTurn for HTTP callers (the runtime /turn
1283
+ // in chat mode). Reuses the exact same read-only policy — chatReadTools +
1284
+ // runChatReadToolLoop + buildDirectChatSystemPrompt — so there is no second
1285
+ // implementation of chat access; it just returns the final text instead of
1286
+ // driving a live repl bubble. The caller must have seeded session.chatAccess
1287
+ // (and session.mcp) so chatReadTools can resolve the allow-listed read tools.
1288
+ export async function runHeadlessChatTurn(session, input, { history = [], onStep } = {}) {
1289
+ const donnaMessage = { role: 'donna', content: '' };
1290
+ const readTools = chatReadTools(session);
1291
+ const canUseReadTools = readTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
1292
+ if (canUseReadTools) {
1293
+ await runChatReadToolLoop({ input, session, history, donnaMessage, onStep, readTools });
1294
+ return donnaMessage.content;
1295
+ }
1296
+ if (typeof session.llm?.stream === 'function') {
1297
+ let content = '';
1298
+ for await (const delta of session.llm.stream({
1299
+ system: buildDirectChatSystemPrompt(session),
1300
+ messages: [...history, { role: 'user', content: input }],
1301
+ signal: session._abortSignal,
1302
+ })) {
1303
+ const clean = stripDsmlArtifacts(delta);
1304
+ if (clean) content += clean;
1305
+ }
1306
+ return stripDsmlArtifacts(content).trimEnd() || formatLlmUnavailableMessage('flux vide');
1307
+ }
1308
+ return directChatUnavailableText(session);
1309
+ }
1310
+
1282
1311
  function directChatUnavailableText(session) {
1283
1312
  if (!session.workspacePath) {
1284
1313
  return 'Direct chat unavailable: no workspace loaded. Use /use <workspace>.';
@@ -7,6 +7,7 @@ import {
7
7
  applyRuntimeStateToShellSession,
8
8
  chatReadTools,
9
9
  createSession,
10
+ runHeadlessChatTurn,
10
11
  conversationMessages,
11
12
  recordRuntimeUnavailableAgentInput,
12
13
  runLine,
@@ -443,3 +444,36 @@ test('/chat falls back to the plain stream when no read tools are declared', asy
443
444
  assert.match(last.content, /PLAIN_STREAM/);
444
445
  assert.doesNotMatch(last.content, /SHOULD_NOT_APPEAR/);
445
446
  });
447
+
448
+ test('runHeadlessChatTurn (HTTP /chat) uses the read-tool path and returns text', async () => {
449
+ const session = createSession();
450
+ session.chatMode = true;
451
+ session.chatAccess = { maxToolIterations: 4, servers: { cme: { allow: ['cme_status'] } } };
452
+ session.mcp = { cme: { status: 'connected', tools: [{ name: 'cme_status', inputSchema: { type: 'object', properties: {} } }] } };
453
+ let usedComplete = false;
454
+ session.llm = {
455
+ async *stream() { yield 'STREAM_FALLBACK'; },
456
+ async completeWithTools() {
457
+ usedComplete = true;
458
+ return { tool_calls: [], content: 'CME est configuré.', message: { role: 'assistant', content: 'CME est configuré.' } };
459
+ },
460
+ };
461
+ const reply = await runHeadlessChatTurn(session, 'le cme est-il configuré', { history: [] });
462
+ assert.ok(usedComplete, 'completeWithTools path was taken');
463
+ assert.match(reply, /CME est configuré/);
464
+ assert.doesNotMatch(reply, /STREAM_FALLBACK/);
465
+ });
466
+
467
+ test('runHeadlessChatTurn falls back to the plain stream without read tools', async () => {
468
+ const session = createSession();
469
+ session.chatMode = true;
470
+ session.chatAccess = null;
471
+ session.mcp = {};
472
+ session.llm = {
473
+ async *stream() { yield 'PLAIN_STREAM'; },
474
+ async completeWithTools() { return { tool_calls: [], content: 'SHOULD_NOT_APPEAR' }; },
475
+ };
476
+ const reply = await runHeadlessChatTurn(session, 'bonjour', { history: [] });
477
+ assert.match(reply, /PLAIN_STREAM/);
478
+ assert.doesNotMatch(reply, /SHOULD_NOT_APPEAR/);
479
+ });
package/wiki-workspace CHANGED
@@ -17,7 +17,12 @@ WORKSPACES_DIR="${WIKI_WORKSPACES_DIR:-$DEFAULT_MANAGER_DIR/workspaces}"
17
17
  MANAGER_ENV_FILE="${WIKI_MANAGER_ENV_FILE:-$DEFAULT_MANAGER_DIR/.env}"
18
18
  MANAGER_ENDPOINTS_FILE="${WIKI_MANAGER_ENDPOINTS_FILE:-$DEFAULT_MANAGER_DIR/mcp.endpoints.json}"
19
19
  MANAGER_STATE_DIR="$(cd "$(dirname "$MANAGER_ENV_FILE")" && pwd)"
20
- MANAGER_RUNTIME_DIR="$MANAGER_STATE_DIR/.wiki-manager"
20
+ MANAGER_RUNTIME_DIR="$MANAGER_STATE_DIR/.wiki/runtime"
21
+ LEGACY_MANAGER_RUNTIME_DIR="$MANAGER_STATE_DIR/.wiki-manager"
22
+ if [[ ! -e "$MANAGER_RUNTIME_DIR" && -d "$LEGACY_MANAGER_RUNTIME_DIR" ]]; then
23
+ mkdir -p "$MANAGER_STATE_DIR/.wiki"
24
+ mv "$LEGACY_MANAGER_RUNTIME_DIR" "$MANAGER_RUNTIME_DIR"
25
+ fi
21
26
  CACERT_PATH="${WIKI_MANAGER_CACERT_PATH:-}"
22
27
 
23
28
  normalize_path() {