@dotdrelle/wiki-manager 0.12.12 → 0.14.0

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.
@@ -15,6 +15,7 @@ export function startRuntimeServer({
15
15
  session = null,
16
16
  getContext,
17
17
  run,
18
+ delegate,
18
19
  cancel,
19
20
  resume,
20
21
  approve,
@@ -271,6 +272,36 @@ export function startRuntimeServer({
271
272
  }
272
273
  return;
273
274
  }
275
+ if (request.method === 'POST' && url.pathname === '/delegate') {
276
+ const { body, context } = await resolveBodyContext(request, url);
277
+ const objective = String(body.objective ?? '').trim();
278
+ if (!objective) {
279
+ sendJson(response, 400, { error: 'Missing objective.' });
280
+ return;
281
+ }
282
+ if (context.running) {
283
+ sendJson(response, 409, { error: 'A runtime run is already active.' });
284
+ return;
285
+ }
286
+ if (typeof delegate !== 'function') {
287
+ sendJson(response, 501, { error: 'Runtime delegation is unavailable.' });
288
+ return;
289
+ }
290
+ try {
291
+ const prepared = await delegate(context, { objective, workspace: body.workspace ?? context.workspace ?? null });
292
+ const started = startRuntimeRun(context, {
293
+ input: objective,
294
+ workspace: body.workspace ?? context.workspace ?? null,
295
+ preparedDelegation: prepared,
296
+ evaluate: false,
297
+ }, { waitForPlan: true });
298
+ await started.ready;
299
+ sendJson(response, 202, { accepted: true, runId: started.runId, workspace: started.workspace, delegation: prepared.summary ?? null });
300
+ } catch (err) {
301
+ sendJson(response, 422, { error: err instanceof Error ? err.message : String(err) });
302
+ }
303
+ return;
304
+ }
274
305
  if (request.method === 'POST' && url.pathname === '/cancel') {
275
306
  const workspace = workspaceFromUrl(url);
276
307
  const context = await resolveContext({ workspace });
@@ -391,14 +422,22 @@ export function startRuntimeServer({
391
422
  return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued };
392
423
  }
393
424
 
394
- function startRuntimeRun(context, body, { controlItemId = null } = {}) {
425
+ function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false } = {}) {
395
426
  const runId = randomUUID();
396
427
  const runWorkspace = context.workspace ?? body.workspace ?? null;
397
428
  context.running = true;
398
429
  context.currentAbortController = new AbortController();
399
430
  context.currentRunId = runId;
400
431
  context.currentRunWorkspace = runWorkspace;
401
- const runBody = { ...body, workspace: runWorkspace, runId };
432
+ let resolveReady;
433
+ let rejectReady;
434
+ const ready = waitForPlan ? new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }) : null;
435
+ const runBody = {
436
+ ...body,
437
+ workspace: runWorkspace,
438
+ runId,
439
+ ...(waitForPlan ? { _planReady: { resolve: resolveReady, reject: rejectReady } } : {}),
440
+ };
402
441
  if (controlItemId) {
403
442
  dispatchAgentEvent(context.session, createAgentEvent('control_started', {
404
443
  origin: 'runtime',
@@ -410,6 +449,7 @@ export function startRuntimeServer({
410
449
  const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
411
450
  runPromise
412
451
  .catch((err) => {
452
+ rejectReady?.(err);
413
453
  context.session?._onRuntimeError?.(err);
414
454
  })
415
455
  .finally(() => {
@@ -420,7 +460,7 @@ export function startRuntimeServer({
420
460
  publishState(runWorkspace, context);
421
461
  void startNextControlRequest(context);
422
462
  });
423
- return { accepted: true, runId, workspace: runWorkspace };
463
+ return { accepted: true, runId, workspace: runWorkspace, ...(ready ? { ready } : {}) };
424
464
  }
425
465
 
426
466
  function startNextControlRequest(context) {
@@ -119,7 +119,10 @@ export async function pollActivitiesOnce(session, {
119
119
  const retry = progress.retryAt
120
120
  ? `retry ${progress.retryAt}`
121
121
  : (progress.waitMs ? `wait ${progress.waitMs}ms` : null);
122
- const progressKey = `${polledActivity.status}:${Number.isFinite(percent) ? Math.round(percent) : ''}:${detail}:${batch ?? ''}:${lastEvent ?? ''}:${retry ?? ''}`;
122
+ // retryAt/waitMs are scheduling metadata and may be recomputed on every
123
+ // status poll. They must remain visible in the first log line, but must
124
+ // not turn an unchanged quota/backoff state into a new trace event.
125
+ const progressKey = `${polledActivity.status}:${Number.isFinite(percent) ? Math.round(percent) : ''}:${detail}:${batch ?? ''}:${lastEvent ?? ''}`;
123
126
  session._activityLogKeys ??= {};
124
127
  if (session._activityLogKeys[key] !== progressKey) {
125
128
  session._activityLogKeys[key] = progressKey;
@@ -41,6 +41,55 @@ test('pollActivitiesOnce updates activity through the event reducer', async () =
41
41
  assert.ok(session.agentProjection.logs.some((line) => line.includes('activity:')));
42
42
  });
43
43
 
44
+ test('pollActivitiesOnce does not repeat an unchanged retry state when retryAt moves', async () => {
45
+ const session = {
46
+ mcp: { production: { status: 'connected' } },
47
+ activities: {},
48
+ headlessPlan: null,
49
+ jobQueue: [],
50
+ };
51
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
52
+ payload: {
53
+ activity: {
54
+ id: 'job-quota',
55
+ source: 'production',
56
+ label: 'Production · ingest',
57
+ status: 'running',
58
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-quota' }, intervalMs: 0 },
59
+ },
60
+ },
61
+ }));
62
+
63
+ let poll = 0;
64
+ const callTool = async () => {
65
+ poll += 1;
66
+ return {
67
+ content: [{ type: 'text', text: JSON.stringify({
68
+ _activity: {
69
+ id: 'job-quota',
70
+ source: 'production',
71
+ label: 'Production · ingest',
72
+ status: 'running',
73
+ terminal: false,
74
+ progress: {
75
+ percent: 15,
76
+ detail: 'LLM quota wait',
77
+ lastEvent: 'llm:rate-limit-wait',
78
+ retryAt: `2026-07-10T20:31:5${poll}.000Z`,
79
+ },
80
+ },
81
+ }) }],
82
+ };
83
+ };
84
+
85
+ await pollActivitiesOnce(session, { callTool });
86
+ await pollActivitiesOnce(session, { callTool });
87
+
88
+ const lines = session.agentProjection.logs.filter((line) => line.includes('activity: Production · ingest'));
89
+ assert.equal(lines.length, 1);
90
+ assert.match(lines[0], /retry 2026-07-10T20:31:51\.000Z/);
91
+ });
92
+
44
93
  test('pollActivitiesOnce retries transient MCP poll failures', async () => {
45
94
  const originalFetch = globalThis.fetch;
46
95
  let attempts = 0;
package/src/shell/repl.js CHANGED
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
5
5
  import { stdin as input, stdout as output } from 'node:process';
6
6
  import { marked } from 'marked';
7
7
  import { markedTerminal } from 'marked-terminal';
8
- import { buildAgentSystemPrompt, classifyAgentInput, formatLlmUnavailableMessage } from '../agent/graph.js';
8
+ import { buildAgentSystemPrompt, formatLlmUnavailableMessage } from '../agent/graph.js';
9
9
  import { handleSlashCommand } from '../commands/slash.js';
10
10
  import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
11
11
  import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
@@ -292,6 +292,7 @@ function buildDirectChatSystemPrompt(session) {
292
292
  return [
293
293
  'You are Donna, the llm-wiki-manager chat assistant.',
294
294
  'Answer directly and concisely. Do not claim to have called tools or changed files.',
295
+ 'Never add a "Next steps", "Prochaines étapes", "À suivre", options, or suggestions section unless the user explicitly asks what to do next. End after answering the question.',
295
296
  'If the user asks for an action that needs workspace commands, MCP tools, services, files, or mutations, say to ask as an agent action instead of pretending to execute it.',
296
297
  `Reply language: ${language}.`,
297
298
  `Current workspace: ${workspace}.`,
@@ -774,18 +775,21 @@ function rememberProductionActivity(session, payload) {
774
775
 
775
776
  export function applyRuntimeStateToShellSession(session, state) {
776
777
  if (!state || typeof state !== 'object') return false;
778
+ const displayState = sanitizeRuntimeStateForDisplay(state);
777
779
  session.agentProjection = {
778
- conversation: Array.isArray(state.conversation) ? state.conversation.map((message) => ({ ...message })) : [],
779
- chain: Array.isArray(state.chain) ? state.chain.map((step) => ({ ...step })) : [],
780
- plan: Array.isArray(state.plan) ? state.plan.map((step) => ({ ...step })) : null,
781
- activities: Array.isArray(state.activities) ? state.activities.map((activity) => ({ ...activity })) : [],
782
- logs: Array.isArray(state.logs) ? [...state.logs] : [],
783
- summary: state.summary ?? null,
784
- status: state.status ?? 'idle',
785
- planRevision: state.planRevision ?? 0,
786
- planPatches: Array.isArray(state.planPatches) ? state.planPatches.map((patch) => ({ ...patch })) : [],
780
+ conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
781
+ chain: Array.isArray(displayState.chain) ? displayState.chain.map((step) => ({ ...step })) : [],
782
+ plan: Array.isArray(displayState.plan) && displayState.plan.length > 0
783
+ ? displayState.plan.map((step) => ({ ...step }))
784
+ : null,
785
+ activities: Array.isArray(displayState.activities) ? displayState.activities.map((activity) => ({ ...activity })) : [],
786
+ logs: Array.isArray(displayState.logs) ? [...displayState.logs] : [],
787
+ summary: displayState.summary ?? null,
788
+ status: displayState.status ?? 'idle',
789
+ planRevision: displayState.planRevision ?? 0,
790
+ planPatches: Array.isArray(displayState.planPatches) ? displayState.planPatches.map((patch) => ({ ...patch })) : [],
787
791
  };
788
- session.workflow = state.workflow && typeof state.workflow === 'object'
792
+ session.workflow = displayState.workflow && typeof displayState.workflow === 'object'
789
793
  ? {
790
794
  ...state.workflow,
791
795
  nodes: Array.isArray(state.workflow.nodes) ? state.workflow.nodes.map((node) => ({ ...node })) : [],
@@ -803,7 +807,7 @@ export function applyRuntimeStateToShellSession(session, state) {
803
807
  // Runtime queue items replace the local jobQueue wholesale on every sync.
804
808
  // Tag their origin so /queue cancel can refuse to fake-cancel them locally
805
809
  // (a local status flip would be silently reverted by the next SSE sync).
806
- if (Array.isArray(state.queue)) session.jobQueue = state.queue.map((item) => ({ ...item, origin: 'runtime' }));
810
+ if (Array.isArray(displayState.queue)) session.jobQueue = displayState.queue.map((item) => ({ ...item, origin: 'runtime' }));
807
811
  const production = session.agentProjection.activities.filter((activity) => activity.source === 'production').at(-1);
808
812
  if (production) {
809
813
  session.productionActivity = {
@@ -817,21 +821,29 @@ export function applyRuntimeStateToShellSession(session, state) {
817
821
  return true;
818
822
  }
819
823
 
820
- // Submits a prompt to the shared runtime. If the workspace is already busy
821
- // (HTTP 409 from POST /run), route the input through the runtime control lane
822
- // so status questions and plan-change proposals do not become future runs.
823
- // A plain question or small talk must never start a runtime run (nor be
824
- // enqueued as a future one): it only needs an answer. Route converse/observe
825
- // to the local agent EVEN during an active run — the chat is supposed to stay
826
- // available, and the graph already restricts tools to read-only in that case.
827
- // Actions/cancels/approvals still go to the runtime.
828
- export function shouldHandleFreeTextLocally(line, session, { llmAvailable = Boolean(session?.llm) } = {}) {
829
- const classification = classifyAgentInput(line, session);
830
- // cancel intents included: Donna interprets "supprime le job et la queue"
831
- // and calls runtime__kill / runtime__cancel herself — no hardcoded regex
832
- // deciding between soft and hard stop. The control lane remains the
833
- // deterministic fallback when the local LLM is down.
834
- if (!['converse', 'observe', 'cancel', 'approve', 'enqueue_run', 'ambiguous'].includes(classification.kind)) return { local: false, classification };
824
+ export function sanitizeRuntimeStateForDisplay(state) {
825
+ if (!state || typeof state !== 'object') return state;
826
+ const status = String(state.status ?? 'idle').toLowerCase();
827
+ const visible = ['running', 'queued', 'pending', 'waiting', 'pending_approval', 'error', 'failed']
828
+ .includes(status);
829
+ if (visible) return state;
830
+ return {
831
+ ...state,
832
+ conversation: [],
833
+ chain: [],
834
+ plan: [],
835
+ activities: [],
836
+ workflow: null,
837
+ logs: [],
838
+ summary: null,
839
+ planPatches: [],
840
+ };
841
+ }
842
+
843
+ // In agent mode, Donna receives every free-text turn and decides whether to
844
+ // answer or call an exposed tool. Slash commands remain the deterministic UI.
845
+ export function shouldHandleFreeTextLocally(_line, session, { llmAvailable = Boolean(session?.llm) } = {}) {
846
+ const classification = { kind: 'agent_turn', confidence: 1, reason: 'agent_mode_llm_decision' };
835
847
  if (!llmAvailable) return { local: false, classification, fallbackReason: 'local LLM unavailable' };
836
848
  return { local: true, classification };
837
849
  }
@@ -1049,17 +1061,12 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayIn
1049
1061
  };
1050
1062
  session._onStreamReset = () => {
1051
1063
  if (!donnaMessage) return;
1052
- if (donnaMessage.content.trim()) {
1053
- // Intermediate streamed text before tool calls: keep it, add separator.
1054
- donnaMessage.content += '\n\n';
1055
- onUpdate?.();
1056
- } else {
1057
- // Still empty ("Thinking…"): remove it cleanly.
1058
- const index = messages.indexOf(donnaMessage);
1059
- if (index !== -1) messages.splice(index, 1);
1060
- donnaMessage = null;
1061
- onUpdate?.();
1062
- }
1064
+ // Text emitted before a tool call is provisional narration, not an answer.
1065
+ // Remove it; the post-tool result gets a fresh Donna bubble.
1066
+ const index = messages.indexOf(donnaMessage);
1067
+ if (index !== -1) messages.splice(index, 1);
1068
+ donnaMessage = null;
1069
+ onUpdate?.();
1063
1070
  };
1064
1071
 
1065
1072
  let agentResult;
@@ -9,12 +9,40 @@ import {
9
9
  conversationMessages,
10
10
  recordRuntimeUnavailableAgentInput,
11
11
  runLine,
12
+ sanitizeRuntimeStateForDisplay,
12
13
  runtimeStatusLine,
13
14
  runtimeUnavailableAgentMessage,
14
15
  shouldHandleFreeTextLocally,
15
16
  submitRuntimeRun,
16
17
  } from './repl.js';
17
18
 
19
+ test('runtime display preserves a failed plan and its diagnostic evidence', () => {
20
+ const state = {
21
+ status: 'error',
22
+ plan: [{ id: 'apply', status: 'failed' }],
23
+ activities: [{ id: 'job-1', status: 'failed', error: 'exitCode=1' }],
24
+ logs: ['run_error: ingest_apply exitCode=1'],
25
+ conversation: [{ role: 'assistant', content: 'Échec de l’ingestion.' }],
26
+ };
27
+
28
+ assert.equal(sanitizeRuntimeStateForDisplay(state), state);
29
+ });
30
+
31
+ test('runtime display still clears completed historical execution state', () => {
32
+ const display = sanitizeRuntimeStateForDisplay({
33
+ status: 'done',
34
+ plan: [{ id: 'old', status: 'done' }],
35
+ activities: [{ id: 'old-job', status: 'done' }],
36
+ logs: ['old log'],
37
+ conversation: [{ role: 'assistant', content: 'Old run.' }],
38
+ });
39
+
40
+ assert.deepEqual(display.plan, []);
41
+ assert.deepEqual(display.activities, []);
42
+ assert.deepEqual(display.logs, []);
43
+ assert.deepEqual(display.conversation, []);
44
+ });
45
+
18
46
  function stubFetch(handler) {
19
47
  const original = globalThis.fetch;
20
48
  globalThis.fetch = handler;
@@ -74,6 +102,50 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
74
102
  assert.deepEqual(conversationMessages(session), []);
75
103
  });
76
104
 
105
+ test('applyRuntimeStateToShellSession clears terminal plan and activities when runtime is idle', () => {
106
+ const session = createSession();
107
+ session.headlessPlan = [{ step: 1, description: 'Old read', status: 'failed' }];
108
+ session.activities = { old: { key: 'old', status: 'failed', terminal: true } };
109
+
110
+ applyRuntimeStateToShellSession(session, {
111
+ status: 'idle',
112
+ conversation: [{ role: 'assistant', content: 'Old failed answer' }],
113
+ chain: [{ id: 'old-step' }],
114
+ plan: [{ step: 1, description: 'Old read', status: 'failed' }],
115
+ activities: [{ key: 'old', status: 'failed', terminal: true }],
116
+ workflow: { nodes: [{ id: 'task:old' }], relations: [] },
117
+ logs: ['Runtime evaluator rejected the old run'],
118
+ summary: 'Old run failed',
119
+ planPatches: [{ id: 'old-patch' }],
120
+ });
121
+
122
+ assert.equal(session.headlessPlan, null);
123
+ assert.deepEqual(session.activities, {});
124
+ assert.equal(session.workflow, null);
125
+ assert.deepEqual(session.agentProjection.logs, []);
126
+ assert.equal(session.agentProjection.summary, null);
127
+ assert.deepEqual(session.agentProjection.conversation, []);
128
+ assert.deepEqual(session.agentProjection.chain, []);
129
+ assert.deepEqual(session.agentProjection.planPatches, []);
130
+ });
131
+
132
+ test('direct chat system prompt forbids unsolicited next steps', async () => {
133
+ const session = createSession();
134
+ let systemPrompt = '';
135
+ session.llm = {
136
+ async *stream({ system }) {
137
+ systemPrompt = system;
138
+ yield 'Réponse concise.';
139
+ },
140
+ };
141
+
142
+ await runLine('bonjour', { agent: null, packageJson: { version: 'test' }, session, chatMode: true });
143
+
144
+ assert.match(systemPrompt, /Never add a "Next steps", "Prochaines étapes", "À suivre"/);
145
+ assert.match(systemPrompt, /unless the user explicitly asks what to do next/);
146
+ assert.equal(conversationMessages(session).at(-1).content, 'Réponse concise.');
147
+ });
148
+
77
149
  test('submitRuntimeRun reports acceptance without throwing', async () => {
78
150
  const restore = stubFetch(async (url) => {
79
151
  assert.equal(pathOf(url), '/run');
@@ -241,37 +313,34 @@ test('/queue cancel on a runtime workflow id points to run cancellation commands
241
313
  assert.match(conversationMessages(session).at(-1).content, /\/run kill/);
242
314
  });
243
315
 
244
- test('free text routing keeps questions local and sends actions to the runtime', () => {
316
+ test('agent mode sends every free-text turn to Donna', () => {
245
317
  const session = createSession();
246
318
  session.llm = { completeWithTools: () => {} };
247
319
 
248
- // The original incident: a config question must never start a run.
249
320
  const question = shouldHandleFreeTextLocally('donne moi la config du cme', session);
250
321
  assert.equal(question.local, true);
251
- assert.equal(question.classification.kind, 'observe');
322
+ assert.equal(question.classification.kind, 'agent_turn');
252
323
 
253
324
  const smallTalk = shouldHandleFreeTextLocally('bonjour', session);
254
325
  assert.equal(smallTalk.local, true);
255
326
 
256
327
  const action = shouldHandleFreeTextLocally('lance le pipeline complet', session);
257
- assert.equal(action.local, false);
258
- assert.equal(action.classification.kind, 'start_run');
328
+ assert.equal(action.local, true);
329
+ assert.equal(action.classification.kind, 'agent_turn');
330
+
331
+ const pending = shouldHandleFreeTextLocally('as ton des fichier en attente d ingestion', session);
332
+ assert.equal(pending.local, true);
333
+ assert.equal(pending.classification.kind, 'agent_turn');
259
334
  });
260
335
 
261
- test('free text routing keeps questions local even during an active run', () => {
262
- // The chat must stay available during a run: a status question or small
263
- // talk answered locally (read-only tools) — never enqueued as a future run.
336
+ test('Donna keeps receiving free text during an active run', () => {
264
337
  const session = createSession();
265
338
  session.llm = { completeWithTools: () => {} };
266
339
  session.agentProjection = { status: 'running', activities: [], conversation: [] };
267
340
  assert.equal(shouldHandleFreeTextLocally('où en est le run', session).local, true);
268
341
  assert.equal(shouldHandleFreeTextLocally('salut', session).local, true);
269
- // Cancel intents are handled by Donna locally (runtime__kill/cancel tools);
270
- // approvals stay on the deterministic control lane.
271
342
  assert.equal(shouldHandleFreeTextLocally('stop le job', session).local, true);
272
343
  assert.equal(shouldHandleFreeTextLocally('supprime le job et la queue', session).local, true);
273
- // Approvals and "later" requests too: Donna owns runtime__approve and
274
- // runtime__enqueue. Only plan modifications and new runs bypass her.
275
344
  assert.equal(shouldHandleFreeTextLocally('approuve le run', session).local, true);
276
345
  assert.equal(shouldHandleFreeTextLocally('fais le build plus tard', session).local, true);
277
346
 
@@ -17,6 +17,7 @@ import {
17
17
  conversationMessages,
18
18
  createSession,
19
19
  runtimeUnavailableAgentMessage,
20
+ sanitizeRuntimeStateForDisplay,
20
21
  } from './repl.js';
21
22
  import { useAgent } from './useAgent';
22
23
 
@@ -340,10 +341,20 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
340
341
  function mergeRuntimeConversation(state: any) {
341
342
  const workspace = (session as any).workspace || '__global__';
342
343
  const runtimeConversation = Array.isArray(state?.conversation) ? state.conversation : [];
343
- if (runtimeConversation.length === 0) return;
344
344
  const target = conversationMessages(session);
345
345
  const backed = runtimeConversationRefsByWorkspace.get(workspace) ?? [];
346
346
  runtimeConversationRefsByWorkspace.set(workspace, backed);
347
+ if (runtimeConversation.length === 0) {
348
+ // Idle runtime display state deliberately has no historical run
349
+ // conversation. Remove only entries previously merged from that
350
+ // runtime; preserve local slash-command output and the current input.
351
+ for (const entry of backed) {
352
+ const index = target.indexOf(entry);
353
+ if (index !== -1) target.splice(index, 1);
354
+ }
355
+ backed.length = 0;
356
+ return;
357
+ }
347
358
  // The merge is index-aligned with the runtime conversation array. If that
348
359
  // array got SHORTER (runtime restart, projection reset), keeping stale
349
360
  // refs would make every new runtime entry silently overwrite an old
@@ -388,8 +399,9 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
388
399
  function syncRuntimeState() {
389
400
  void fetchRuntimeState({ url: props.runtime.url, workspace: (session as any).workspace ?? null })
390
401
  .then((state) => {
391
- setRuntimeState(state);
392
- mergeRuntimeConversation(state);
402
+ const displayState = sanitizeRuntimeStateForDisplay(state);
403
+ setRuntimeState(displayState);
404
+ mergeRuntimeConversation(displayState);
393
405
  setRuntimeStatus('connected');
394
406
  refresh();
395
407
  })