@dotdrelle/wiki-manager 0.15.85 → 0.15.91

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.
@@ -2,12 +2,24 @@ import { normalizeActivity, parseJsonText } from '../core/activity.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
3
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
4
4
  import { loadWorkspaceProfile } from '../core/profile.js';
5
+ import { containerReachableUrl } from '../core/wikiSetup.js';
5
6
  import { mapRuntimeEvent } from '../core/runtimeEventAdapter.js';
6
7
  import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
7
8
  import { APPROVAL_DEFAULT_CLASS, approvalCovered } from './approvalPolicy.js';
8
9
  import { isSuccessful, isTerminal } from './taskStatuses.js';
9
10
 
10
11
 
12
+ // Distinguishes "the manager process is shutting down" from a genuine task
13
+ // cancellation (/run cancel, /stop, /control cancel) on the SAME shared
14
+ // AbortController + signal (server.js's context.currentAbortController) —
15
+ // the dispatcher otherwise cannot tell the two apart, and calling agent_cancel
16
+ // on a shutdown-abort was cancelling a still-healthy job the very recovery
17
+ // mechanism (recoveryManager.js's idempotency requeue) exists to reattach to
18
+ // on the next boot. A shutdown must still abort the poll loop — the `finally`
19
+ // block's lock release has to run before the process exits — it must just not
20
+ // tell the agent to give up on real work.
21
+ export const RUNTIME_SHUTDOWN_ABORT_REASON = 'runtime_shutdown';
22
+
11
23
  export function createDispatcher({
12
24
  session = null,
13
25
  callTool = callMcpTool,
@@ -66,7 +78,7 @@ export async function execute(task, assignment, {
66
78
  session.mcp,
67
79
  serverName,
68
80
  executeTool,
69
- executeRequest(task, session, runId),
81
+ executeRequest(task, session, runId, assignment),
70
82
  signal,
71
83
  ));
72
84
  if (accepted?.accepted === false || accepted?.ok === false) {
@@ -155,7 +167,7 @@ export async function execute(task, assignment, {
155
167
  }
156
168
  }
157
169
  } catch (error) {
158
- if (isAbortError(error) && jobId) {
170
+ if (isAbortError(error) && jobId && error.reason !== RUNTIME_SHUTDOWN_ABORT_REASON) {
159
171
  await callTool(session.mcp, serverName, cancelTool, { jobId }, null).catch(() => null);
160
172
  }
161
173
  throw error;
@@ -407,7 +419,20 @@ function dispatchExternalRuntimeActivity(session, task, assignment, runtimeRunId
407
419
  }));
408
420
  }
409
421
 
410
- function executeRequest(task, session, runId) {
422
+ function executeRequest(task, session, runId, assignment) {
423
+ // The assignment's `capability` field is the capability ID (a string) for
424
+ // MCP agents; the OBJECT with the inputSchema lives on the registry agent's
425
+ // description. Resolve it properly — reading inputSchema off the string is
426
+ // what silently disabled the configPath injection below.
427
+ const capabilityId = task?.requiredCapability;
428
+ const capabilityObject = (assignment?.capability && typeof assignment.capability === 'object'
429
+ ? assignment.capability
430
+ : null)
431
+ ?? assignment?.agent?.description?.capabilities?.find(
432
+ (capability) => String(capability?.id ?? '') === String(capabilityId ?? ''),
433
+ )
434
+ ?? null;
435
+ const schemaProperties = capabilityObject?.inputSchema?.properties ?? {};
411
436
  return {
412
437
  taskId: String(task.id ?? task.step),
413
438
  ...(runId ? { runId: String(runId) } : {}),
@@ -428,6 +453,17 @@ function executeRequest(task, session, runId) {
428
453
  // first E2E ingest plan dispatched by the deep agent's
429
454
  // planExpansionRequest failed exactly that way, 19 tasks in one batch.
430
455
  ...(task.requiresApproval === true ? { confirm: true } : {}),
456
+ // The ACTIVE profile must reach the job: a task planned without an
457
+ // explicit configPath (the common case) otherwise runs on the workspace
458
+ // default .wikirc, so /config use <profile> changes the runtime's own
459
+ // LLM but silently not the production job's. The dispatcher is the one
460
+ // place every executor sees — inject the session's current profile when
461
+ // the capability declares the field and the task did not set one.
462
+ ...(task.arguments?.configPath === undefined
463
+ && 'configPath' in schemaProperties
464
+ && session?.wikirc?.fileName
465
+ ? { configPath: session.wikirc.fileName }
466
+ : {}),
431
467
  },
432
468
  constraints: {
433
469
  requireApprovalForMutations: task.requiresApproval === true,
@@ -448,7 +484,7 @@ function workspaceRequest(session) {
448
484
  function activeProfileModel(session) {
449
485
  const llm = session?.wikircConfig?.llm ?? {};
450
486
  const model = {
451
- ...(llm.baseUrl ? { baseUrl: String(llm.baseUrl) } : {}),
487
+ ...(llm.baseUrl ? { baseUrl: containerReachableUrl(String(llm.baseUrl)).url } : {}),
452
488
  ...(llm.model ? { model: String(llm.model) } : {}),
453
489
  ...(llm.apiKey ? { apiKey: String(llm.apiKey) } : {}),
454
490
  };
@@ -521,7 +557,7 @@ export function activeProfileMcp(session) {
521
557
  };
522
558
  blocks.push({
523
559
  name: 'wiki',
524
- url: String(wiki.url),
560
+ url: containerReachableUrl(String(wiki.url)).url,
525
561
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
526
562
  tools,
527
563
  });
@@ -542,7 +578,7 @@ export function activeProfileMcp(session) {
542
578
  if (tools.length === 0) continue;
543
579
  blocks.push({
544
580
  name,
545
- url: String(entry.url),
581
+ url: containerReachableUrl(String(entry.url)).url,
546
582
  ...(entry.headers && typeof entry.headers === 'object' ? { headers: entry.headers } : {}),
547
583
  tools,
548
584
  });
@@ -739,24 +775,25 @@ function taskLogPayload(event, task, assignment, {
739
775
  function delay(ms, signal) {
740
776
  return new Promise((resolve, reject) => {
741
777
  if (signal?.aborted) {
742
- reject(abortError());
778
+ reject(abortError(signal));
743
779
  return;
744
780
  }
745
781
  const timer = setTimeout(resolve, Math.max(0, Number(ms) || 0));
746
782
  signal?.addEventListener('abort', () => {
747
783
  clearTimeout(timer);
748
- reject(abortError());
784
+ reject(abortError(signal));
749
785
  }, { once: true });
750
786
  });
751
787
  }
752
788
 
753
789
  function throwIfAborted(signal) {
754
- if (signal?.aborted) throw abortError();
790
+ if (signal?.aborted) throw abortError(signal);
755
791
  }
756
792
 
757
- function abortError() {
793
+ function abortError(signal) {
758
794
  const error = new Error('Runtime run cancelled.');
759
795
  error.name = 'AbortError';
796
+ error.reason = signal?.reason;
760
797
  return error;
761
798
  }
762
799
 
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { activeProfileMcp, createDispatcher, normalizeTaskError } from './dispatcher.js';
3
+ import { activeProfileMcp, createDispatcher, normalizeTaskError, RUNTIME_SHUTDOWN_ABORT_REASON } from './dispatcher.js';
4
4
 
5
5
  test('activeProfileMcp forwards only the read-only wiki tools to the external runtime', () => {
6
6
  const session = {
@@ -91,6 +91,29 @@ test('activeProfileMcp tolerates namespaced tool names', () => {
91
91
  assert.deepEqual(activeProfileMcp(session)[0].tools, ['wiki__wiki_read_page']);
92
92
  });
93
93
 
94
+ test('activeProfileMcp rewrites loopback URLs for the container the runtime runs in', () => {
95
+ const session = {
96
+ mcp: {
97
+ wiki: {
98
+ url: 'http://127.0.0.1:3201/mcp', status: 'connected',
99
+ tools: [{ name: 'wiki_read_page' }],
100
+ },
101
+ exa: {
102
+ url: 'http://localhost:9999/mcp/', status: 'connected', external: true,
103
+ tools: [{ name: 'web_search_exa' }],
104
+ },
105
+ hosted: {
106
+ url: 'https://mcp.exa.ai/mcp', status: 'connected', external: true,
107
+ tools: [{ name: 'web_search_exa' }],
108
+ },
109
+ },
110
+ };
111
+ const pool = activeProfileMcp(session);
112
+ assert.equal(pool.find((block) => block.name === 'wiki').url, 'http://host.docker.internal:3201/mcp');
113
+ assert.equal(pool.find((block) => block.name === 'exa').url, 'http://host.docker.internal:9999/mcp');
114
+ assert.equal(pool.find((block) => block.name === 'hosted').url, 'https://mcp.exa.ai/mcp');
115
+ });
116
+
94
117
  test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
95
118
  const session = {
96
119
  workspace: 'test',
@@ -203,6 +226,72 @@ test('dispatcher completes when an executor-only agent reports succeeded', async
203
226
  assert.equal(result.jobId, 'job-connectors');
204
227
  });
205
228
 
229
+ test('dispatcher cancels the agent job when genuinely aborted mid-poll', async () => {
230
+ const session = {
231
+ workspace: 'test',
232
+ mcp: { production: { status: 'connected', tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }] } },
233
+ activities: {},
234
+ };
235
+ const calledTools = [];
236
+ const dispatcher = createDispatcher({
237
+ session,
238
+ pollIntervalMs: 5,
239
+ callTool: async (_mcp, _server, tool) => {
240
+ calledTools.push(tool);
241
+ if (tool === 'agent_execute') return { accepted: true, jobId: 'job-1', status: 'queued' };
242
+ if (tool === 'agent_cancel') return { ok: true };
243
+ // agent_status: never terminal, forces the poll loop to keep waiting
244
+ // until the abort fires.
245
+ return { jobId: 'job-1', status: 'running' };
246
+ },
247
+ });
248
+ const controller = new AbortController();
249
+ setTimeout(() => controller.abort(), 10);
250
+
251
+ await assert.rejects(
252
+ dispatcher.execute(
253
+ { id: 'analyze-doc', label: 'Analyze doc', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: {} },
254
+ { serverName: 'production', agentInstanceId: 'production-main' },
255
+ { attempt: { attemptId: 'analyze-doc:attempt-1', locks: [], release() {} }, signal: controller.signal },
256
+ ),
257
+ );
258
+ assert.ok(calledTools.includes('agent_cancel'), 'a genuine cancel/abort must still cancel the agent job');
259
+ });
260
+
261
+ test('dispatcher does NOT cancel the agent job when the manager is only shutting down', async () => {
262
+ // The exact scenario that caused a real incident: restarting the runtime
263
+ // mid-ingest orphaned in-flight sources because this path cancelled their
264
+ // still-healthy jobs instead of leaving them for recoveryManager.js's
265
+ // idempotency requeue to reattach to on the next boot.
266
+ const session = {
267
+ workspace: 'test',
268
+ mcp: { production: { status: 'connected', tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }] } },
269
+ activities: {},
270
+ };
271
+ const calledTools = [];
272
+ const dispatcher = createDispatcher({
273
+ session,
274
+ pollIntervalMs: 5,
275
+ callTool: async (_mcp, _server, tool) => {
276
+ calledTools.push(tool);
277
+ if (tool === 'agent_execute') return { accepted: true, jobId: 'job-2', status: 'queued' };
278
+ if (tool === 'agent_cancel') return { ok: true };
279
+ return { jobId: 'job-2', status: 'running' };
280
+ },
281
+ });
282
+ const controller = new AbortController();
283
+ setTimeout(() => controller.abort(RUNTIME_SHUTDOWN_ABORT_REASON), 10);
284
+
285
+ await assert.rejects(
286
+ dispatcher.execute(
287
+ { id: 'analyze-doc', label: 'Analyze doc', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: {} },
288
+ { serverName: 'production', agentInstanceId: 'production-main' },
289
+ { attempt: { attemptId: 'analyze-doc:attempt-1', locks: [], release() {} }, signal: controller.signal },
290
+ ),
291
+ );
292
+ assert.ok(!calledTools.includes('agent_cancel'), 'a shutdown-reason abort must leave the still-healthy agent job running');
293
+ });
294
+
206
295
  test('dispatcher normalizes a bare string error reported on a terminal agent_status', async () => {
207
296
  const session = {
208
297
  workspace: 'test',
@@ -298,7 +298,7 @@ test('dispatcher sends the active profile model with the run', async () => {
298
298
  assert.equal(requests[0].language, 'fr', 'the workspace language travels with the run');
299
299
  assert.deepEqual(requests[0].mcp, [{
300
300
  name: 'wiki',
301
- url: 'http://localhost:3335/mcp/',
301
+ url: 'http://host.docker.internal:3335/mcp',
302
302
  headers: { Authorization: 'Bearer wiki-token' },
303
303
  tools: ['wiki_search_context', 'wiki_read_page'],
304
304
  }], 'the wiki MCP travels per run, read tools only — write tools never leave');
@@ -378,7 +378,7 @@ test('dispatcher sends the active profile model with the run', async () => {
378
378
  );
379
379
 
380
380
  assert.equal(received[0].model.model, 'openai/gpt-test');
381
- assert.equal(received[0].model.baseUrl, 'http://127.0.0.1:9/v1');
381
+ assert.equal(received[0].model.baseUrl, 'http://host.docker.internal:9/v1');
382
382
  assert.equal(received[0].model.apiKey, 'k');
383
383
  assert.equal(received[0].model.temperature, 0.2);
384
384
  });
@@ -157,8 +157,10 @@ test('resolveRuntimeProviders maps enabled entries and skips unknown types', ()
157
157
  test('loadAgentRuntimesConfig reads both array and object forms', () => {
158
158
  const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
159
159
  try {
160
+ // env: {} — the machine's manager .env (GATEWAY_ENABLED) must not leak
161
+ // into this parse-shape test through the implied-gateway path.
160
162
  writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({ runtimes: [{ id: 'a', type: 'fake' }] }));
161
- assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir }), [{ id: 'a', type: 'fake' }]);
163
+ assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir, env: {} }), [{ id: 'a', type: 'fake' }]);
162
164
  } finally {
163
165
  rmSync(dir, { recursive: true, force: true });
164
166
  }
@@ -1,3 +1,5 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { validateContract } from '../contracts/schemas.js';
2
4
  import { parseJsonText } from '../core/activity.js';
3
5
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
@@ -63,6 +65,26 @@ export async function accept(result, {
63
65
  taskId,
64
66
  payload,
65
67
  })));
68
+ // A worktree proposal is a review item, not a log line: persist it into the
69
+ // workspace review queue (.wiki/agent-proposals/) where the served review
70
+ // surface reads it, and announce it — a proposal nobody is told about is a
71
+ // proposal nobody merges, and a merge is the approval.
72
+ const worktreePersisted = persistWorktreeProposal(session, result, { runId, taskId, ok, status });
73
+ if (worktreePersisted.error) {
74
+ persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('runtime_log', {
75
+ origin: 'result_aggregator',
76
+ runId,
77
+ taskId,
78
+ payload: { message: `agent-proposal: could not persist the worktree proposal for ${taskId}: ${worktreePersisted.error}` },
79
+ })));
80
+ } else if (worktreePersisted.path) {
81
+ persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('runtime_log', {
82
+ origin: 'result_aggregator',
83
+ runId,
84
+ taskId,
85
+ payload: { message: `agent-proposal: ${taskId} is waiting for review — ${worktreePersisted.path}` },
86
+ })));
87
+ }
66
88
  persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
67
89
  origin: 'result_aggregator',
68
90
  runId,
@@ -227,6 +249,51 @@ function persistDispatch(store, event) {
227
249
  store?.persistEvent?.(event);
228
250
  }
229
251
 
252
+ /**
253
+ * Worktree proposals (agent.curate): the external runtime's run result carries
254
+ * `worktreeProposal` — the confined branch's changed files, their new content
255
+ * and the unified diff. The manager writes it into the workspace review queue
256
+ * (`.wiki/agent-proposals/<id>.json`, gitignored state) where the served
257
+ * review surface reads it; the MERGE happens there, through the engine's own
258
+ * write machinery — this function only records, it never touches wiki content.
259
+ */
260
+ function persistWorktreeProposal(session, result, { runId, taskId }) {
261
+ const proposal = result?.result?.worktreeProposal ?? result?.worktreeProposal;
262
+ if (!proposal || typeof proposal !== 'object') return { path: null };
263
+ const changes = Array.isArray(proposal.changes) ? proposal.changes : [];
264
+ if (changes.length === 0) return { path: null };
265
+ const workspacePath = session?.workspacePath;
266
+ if (!workspacePath || typeof workspacePath !== 'string') {
267
+ return { error: 'no workspace path on the session — the proposal stays in the run result only' };
268
+ }
269
+ const record = {
270
+ id: String(taskId),
271
+ runId: String(runId ?? ''),
272
+ workspace: String(proposal.workspace ?? session.workspace ?? ''),
273
+ branch: String(proposal.branch ?? ''),
274
+ worktreePath: String(proposal.worktreePath ?? ''),
275
+ worktreeRelativePath: String(proposal.worktreeRelativePath ?? ''),
276
+ createdAt: new Date().toISOString(),
277
+ justification: String(proposal.justification ?? ''),
278
+ ...(Array.isArray(proposal.objections) && proposal.objections.length > 0
279
+ ? { objections: proposal.objections }
280
+ : {}),
281
+ changedFiles: Array.isArray(proposal.changedFiles) ? proposal.changedFiles : [],
282
+ changes,
283
+ diff: String(proposal.diff ?? ''),
284
+ };
285
+ try {
286
+ const safeId = String(taskId).replace(/[^a-zA-Z0-9._-]/g, '_');
287
+ const dir = join(workspacePath, '.wiki', 'agent-proposals');
288
+ mkdirSync(dir, { recursive: true });
289
+ const path = join(dir, `${safeId}.json`);
290
+ writeFileSync(path, JSON.stringify(record, null, 2));
291
+ return { path };
292
+ } catch (error) {
293
+ return { error: error instanceof Error ? error.message : String(error) };
294
+ }
295
+ }
296
+
230
297
  function agentPlanRequest(request, session) {
231
298
  return {
232
299
  capability: request.capability,
@@ -8,6 +8,7 @@ import { runtimeTokenFromEnv } from './auth.js';
8
8
  import { controlMessage } from './controlMessages.js';
9
9
  import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
10
10
  import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
11
+ import { RUNTIME_SHUTDOWN_ABORT_REASON } from '../orchestrator/dispatcher.js';
11
12
  import { matchSkillInvocation } from '../core/skillInvocation.js';
12
13
  import { reconcileControlQueue } from './controlDrain.js';
13
14
  import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
@@ -413,7 +414,7 @@ export function startRuntimeServer({
413
414
  // Read-only chat turns intentionally remain available while an agent
414
415
  // run is active. Other interactive turns still become control
415
416
  // messages so they cannot start a competing agent decision.
416
- const readOnlyChat = String(body.mode ?? '').toLowerCase() === 'chat';
417
+ let readOnlyChat = String(body.mode ?? '').toLowerCase() === 'chat';
417
418
  // An explicit /skill invocation has deterministic meaning. Keep the
418
419
  // conversational /turn boundary, but do not ask the LLM to rediscover
419
420
  // the skill from prose: it could choose a direct mutation instead and
@@ -433,14 +434,25 @@ export function startRuntimeServer({
433
434
  return;
434
435
  }
435
436
  if (context.running && !readOnlyChat) {
436
- const result = await handleControlMessage(context, store, input, {
437
- intent: body.intent,
438
- startNextControlRequest,
439
- cancel,
440
- approve,
437
+ // Agent-mode message while a run is active. Classify once: control
438
+ // verbs and new tasks go to the control lane, plain conversation is
439
+ // ANSWERED read-only (like a chat turn) instead of parking the
440
+ // reader in a choice menu — the chat must stay usable during runs.
441
+ const classification = await classifyControlMessage(input, controlStatus(context, store), {
442
+ llm: context?.session?.llm,
443
+ session: context?.session,
441
444
  });
442
- sendJson(response, result.statusCode, result.body);
443
- return;
445
+ if (classification.kind !== 'converse') {
446
+ const result = await handleControlMessage(context, store, input, {
447
+ intent: body.intent,
448
+ startNextControlRequest,
449
+ cancel,
450
+ approve,
451
+ });
452
+ sendJson(response, result.statusCode, result.body);
453
+ return;
454
+ }
455
+ readOnlyChat = true;
444
456
  }
445
457
  if (typeof turn !== 'function') {
446
458
  sendJson(response, 501, { error: 'Runtime interactive turns are unavailable.' });
@@ -454,21 +466,27 @@ export function startRuntimeServer({
454
466
  // after this request was accepted. Reclassify against the fresh
455
467
  // state instead of starting another interactive decision in parallel.
456
468
  if (context.running && !readOnlyChat) {
457
- const result = await handleControlMessage(context, store, input, {
458
- intent: body.intent,
459
- startNextControlRequest,
460
- cancel,
461
- approve,
469
+ const classification = await classifyControlMessage(input, controlStatus(context, store), {
470
+ llm: context?.session?.llm,
471
+ session: context?.session,
462
472
  });
463
- publish(createAgentEvent('assistant_message', {
464
- origin: 'runtime_turn',
465
- turnId,
466
- workspace: context.workspace ?? null,
467
- payload: { content: result.body?.explanation ?? 'Runtime control request processed.' },
468
- }));
469
- return result.body;
473
+ if (classification.kind !== 'converse') {
474
+ const result = await handleControlMessage(context, store, input, {
475
+ intent: body.intent,
476
+ startNextControlRequest,
477
+ cancel,
478
+ approve,
479
+ });
480
+ publish(createAgentEvent('assistant_message', {
481
+ origin: 'runtime_turn',
482
+ turnId,
483
+ workspace: context.workspace ?? null,
484
+ payload: { content: result.body?.explanation ?? 'Runtime control request processed.' },
485
+ }));
486
+ return result.body;
487
+ }
470
488
  }
471
- return turn(context, { ...body, input }, {
489
+ return turn(context, { ...body, input, mode: readOnlyChat ? 'chat' : body.mode }, {
472
490
  signal: controller.signal,
473
491
  turnId,
474
492
  });
@@ -560,7 +578,13 @@ export function startRuntimeServer({
560
578
  const workspace = workspaceFromUrl(url);
561
579
  const context = await resolveContext({ workspace });
562
580
  if (context?.running && context.currentAbortController) {
563
- context.currentAbortController.abort();
581
+ // Reason-tagged: the poll loop still has to unwind so its `finally`
582
+ // releases locks before the process exits, but this is the manager
583
+ // going away, not a user cancellation — the dispatcher must not
584
+ // read it as "give up on the agent job too". recoveryManager.js's
585
+ // idempotency requeue exists precisely to reattach to that job on
586
+ // the next boot; cancelling it here defeats that on every restart.
587
+ context.currentAbortController.abort(RUNTIME_SHUTDOWN_ABORT_REASON);
564
588
  await cancel?.(context);
565
589
  }
566
590
  sendJson(response, 202, { shutdown: true });
@@ -1484,16 +1508,20 @@ async function classifyControlMessage(input, status, { forcedIntent = null, llm
1484
1508
  const kind = String(reply ?? '').trim().toLowerCase();
1485
1509
  if (kind.startsWith('action')) return { kind: 'enqueue_run', confidence: 0.85, reason: 'llm_classified_action' };
1486
1510
  if (kind.startsWith('conversation')) return { kind: 'converse', confidence: 0.85, reason: 'llm_classified_conversation' };
1487
- emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply, falling back to the choice menu — ${JSON.stringify(kind).slice(0, 200)}`);
1511
+ emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply, answering as read-only conversation — ${JSON.stringify(kind).slice(0, 200)}`);
1488
1512
  } catch (err) {
1489
1513
  // A degradation must announce itself: silently falling through here
1490
1514
  // hides the difference between "no LLM configured" (expected) and "the
1491
1515
  // configured LLM is failing every call" (a real problem) — both would
1492
1516
  // otherwise look identical from the Shell or serve UI.
1493
- emitRuntimeLog(session, `control-classify: LLM call failed, falling back to the choice menu — ${err instanceof Error ? err.message : String(err)}`);
1517
+ emitRuntimeLog(session, `control-classify: LLM call failed, answering as read-only conversation — ${err instanceof Error ? err.message : String(err)}`);
1494
1518
  }
1495
1519
  }
1496
- return { kind: 'ambiguous', confidence: 0.45, reason: 'action_vs_conversation_unclear' };
1520
+ // A choice menu IS the block the reader complains about: while a run is
1521
+ // active, every unanswered message turned into a menu. Falling back to
1522
+ // read-only conversation keeps the chat usable — a wrong converse only
1523
+ // answers as chat, it can never mutate or queue anything.
1524
+ return { kind: 'converse', confidence: 0.3, reason: 'fallback_to_readonly_conversation' };
1497
1525
  }
1498
1526
 
1499
1527
  function isAuthorized(request, token) {
package/src/shell/repl.js CHANGED
@@ -437,31 +437,71 @@ export function sanitizeOpenWikiPages(values) {
437
437
  }
438
438
 
439
439
 
440
+ // Tolerant [src: path] matcher, mirroring llm-wiki's own
441
+ // src/utils/markdown.ts#extractSourceCitations (chained "[src: a.md ; src:
442
+ // b.md]" included) without importing across the repo boundary for one regex.
443
+ const SOURCE_CITATION_PATTERN = /\[\s*src\s*:\s*([^\]]+?)\s*\]/gi;
444
+
445
+ function extractSourceCitationPaths(content) {
446
+ return [...content.matchAll(SOURCE_CITATION_PATTERN)].flatMap((match) =>
447
+ (match[1] ?? '')
448
+ .split(';')
449
+ .map((part) => part.trim().replace(/^src\s*:\s*/i, ''))
450
+ .filter(Boolean),
451
+ );
452
+ }
453
+
454
+ async function readWorkspaceFile(session, relPath, maxCharsPerDoc) {
455
+ try {
456
+ const absPath = path.resolve(session.workspacePath, relPath);
457
+ const rel = path.relative(session.workspacePath, absPath);
458
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return { path: relPath, content: null };
459
+ const raw = await readFile(absPath, 'utf8');
460
+ const content = raw.length > maxCharsPerDoc
461
+ ? `${raw.slice(0, maxCharsPerDoc).trimEnd()}\n[truncated]`
462
+ : raw;
463
+ return { path: relPath, content };
464
+ } catch {
465
+ return { path: relPath, content: null };
466
+ }
467
+ }
468
+
440
469
  // Read the selected documents' content so chat can summarize them directly,
441
470
  // without depending on the model choosing to call a read tool (and without the
442
471
  // tool being offered at all). Paths are already sanitized to wiki/ or
443
472
  // raw/untracked/ .md files; the path.relative check is defence in depth. A doc
444
473
  // that cannot be read yields { content: null } so the caller can note it.
445
- export async function readSelectedPageDocuments(session, pages, { maxCharsPerDoc = 16000 } = {}) {
474
+ //
475
+ // A wiki page is a digest, not the evidence: it names its real sources inline
476
+ // as [src: ...] citations. Reading only the digest is exactly the shortcut
477
+ // that produced an answer covering one facet of a source ingest had split
478
+ // into several concept leaves — the model reused the one digest already in
479
+ // hand instead of going back to the source, even though it was told to.
480
+ // Rather than hope a differently-prompted model chooses to call a read tool
481
+ // for that source, follow each selected page's own citations one level deep
482
+ // and attach them the same deterministic way, up to maxCitedDocs.
483
+ export async function readSelectedPageDocuments(session, pages, { maxCharsPerDoc = 16000, maxCitedDocs = 5 } = {}) {
446
484
  if (!Array.isArray(pages) || pages.length === 0 || !session?.workspacePath) return [];
447
485
  const docs = [];
486
+ const seen = new Set();
448
487
  for (const relPath of pages) {
449
- try {
450
- const absPath = path.resolve(session.workspacePath, relPath);
451
- const rel = path.relative(session.workspacePath, absPath);
452
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
453
- docs.push({ path: relPath, content: null });
454
- continue;
455
- }
456
- const raw = await readFile(absPath, 'utf8');
457
- const content = raw.length > maxCharsPerDoc
458
- ? `${raw.slice(0, maxCharsPerDoc).trimEnd()}\n[truncated]`
459
- : raw;
460
- docs.push({ path: relPath, content });
461
- } catch {
462
- docs.push({ path: relPath, content: null });
488
+ docs.push(await readWorkspaceFile(session, relPath, maxCharsPerDoc));
489
+ seen.add(relPath);
490
+ }
491
+
492
+ const citedBy = new Map();
493
+ for (const doc of docs) {
494
+ if (typeof doc.content !== 'string') continue;
495
+ for (const citation of extractSourceCitationPaths(doc.content)) {
496
+ if (seen.has(citation) || citedBy.has(citation)) continue;
497
+ citedBy.set(citation, doc.path);
463
498
  }
464
499
  }
500
+ for (const [citation, citingPath] of [...citedBy.entries()].slice(0, maxCitedDocs)) {
501
+ const doc = await readWorkspaceFile(session, citation, maxCharsPerDoc);
502
+ docs.push({ ...doc, citedBy: citingPath });
503
+ seen.add(citation);
504
+ }
465
505
  return docs;
466
506
  }
467
507
 
@@ -473,7 +513,12 @@ export function buildAttachedDocMessages(docs) {
473
513
  const readable = (docs ?? []).filter((doc) => typeof doc?.content === 'string' && doc.content.trim());
474
514
  if (readable.length === 0) return [];
475
515
  const body = readable
476
- .map((doc) => `--- BEGIN ATTACHED DOCUMENT ${doc.path} ---\n${doc.content}\n--- END ATTACHED DOCUMENT ${doc.path} ---`)
516
+ .map((doc) => {
517
+ const label = doc.citedBy
518
+ ? `${doc.path} (cited source of ${doc.citedBy} — the real evidence, not a digest)`
519
+ : doc.path;
520
+ return `--- BEGIN ATTACHED DOCUMENT ${label} ---\n${doc.content}\n--- END ATTACHED DOCUMENT ${label} ---`;
521
+ })
477
522
  .join('\n\n');
478
523
  return [{
479
524
  role: 'user',