@dotdrelle/wiki-manager 0.15.91 → 0.15.94

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.
@@ -130,7 +130,7 @@ services:
130
130
  # error. Every compose-deployed ingest then ran without the Lot 4 barrier
131
131
  # and left the published map stale — the very defect that work fixed.
132
132
  # `copy` stays out on purpose: it is the legacy step, opt-in only.
133
- - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,doctor_apply,ingest,ingest_plan,ingest_apply,build,export,polish,restore,pipeline}
133
+ - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,doctor_apply,copy,ingest,ingest_plan,ingest_apply,ingest_rebuild,build,export,polish,restore,pipeline,lint}
134
134
  - PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
135
135
  # Parallelism levers — effective concurrency ≈ recommendedConcurrency.
136
136
  # Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.91",
3
+ "version": "0.15.94",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "start": "bun ./bin/wiki-manager.js",
22
- "test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.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/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.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/wikiWorkspaceStart.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/currentArtifact.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/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.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/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js",
22
+ "test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.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/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.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/wikiWorkspaceStart.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/currentArtifact.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/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.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/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js src/runtime/totp.test.js src/runtime/loginSession.test.js src/runtime/loginRoutes.test.js",
23
23
  "check-versions": "node scripts/check-versions.js",
24
24
  "prepack": "node scripts/check-versions.js",
25
25
  "prepublishOnly": "node scripts/check-versions.js",
@@ -555,6 +555,27 @@ function isUnresolvedTargetFailure(rawFailure) {
555
555
  return /file does not exist|does not exist|no files match/i.test(rawFailure);
556
556
  }
557
557
 
558
+ // An agent that plans zero tasks is not a failure: it found nothing to act on
559
+ // (no pending source, no template, no deliverable, no archived source to
560
+ // re-file) and says so in its own synthesis sentence. Relaying that outcome is
561
+ // the whole point — the old generic path called it "could not be started" and
562
+ // sent the reader hunting a connectivity problem that did not exist.
563
+ export function nothingToDoForDonna(rawReason) {
564
+ const reason = String(rawReason ?? '')
565
+ .replace(/\s*Available capabilities:\s*[\s\S]*$/i, '')
566
+ .replace(/\b(?:provider|endpoint)=[^\s,]+/g, '')
567
+ .replace(/\s{2,}/g, ' ')
568
+ .trim()
569
+ .slice(0, 400);
570
+ return JSON.stringify({
571
+ delegated: false,
572
+ nothingToDo: true,
573
+ reason,
574
+ instruction:
575
+ 'The agent ran its planning and found nothing to act on. Answer the user naturally in their language, stating that plainly (use the reason above), and do not call it a failure, do not suggest restarting a service, and do not retry the same action. Do not expose exception names, capability identifiers, tool names, UUIDs, or internal routing details.',
576
+ });
577
+ }
578
+
558
579
  function unresolvedTargetForDonna(rawFailure) {
559
580
  const cleaned = String(rawFailure ?? '')
560
581
  .replace(/\b(?:provider|endpoint)=[^\s,]+/g, '')
@@ -941,14 +962,19 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
941
962
  });
942
963
  }
943
964
  const result = await postRuntimeDelegate(objective, { url, workspace });
944
- return result?.runId
945
- ? JSON.stringify({
946
- delegated: true,
947
- runId: result.runId,
948
- summary: result.delegation ?? null,
949
- message: `Action started (${String(result.runId).slice(0, 8)}) after real-plan validation: ${result.delegation?.tasks ?? 0} task(s), ${result.delegation?.agent ?? 'resolved agent'}. Execution in progress.`,
950
- })
951
- : `Delegation refused: ${result?.error ?? JSON.stringify(result)}`;
965
+ if (result?.runId) {
966
+ return JSON.stringify({
967
+ delegated: true,
968
+ runId: result.runId,
969
+ summary: result.delegation ?? null,
970
+ message: `Action started (${String(result.runId).slice(0, 8)}) after real-plan validation: ${result.delegation?.tasks ?? 0} task(s), ${result.delegation?.agent ?? 'resolved agent'}. Execution in progress.`,
971
+ });
972
+ }
973
+ const refused = String(result?.error ?? JSON.stringify(result));
974
+ // Same EMPTY_PLAN outcome as the in-run path above: relay it, don't call
975
+ // it a failure.
976
+ if (/^EMPTY_PLAN:\s*/i.test(refused)) return nothingToDoForDonna(refused.replace(/^EMPTY_PLAN:\s*/i, ''));
977
+ return `Delegation refused: ${refused}`;
952
978
  }
953
979
  if (tool === 'run_skill') {
954
980
  const skillName = String(args.skillName ?? '').trim();
@@ -2009,6 +2035,14 @@ export function createAgentGraph(options = {}) {
2009
2035
  missingRequiredFields: needsInput[1].split(',').map((item) => item.trim()).filter(Boolean),
2010
2036
  instruction: 'Ask the user for the missing required information. Do not expose internal validation details.',
2011
2037
  });
2038
+ } else if (/^EMPTY_PLAN:\s*/i.test(delegationFailure)) {
2039
+ // Valid planning, nothing to do (no pending source/template/
2040
+ // deliverable). Not a failure and not retryable — Donna relays
2041
+ // the agent's own sentence instead of a connectivity-sounding
2042
+ // generic error. Checked before the target heuristic: a plan
2043
+ // reason is authoritative and could mention a missing file
2044
+ // without meaning "unresolved target".
2045
+ resultText = nothingToDoForDonna(delegationFailure.replace(/^EMPTY_PLAN:\s*/i, ''));
2012
2046
  } else if (isUnresolvedTargetFailure(delegationFailure)) {
2013
2047
  // A named target that resolves to nothing is not an unsupported
2014
2048
  // action: Donna can look it up and retry (or ask), so the turn
@@ -3,7 +3,7 @@ import test from 'node:test';
3
3
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { bareToolCallJson, buildAgentSystemPrompt, connectorConfigurationTarget, createAgentGraph, invalidSuggestedSlashCommands, invalidUserFacingToolNames, isOrchestrationBypassTool, knownCapabilityIds, normalizeToolArgumentsFromSchema } from './graph.js';
6
+ import { bareToolCallJson, buildAgentSystemPrompt, connectorConfigurationTarget, createAgentGraph, invalidSuggestedSlashCommands, invalidUserFacingToolNames, isOrchestrationBypassTool, knownCapabilityIds, normalizeToolArgumentsFromSchema, nothingToDoForDonna } from './graph.js';
7
7
 
8
8
  test('user-facing response guard hides MCP identifiers generically', () => {
9
9
  const session = sessionBase();
@@ -13,6 +13,29 @@ test('user-facing response guard hides MCP identifiers generically', () => {
13
13
  );
14
14
  });
15
15
 
16
+ test('an empty agent plan is relayed as "nothing to do", not as a failure', () => {
17
+ // The agent planned zero tasks and said why: no pending source, no template,
18
+ // no deliverable. That is an outcome. The old generic path called it "could
19
+ // not be started" and sent the reader after a connectivity problem that did
20
+ // not exist.
21
+ const payload = JSON.parse(nothingToDoForDonna(
22
+ 'No Markdown files were found in raw/untracked; no ingest operation was planned.',
23
+ ));
24
+ assert.equal(payload.delegated, false);
25
+ assert.equal(payload.nothingToDo, true);
26
+ assert.match(payload.reason, /No Markdown files were found in raw\/untracked/);
27
+ assert.match(payload.instruction, /do not call it a failure/i);
28
+ assert.doesNotMatch(payload.instruction, /EMPTY_PLAN/);
29
+ });
30
+
31
+ test('the nothing-to-do reason strips internal routing details', () => {
32
+ const payload = JSON.parse(nothingToDoForDonna(
33
+ 'No templates were found. Available capabilities: document.build, knowledge.update provider=production endpoint=http://host:3202',
34
+ ));
35
+ assert.doesNotMatch(payload.reason, /Available capabilities/);
36
+ assert.doesNotMatch(payload.reason, /provider=|endpoint=/);
37
+ });
38
+
16
39
  test('CME setup stays direct while CME export execution stays orchestrated', () => {
17
40
  assert.equal(isOrchestrationBypassTool('cme__cme_export_run'), true);
18
41
  assert.equal(isOrchestrationBypassTool('cme__cme_setup'), false);
@@ -11,6 +11,7 @@ import { randomUUID } from 'node:crypto';
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { mkdir, writeFile } from 'node:fs/promises';
14
+ import { createInterface } from 'node:readline/promises';
14
15
  import { dirname, join, resolve } from 'node:path';
15
16
  import { fileURLToPath } from 'node:url';
16
17
  import { ensureManagerScaffold, loadManagerEnv } from '../core/env.js';
@@ -49,6 +50,17 @@ function valueAfter(argv, flag) {
49
50
  return argv[index + 1];
50
51
  }
51
52
 
53
+ async function confirmOnTty(question, defaultValue) {
54
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue;
55
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
56
+ try {
57
+ const answer = (await readline.question(`${question} [y/N] `)).trim().toLowerCase();
58
+ return answer === 'y' || answer === 'yes';
59
+ } finally {
60
+ readline.close();
61
+ }
62
+ }
63
+
52
64
  function errorDiagnostic(err) {
53
65
  const parts = [];
54
66
  let current = err;
@@ -949,6 +961,11 @@ async function runRuntime(argv, agent) {
949
961
  const host = valueAfter(argv, '--host') ?? process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0';
950
962
  const port = Number(valueAfter(argv, '--port') ?? process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788);
951
963
  const stateDir = valueAfter(argv, '--state-dir') ?? defaultRuntimeStateDir();
964
+ // The TOTP login modules resolve their state directory from the ENV, not
965
+ // from the CLI option: without this, `runtime --state-dir X` would store
966
+ // totp.json/session.json in the DEFAULT manager state dir while the rest of
967
+ // the runtime uses X — sessions issued on one side invisible to the other.
968
+ process.env.WIKI_MANAGER_STATE_DIR = stateDir;
952
969
  const auth = resolveRuntimeAuthToken({ host, stateDir });
953
970
  if (auth.token) process.env.WIKI_MANAGER_RUNTIME_TOKEN = auth.token;
954
971
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
@@ -1351,7 +1368,15 @@ async function runRuntime(argv, agent) {
1351
1368
  );
1352
1369
  }
1353
1370
  if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
1354
- throw new Error(fragment?.summary?.initialSynthesis?.[0] ?? `No task was planned for ${selection.capability}/${selection.operation}.`);
1371
+ // An agent may legitimately plan nothing: no pending source to ingest, no
1372
+ // template, no deliverable, no archived source to re-file. That is an
1373
+ // OUTCOME, not a failure — the agent carries its own human-readable
1374
+ // sentence in summary.initialSynthesis. The stable EMPTY_PLAN sentinel
1375
+ // tells graph.js to relay that outcome instead of the generic "could not
1376
+ // be started" failure (which read as a connectivity problem).
1377
+ const plannedReason = String(fragment?.summary?.initialSynthesis?.[0] ?? '').replace(/\s+/g, ' ').trim();
1378
+ const reason = plannedReason || `${selection.capability} planned no task for ${selection.operation}.`;
1379
+ throw new Error(`EMPTY_PLAN: ${reason}`);
1355
1380
  }
1356
1381
  const validation = validateFragment(fragment, {
1357
1382
  registry: capabilityRegistryForSession(session),
@@ -1898,6 +1923,65 @@ export async function runCli(argv) {
1898
1923
  return;
1899
1924
  }
1900
1925
 
1926
+ if (argv[0] === 'login' || argv[0] === 'logout') {
1927
+ const { ensureRuntime } = await import('../runtime/lifecycle.js');
1928
+ const { fetchLoginStatus, requestTotpSession, revokeRuntimeSession } = await import('../runtime/totpLogin.js');
1929
+ let runtime = null;
1930
+ try {
1931
+ runtime = await ensureRuntime();
1932
+ } catch (err) {
1933
+ runtime = { url: null, error: err instanceof Error ? err.message : String(err) };
1934
+ }
1935
+ if (!runtime.url) throw new Error(`Runtime unavailable: ${runtime.error}`);
1936
+ if (argv[0] === 'logout') {
1937
+ const { currentSessionToken } = await import('../runtime/loginSession.js');
1938
+ const revoked = await revokeRuntimeSession(runtime, currentSessionToken());
1939
+ console.log(revoked ? 'Session revoked.' : 'No active session to revoke (or the runtime is not answering).');
1940
+ return;
1941
+ }
1942
+ if (argv.includes('--reset')) {
1943
+ // Re-enrollment after a lost authenticator: wipes the secret and the
1944
+ // active session (local files, not an HTTP route) then runs the normal
1945
+ // login flow, which lands on a fresh enrollment QR code.
1946
+ const confirmed = argv.includes('--yes') || (await confirmOnTty(
1947
+ 'Reset the TOTP enrollment? This revokes the active session.\nContinue?',
1948
+ false,
1949
+ ));
1950
+ if (!confirmed) {
1951
+ console.log('Aborted.');
1952
+ return;
1953
+ }
1954
+ const { resetTotpEnrollment } = await import('../runtime/loginSession.js');
1955
+ resetTotpEnrollment();
1956
+ console.log('TOTP enrollment and the active session were reset — enrolling a new authenticator now.');
1957
+ }
1958
+ const status = await fetchLoginStatus(runtime);
1959
+ if (status && !status.enabled) {
1960
+ console.log('TOTP login is disabled (WIKI_MANAGER_TOTP=off).');
1961
+ return;
1962
+ }
1963
+ if (!argv.includes('--reset') && status?.sessionActive) {
1964
+ // A session already exists, but the *browser* only gets the shared
1965
+ // wiki_session cookie the moment it verifies a code on the runtime's
1966
+ // origin. Re-open the page when asked so a browser that has none can
1967
+ // seed it (and then reuse it on serve, same host) without waiting for
1968
+ // the session to expire first.
1969
+ if (!argv.includes('--no-open')) {
1970
+ const { loginPageUrl, openBrowser } = await import('../runtime/totpLogin.js');
1971
+ try {
1972
+ await openBrowser(loginPageUrl(runtime));
1973
+ } catch {
1974
+ console.log(`Open ${loginPageUrl(runtime)} to seed this browser's session.`);
1975
+ }
1976
+ }
1977
+ console.log(`Session already active until ${new Date(status.sessionExpiresAt).toLocaleString()}.`);
1978
+ return;
1979
+ }
1980
+ const result = await requestTotpSession(runtime, { open: !argv.includes('--no-open') });
1981
+ if (!result.ok) throw new Error(result.error);
1982
+ return;
1983
+ }
1984
+
1901
1985
  if (argv.includes('--setup-wizard')) {
1902
1986
  if (!process.versions.bun) {
1903
1987
  throw new Error('Setup wizard requires Bun. Run: bun ./bin/wiki-manager.js --setup-wizard');
@@ -2008,6 +2092,15 @@ export async function runCli(argv) {
2008
2092
  runtime = unavailableRuntime(err);
2009
2093
  console.error(`Runtime unavailable: ${runtime.error}`);
2010
2094
  }
2095
+ if (runtime.url) {
2096
+ const { requestTotpSession } = await import('../runtime/totpLogin.js');
2097
+ const gate = await requestTotpSession(runtime);
2098
+ if (!gate.ok) {
2099
+ console.error(gate.error);
2100
+ process.exitCode = 1;
2101
+ return;
2102
+ }
2103
+ }
2011
2104
  preflight = withRuntimePreflight(preflight, runtime);
2012
2105
  // render() resolves at mount; the TUI owns renderer teardown. The shared
2013
2106
  // runtime deliberately survives shell exit because `serve` may use it.
@@ -2030,6 +2123,15 @@ export async function runCli(argv) {
2030
2123
  runtime = unavailableRuntime(err);
2031
2124
  console.error(`Runtime unavailable: ${runtime.error}`);
2032
2125
  }
2126
+ if (runtime.url) {
2127
+ const { requestTotpSession } = await import('../runtime/totpLogin.js');
2128
+ const gate = await requestTotpSession(runtime);
2129
+ if (!gate.ok) {
2130
+ console.error(gate.error);
2131
+ process.exitCode = 1;
2132
+ return;
2133
+ }
2134
+ }
2033
2135
  }
2034
2136
  await runShell({ agent, packageJson, runtime });
2035
2137
  }
@@ -109,6 +109,26 @@ export function compactBaseUrl(value) {
109
109
  }
110
110
  }
111
111
 
112
+ /*
113
+ The web UI URL `/openui` opens. The runtime sets the `wiki_session` cookie on
114
+ its own origin when the ShellUI logs in, and cookies ignore the port, so we
115
+ reuse the runtime's loopback hostname: serve on that same host then validates
116
+ the shared session instead of asking for a second TOTP code. A non-loopback
117
+ runtime (remote deployment) shares no cookie with the local browser, so we
118
+ keep the plain `localhost` URL there — behaving exactly as before.
119
+ */
120
+ export function webUiUrl(port, runtimeUrl) {
121
+ let runtimeHost = null;
122
+ try {
123
+ if (runtimeUrl) runtimeHost = new URL(runtimeUrl).hostname || null;
124
+ } catch {
125
+ runtimeHost = null;
126
+ }
127
+ const loopback = runtimeHost === 'localhost' || runtimeHost === '::1' || /^127\./.test(runtimeHost ?? '');
128
+ const host = loopback ? runtimeHost : 'localhost';
129
+ return `http://${host}:${port}`;
130
+ }
131
+
112
132
  function commandLabel(value) {
113
133
  return `${styles.bold}${styles.cyan}${value}${styles.reset}`;
114
134
  }
@@ -726,8 +746,8 @@ async function statusText(session) {
726
746
  const runtimesColumn = runtimeProvidersSection(session);
727
747
  const stats = workspaceStatsColumns(workspaceStats, session);
728
748
 
729
- const wikiColumnAll = [workspaceColumn, stats.wiki, runtimeColumn].filter(Boolean).join('\n\n');
730
- const configColumnAll = [configColumn, stats.tuning, mcpColumn, runtimesColumn].filter(Boolean).join('\n\n');
749
+ const wikiColumnAll = [workspaceColumn, stats.wiki, runtimeColumn, mcpColumn].filter(Boolean).join('\n\n');
750
+ const configColumnAll = [configColumn, stats.tuning, runtimesColumn].filter(Boolean).join('\n\n');
731
751
 
732
752
  // Leading/trailing blank row so the boxed pair doesn't butt directly against
733
753
  // the pane border when the view is scrolled to show the tail. It is padding,
@@ -1734,9 +1754,23 @@ export async function handleSlashCommand(line, context) {
1734
1754
  }
1735
1755
  case 'openui': {
1736
1756
  const port = context.session.workspaceEnv?.WIKI_SERVE_PORT ?? '3100';
1737
- const url = `http://localhost:${port}`;
1757
+ const url = webUiUrl(port, context.runtime?.url ?? context.session?.runtime?.url ?? null);
1758
+ // Hand the ShellUI session to the browser so serve opens without a second
1759
+ // TOTP code: the token rides in the URL fragment (never sent to the
1760
+ // server, so it stays out of logs), and serve's login page exchanges it
1761
+ // for its cookie on load. Open `/login` directly rather than the root so
1762
+ // the fragment never has to survive a redirect. The printed line never
1763
+ // carries the token.
1764
+ let openUrl = url;
1765
+ try {
1766
+ const { currentSessionToken } = await import('../runtime/loginSession.js');
1767
+ const token = currentSessionToken();
1768
+ if (token) openUrl = `${url}/login#t=${encodeURIComponent(token)}`;
1769
+ } catch {
1770
+ // No readable session (gate off, or a custom state dir): plain URL.
1771
+ }
1738
1772
  const note = context.session.workspaceEnv ? '' : ' (no workspace loaded — using default port)';
1739
- if (openExternalUrl(url)) return { output: `Opening web UI: ${url}${note}` };
1773
+ if (openExternalUrl(openUrl)) return { output: `Opening web UI: ${url}${note}` };
1740
1774
  return { output: `Web UI: ${url}${note}` };
1741
1775
  }
1742
1776
  case 'clear': {
@@ -4,7 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
- import { agentConcurrencySections, compactBaseUrl, compactMcpStatus, handleSlashCommand, localizedOperationResult, refreshMcpRuntimeStatus } from './slash.js';
7
+ import { agentConcurrencySections, compactBaseUrl, compactMcpStatus, handleSlashCommand, localizedOperationResult, refreshMcpRuntimeStatus, webUiUrl } from './slash.js';
8
8
  import { completionContext } from '../shell/repl.js';
9
9
 
10
10
  test('deterministic operation results ask Donna to localize compact facts without leaking commands', () => {
@@ -52,6 +52,16 @@ test('/status base URL displays only its domain while retaining the full link',
52
52
  assert.equal(compactBaseUrl(undefined), '-');
53
53
  });
54
54
 
55
+ test('/openui reuses the loopback runtime host so serve shares the TOTP session', () => {
56
+ // The runtime sets wiki_session on its own origin and cookies ignore the
57
+ // port: opening serve on the same host avoids a second login. Non-loopback
58
+ // runtimes share no cookie, so the plain localhost URL stays.
59
+ assert.equal(webUiUrl('3200', 'http://127.0.0.1:7788'), 'http://127.0.0.1:3200');
60
+ assert.equal(webUiUrl('3200', 'http://localhost:7788'), 'http://localhost:3200');
61
+ assert.equal(webUiUrl('3200', null), 'http://localhost:3200');
62
+ assert.equal(webUiUrl('3200', 'https://manager.example.com'), 'http://localhost:3200');
63
+ });
64
+
55
65
  test('/status replaces Internal and Hints with effective agent concurrency', () => {
56
66
  const sections = agentConcurrencySections({
57
67
  agentRegistrySnapshot: [
@@ -215,6 +215,8 @@ export function conversationEventSequences(events = []) {
215
215
  function createProjectionState() {
216
216
  return {
217
217
  conversation: [],
218
+ conversationSeedStart: 0,
219
+ conversationSummary: null,
218
220
  chain: [],
219
221
  plan: null,
220
222
  activities: {},
@@ -234,6 +236,8 @@ function createProjectionState() {
234
236
  function publicProjection(state) {
235
237
  const projection = {
236
238
  conversation: state.conversation.map((message) => ({ ...message })),
239
+ conversationSeedStart: state.conversationSeedStart ?? 0,
240
+ conversationSummary: state.conversationSummary ?? null,
237
241
  chain: state.chain.map((step) => ({ ...step })),
238
242
  plan: state.plan ? state.plan.map((step) => ({ ...step })) : null,
239
243
  activities: sortedActivities(state.activities).map((activity) => ({ ...activity })),
@@ -313,6 +317,25 @@ function applyEvent(state, event) {
313
317
  case 'user_message':
314
318
  state.conversation.push({ role: 'user', content: String(event.payload?.content ?? '') });
315
319
  return;
320
+ case 'conversation_reset':
321
+ // A deliberate user action (the served chat's memory gauge): everything
322
+ // said before this point stops feeding conversationSeed's LLM context,
323
+ // WITHOUT touching the displayed conversation — the thread stays visible
324
+ // everywhere (ShellUI and serve), only what Donna is told going forward
325
+ // is reset. Nothing is removed from the event log. Marking the boundary
326
+ // (length at reset time) instead of clearing state.conversation is what
327
+ // keeps the two concerns apart: the gauge/seed read conversationSeedStart,
328
+ // the display reads conversation.
329
+ state.conversationSeedStart = state.conversation.length;
330
+ // The summary is best-effort (an LLM call the compact route makes before
331
+ // dispatching this event): when it succeeds it REPLACES the previous one
332
+ // — it is a rolling summary of "everything before this point", not an
333
+ // accumulating log — and when it fails or is skipped (no LLM configured)
334
+ // the previous summary survives rather than being wiped by an empty one.
335
+ if (typeof event.payload?.summary === 'string' && event.payload.summary.trim()) {
336
+ state.conversationSummary = event.payload.summary.trim();
337
+ }
338
+ return;
316
339
  case 'assistant_message':
317
340
  finalizeAssistantMessage(state, String(event.payload?.content ?? ''));
318
341
  return;
@@ -39,6 +39,43 @@ test('a streamed reply keeps the sequence of the delta that created it', () => {
39
39
  assert.deepEqual(conversationEventSequences(events), [1, 2]);
40
40
  });
41
41
 
42
+ test('conversation_reset marks a seed boundary and keeps the displayed thread', () => {
43
+ const events = sequenced([
44
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'avant' } }),
45
+ createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'réponse avant' } }),
46
+ createAgentEvent('conversation_reset', { origin: 'user', payload: {} }),
47
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'après' } }),
48
+ ]);
49
+
50
+ const projection = reduceAgentEvents(events);
51
+ // The thread stays whole — compacting must not erase what the reader sees…
52
+ assert.deepEqual(projection.conversation.map((message) => message.content), ['avant', 'réponse avant', 'après']);
53
+ // …only the grounding boundary moves.
54
+ assert.equal(projection.conversationSeedStart, 2);
55
+ });
56
+
57
+ test('conversation_reset stores the summary it carries and keeps it through a later compact with none', () => {
58
+ const events = sequenced([
59
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'avant' } }),
60
+ createAgentEvent('conversation_reset', { origin: 'user', payload: { summary: 'Résumé 1' } }),
61
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'entre' } }),
62
+ // A summary is best-effort (an LLM call): a compact with no summary (LLM
63
+ // unavailable, or nothing worth summarizing) must not erase the last one.
64
+ createAgentEvent('conversation_reset', { origin: 'user', payload: {} }),
65
+ ]);
66
+ const projection = reduceAgentEvents(events);
67
+ assert.equal(projection.conversationSummary, 'Résumé 1');
68
+ });
69
+
70
+ test('a later compact with a new summary replaces the previous one', () => {
71
+ const events = sequenced([
72
+ createAgentEvent('conversation_reset', { origin: 'user', payload: { summary: 'Résumé 1' } }),
73
+ createAgentEvent('conversation_reset', { origin: 'user', payload: { summary: 'Résumé 2' } }),
74
+ ]);
75
+ const projection = reduceAgentEvents(events);
76
+ assert.equal(projection.conversationSummary, 'Résumé 2');
77
+ });
78
+
42
79
  test('an independent queued skill invocation never inherits the active run identity', () => {
43
80
  const session = {
44
81
  workspace: 'docs',
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.91",
3
- "commit": "fb6be4f"
2
+ "version": "0.15.94",
3
+ "commit": "00c41b1"
4
4
  }
@@ -27,6 +27,20 @@ test('workspace production agent enables restore by default', async () => {
27
27
  assert.match(String(allowed), /(?:^|,)restore(?:,|})/);
28
28
  });
29
29
 
30
+ test('the manager allowlist carries the new knowledge steps, not only the agent default', async () => {
31
+ // This env OVERRIDES agent-production's own default: a value missing
32
+ // ingest_rebuild/lint silently filters knowledge.rebuild/knowledge.check out
33
+ // of agent_describe (the capability is published only if one of its steps
34
+ // survives). That is exactly what made the wiki-row rebuild button run a
35
+ // plain ingest from raw/untracked instead of re-filing raw/ingested.
36
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
37
+ const compose = YAML.parse(raw);
38
+ const allowed = String(compose.services['production-mcp'].environment
39
+ .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS=')));
40
+ assert.match(allowed, /(?:^|,)ingest_rebuild(?:,|})/);
41
+ assert.match(allowed, /(?:^|,)lint(?:,|})/);
42
+ });
43
+
30
44
  test('the shipped default carries no step the engine retired in 0.15.66', async () => {
31
45
  /*
32
46
  0.15.66 a retiré du moteur llm-wiki les commandes `wiki concepts`,
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.91';
4
+ const WIKI_MANAGER_VERSION = '0.15.94';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -31,7 +31,7 @@ test('validation rejects technical routing details', () => {
31
31
  });
32
32
 
33
33
  test('every shipped scaffold skill compiles to a single intention, deterministically', async () => {
34
- const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1 };
34
+ const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-rebuild': 1 };
35
35
  // Passing no llmFallback used to make this test assert the one path
36
36
  // production never takes: an ambiguous body silently returns the safe
37
37
  // mono-intention fallback, so the count was 1 and the test was green while
@@ -47,6 +47,26 @@ test('every shipped scaffold skill compiles to a single intention, deterministic
47
47
  }
48
48
  });
49
49
 
50
+ test('the shipped wiki-rebuild skill resolves through the deterministic alias path only', async () => {
51
+ // The objective resolver's fast path fires only when EXACTLY ONE capability
52
+ // alias phrase matches the objective. Bare words other agents alias ('build',
53
+ // 'rebuild', 'ingest', 'check', 'export'…) would make the LLM resolver decide
54
+ // instead. The shipped body is worded to stay on the deterministic path: it
55
+ // must carry the agent-production knowledge.rebuild alias phrase and none of
56
+ // the colliding words — verified against the alias lists actually shipped in
57
+ // agent-production (knowledge.rebuild / knowledge.check) and the other
58
+ // agents (cme: 'export sources'…, gateway: 'check'…).
59
+ const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', 'wiki-rebuild.md'), 'utf8');
60
+ const { body } = parseFrontmatter(raw);
61
+ const objectives = await compileSkillObjectives({ body }, {});
62
+ assert.equal(objectives.length, 1);
63
+ const text = objectives[0].text;
64
+ assert.match(text, /file the archived sources/i);
65
+ for (const word of ['ingest', 'build', 'rebuild', 'export', 'publish', 'okf', 'frontmatter', 'diagnose', 'restore', 'pipeline', 'check', 'audit', 'review', 'analyze', 'compare']) {
66
+ assert.doesNotMatch(text, new RegExp(`\\b${word}\\b`, 'i'), `word "${word}" must not appear in the objective`);
67
+ }
68
+ });
69
+
50
70
  test('every orchestrated scaffold skill declares the capability it targets', () => {
51
71
  // Without a declaration the capability is inferred from the body's prose by
52
72
  // alias matching, which any runtime added to agent-runtimes.json can break by
@@ -108,6 +108,33 @@ test('resolveObjective resolves an ingest objective deterministically despite no
108
108
  assert.equal(result.operation, 'ingest');
109
109
  });
110
110
 
111
+ test('"rebuild" resolves to knowledge.rebuild, not document.build', async () => {
112
+ // document.build used to alias "rebuild" too, so the wiki row's rebuild
113
+ // button ran a template build instead of re-filing the archive. The alias is
114
+ // unique to knowledge.rebuild now, and the bare /wiki-rebuild invocation must
115
+ // resolve deterministically (no LLM) to ingest_rebuild.
116
+ const knowledgeRebuild = makeCapability('knowledge.rebuild', {
117
+ operations: ['ingest_rebuild'],
118
+ aliases: ['file the archived sources', 'archived sources into the wiki', 'concept pages from the archive', 'rebuild', 'rebuild concepts'],
119
+ description: 'Re-file the archived sources into their concept folders.',
120
+ });
121
+ const documentBuild = makeCapability('document.build', {
122
+ operations: ['build'],
123
+ aliases: ['build', 'generate deliverable'],
124
+ description: 'Build llm-wiki deliverables from templates.',
125
+ });
126
+ const session = sessionWith([
127
+ provider('production-1', knowledgeRebuild),
128
+ provider('production-1', documentBuild),
129
+ ]);
130
+ session.llm.completeWithTools = async () => {
131
+ throw new Error('the rebuild alias must resolve without the LLM');
132
+ };
133
+ const result = await resolveObjective('/wiki-rebuild', session);
134
+ assert.equal(result.capability, 'knowledge.rebuild');
135
+ assert.equal(result.operation, 'ingest_rebuild');
136
+ });
137
+
111
138
  test('resolveObjective resolves diagnose via alias despite the notification "send"', async () => {
112
139
  const session = sessionWith([
113
140
  provider('production-1', diagnose),