@dotdrelle/wiki-manager 0.15.60 → 0.15.62

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.15.60",
3
+ "version": "0.15.62",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.60",
3
- "commit": "8c5c615"
2
+ "version": "0.15.62",
3
+ "commit": "399432f"
4
4
  }
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.60';
4
+ const WIKI_MANAGER_VERSION = '0.15.62';
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('scaffold skills preserve existing capabilities and split only wiki-sync', async () => {
34
- const expected = { pipeline: 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
34
+ const expected = { pipeline: 1, 'wiki-ingest': 2, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
35
35
  for (const [name, count] of Object.entries(expected)) {
36
36
  const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
37
37
  const { meta, body } = parseFrontmatter(raw);
@@ -13,6 +13,7 @@
13
13
 
14
14
  const CONTROL_MESSAGES = {
15
15
  queued_for_future_run: 'Request added to the queue — it will start automatically after the current run.',
16
+ control_run_started: 'A queued request is now starting.',
16
17
  plan_patch_proposed: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
17
18
  ambiguous_control: 'A run is already active, and this looks like a new action. Say "queue it" to run it after the current run, "modify the run" to change the active plan, "cancel" to stop the current run first — or wait for it to finish.',
18
19
  converse_while_running: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
@@ -12,6 +12,7 @@ import { matchSkillInvocation } from '../core/skillInvocation.js';
12
12
  import { reconcileControlQueue } from './controlDrain.js';
13
13
  import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
14
14
  import { generateSkillAcknowledgment, runSkillChain } from './skillRun.js';
15
+ import { emitRuntimeLog } from './supervisor.js';
15
16
  import { findSkill, listSkills } from '../core/skills.js';
16
17
 
17
18
  const PRIVATE_CONTROL_INPUTS = new WeakMap();
@@ -780,7 +781,7 @@ export function startRuntimeServer({
780
781
  return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued, ...(purged !== null ? { purged } : {}) };
781
782
  }
782
783
 
783
- function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false } = {}) {
784
+ function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false, announceLaunch = false } = {}) {
784
785
  const runId = randomUUID();
785
786
  const runWorkspace = context.workspace ?? body.workspace ?? null;
786
787
  context.running = true;
@@ -803,6 +804,14 @@ export function startRuntimeServer({
803
804
  workspace: runWorkspace,
804
805
  payload: { id: controlItemId, runId },
805
806
  }));
807
+ // A control item is now a live run: announce it in the conversation so the
808
+ // "queued" acknowledgement is closed out by a "starting" one. A task is a
809
+ // task whether it waited or not — it is about to go through approval — so
810
+ // this is not gated on having waited. Skill-chain steps are skipped: they
811
+ // already announced the whole skill at invocation.
812
+ if (announceLaunch) {
813
+ announceControlLaunch(context.session, body.publicInput ?? body.input, runWorkspace);
814
+ }
806
815
  }
807
816
  const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
808
817
  runPromise
@@ -860,7 +869,7 @@ export function startRuntimeServer({
860
869
  },
861
870
  }
862
871
  : {}),
863
- }, { controlItemId: item.id }),
872
+ }, { controlItemId: item.id, announceLaunch: !item.chainId }),
864
873
  skipItem: (item, reason) => {
865
874
  privateControlInputsFor(context.session).delete(item.id);
866
875
  emitControlSkipped(context, item, reason);
@@ -1091,7 +1100,11 @@ export function approvalRequestFromStatus(status) {
1091
1100
 
1092
1101
  async function handleControlMessage(context, store, input, { intent = null, startNextControlRequest = () => false, cancel = null, approve = null } = {}) {
1093
1102
  const status = controlStatus(context, store);
1094
- const classification = classifyControlMessage(input, status, intent);
1103
+ const classification = await classifyControlMessage(input, status, {
1104
+ forcedIntent: intent,
1105
+ llm: context?.session?.llm,
1106
+ session: context?.session,
1107
+ });
1095
1108
  if (classification.kind === 'observe') {
1096
1109
  return readOnlyControlResponse('observe', classification, status, explainControlState(status));
1097
1110
  }
@@ -1129,6 +1142,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
1129
1142
  // startNextControlRequest), which can change running/plan/status — a full
1130
1143
  // controlStatus() recompute is required here, not just controlQueue.
1131
1144
  void startNextControlRequest(context);
1145
+ const explanation = await generateControlAcknowledgment(context?.session, { kind: 'queued', input });
1132
1146
  return {
1133
1147
  statusCode: 202,
1134
1148
  body: {
@@ -1137,7 +1151,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
1137
1151
  classification,
1138
1152
  item,
1139
1153
  ...controlStatus(context, store),
1140
- explanation: controlMessage(context?.session, 'queued_for_future_run'),
1154
+ explanation,
1141
1155
  },
1142
1156
  };
1143
1157
  }
@@ -1158,6 +1172,61 @@ async function handleControlMessage(context, store, input, { intent = null, star
1158
1172
  : controlMessage(context?.session, 'converse_while_idle'));
1159
1173
  }
1160
1174
 
1175
+ /*
1176
+ Control-lane acknowledgements are Donna's to localize.
1177
+
1178
+ The control lane stays deterministic in its CLASSIFICATION and its actions,
1179
+ but the acknowledgement the user reads ("queued, will run after this one" /
1180
+ "the queued task is starting") is a conversational reply: it goes through a
1181
+ single bounded LLM completion, like the skill-launch acknowledgement, and
1182
+ falls back to the deterministic English catalog when no LLM is configured or
1183
+ the call fails. The fallback is what keeps the lane deterministic-under-failure.
1184
+ */
1185
+ async function generateControlAcknowledgment(session, { kind, input }) {
1186
+ const language = String(session?.language ?? '').trim().toLowerCase() || 'en';
1187
+ const llm = session?.llm;
1188
+ const fallback = kind === 'queued'
1189
+ ? controlMessage(session, 'queued_for_future_run')
1190
+ : controlMessage(session, 'control_run_started');
1191
+ if (llm && typeof llm.complete === 'function') {
1192
+ try {
1193
+ const scenario = kind === 'queued'
1194
+ ? 'The user requested a new task while a run is active. It was queued and will start automatically after the current run finishes.'
1195
+ : 'A task the user queued earlier is now starting.';
1196
+ const instruction = kind === 'queued'
1197
+ ? 'their request is queued and will run after the current run finishes'
1198
+ : 'the queued task is now starting';
1199
+ const reply = await llm.complete({
1200
+ system: 'You are Donna, the workspace assistant. You acknowledge a runtime queue event in the user\'s language. Be concise: exactly one short sentence.',
1201
+ input: `${scenario}\n\nThe task is: ${input}\n\nWrite ONE short sentence in ${language} that tells the user ${instruction}. Return only that sentence, nothing else.`,
1202
+ signal: AbortSignal.timeout(8_000),
1203
+ });
1204
+ const text = String(reply ?? '').trim();
1205
+ if (text) return text;
1206
+ emitRuntimeLog(session, 'control-acknowledgment: LLM returned an empty reply, using the deterministic fallback');
1207
+ } catch (err) {
1208
+ // A degradation must announce itself: silently falling through here
1209
+ // hides the difference between "no LLM configured" (expected) and "the
1210
+ // configured LLM is failing every call" (a real problem) — both would
1211
+ // otherwise look identical from the Shell or serve UI.
1212
+ emitRuntimeLog(session, `control-acknowledgment: LLM call failed, using the deterministic fallback — ${err instanceof Error ? err.message : String(err)}`);
1213
+ }
1214
+ }
1215
+ return fallback;
1216
+ }
1217
+
1218
+ function announceControlLaunch(session, input, workspace) {
1219
+ void generateControlAcknowledgment(session, { kind: 'started', input })
1220
+ .then((content) => {
1221
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', {
1222
+ origin: 'runtime',
1223
+ workspace,
1224
+ payload: { content, independent: true },
1225
+ }));
1226
+ })
1227
+ .catch(() => {});
1228
+ }
1229
+
1161
1230
  /*
1162
1231
  `skillStack` accompagne l'élément, il ne vit pas sur la session.
1163
1232
 
@@ -1353,13 +1422,15 @@ function rejectPlanPatch(context, store, patchId, reason) {
1353
1422
  };
1354
1423
  }
1355
1424
 
1356
- // Interim classifier for control §4.2 of the plan directeur: the plan expects
1357
- // an LLM-backed classification eventually ("la classification LLM se
1358
- // trompera" — the plan's own fallback-UX rule presupposes an LLM). This is a
1359
- // synchronous keyword/regex stand-in with the same {kind, confidence, reason}
1360
- // contract, so swapping in an LLM call later shouldn't require touching
1361
- // handleControlMessage.
1362
- function classifyControlMessage(input, status, forcedIntent = null) {
1425
+ // Classifier for control §4.2 of the plan directeur. The plan expects an
1426
+ // LLM-backed classification — "the classification LLM se trompera" — and this
1427
+ // is that, now: the only deterministic matches left are the runtime's own
1428
+ // control verbs (cancel, an explicit "later/queue", status and plan-change
1429
+ // wording). Deciding "is this a NEW task to queue vs plain conversation" is a
1430
+ // semantic judgement about the workspace's domain, so it is never a keyword
1431
+ // list here — it goes to the model, bounded, and falls back to the choice menu
1432
+ // (`ambiguous`) rather than guessing when no model is available.
1433
+ async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
1363
1434
  // Caller (the /control message route) already trims and rejects empty input.
1364
1435
  const lower = String(input ?? '').toLowerCase();
1365
1436
  const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
@@ -1377,22 +1448,53 @@ function classifyControlMessage(input, status, forcedIntent = null) {
1377
1448
  if (explicit) {
1378
1449
  return { kind: explicit, confidence: 1, reason: 'explicit_intent' };
1379
1450
  }
1451
+ // Cancel stays a keyword: it is a runtime control verb, and an abort must not
1452
+ // wait on a model round-trip.
1380
1453
  if (/\b(cancel|annule|stop|arr[eê]te|interromps|abort)\b/i.test(lower)) {
1381
1454
  return { kind: 'cancel', confidence: 0.86, reason: 'cancel_request' };
1382
1455
  }
1383
1456
  if (/\b(plus tard|later|ensuite|apr[eè]s ce run|enqueue|mets en file|met en file|futur|next run|future run)\b/i.test(lower)) {
1384
1457
  return { kind: 'enqueue_run', confidence: 0.8, reason: 'future_run_request' };
1385
1458
  }
1386
- if (/\b(o[uù] en es[t-]|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
1459
+ if (/\b(o[uù] en es[t-]|status|statut|progress|progression|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
1387
1460
  return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
1388
1461
  }
1389
1462
  if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
1390
1463
  return { kind: 'modify_run', confidence: 0.78, reason: 'active_run_change_request' };
1391
1464
  }
1392
- if (status.running && /\b(lance|run|g[eé]n[eè]re|build|export|cr[eé]e|create|send|envoie|ingest|convert|importe|import)\b/i.test(lower)) {
1393
- return { kind: 'ambiguous', confidence: 0.45, reason: 'active_run_action_is_ambiguous' };
1465
+ if (!status.running) return { kind: 'converse', confidence: 0.62, reason: 'plain_conversation' };
1466
+ // A run is active and none of the runtime control verbs matched. The message
1467
+ // is either a request to perform a NEW mutating task (→ queue it to run
1468
+ // after the current one) or ordinary conversation — that is a judgement about
1469
+ // the workspace's domain, so the model decides it, never a keyword list.
1470
+ if (llm && typeof llm.complete === 'function') {
1471
+ try {
1472
+ const reply = await llm.complete({
1473
+ system: 'You classify one user message typed while a run is already active. Return exactly one word, nothing else.',
1474
+ input: [
1475
+ `The user typed this while a run is active: "${input}"`,
1476
+ '',
1477
+ 'Choose ONE of:',
1478
+ '- "action" — a request to perform a NEW task (generate, create, ingest, build, export, convert, send, publish, produce…), which must run after the current run.',
1479
+ '- "conversation" — ordinary conversation, a question, or an unrelated remark.',
1480
+ '',
1481
+ 'Return only that one word.',
1482
+ ].join('\n'),
1483
+ signal: AbortSignal.timeout(8_000),
1484
+ });
1485
+ const kind = String(reply ?? '').trim().toLowerCase();
1486
+ if (kind.startsWith('action')) return { kind: 'enqueue_run', confidence: 0.85, reason: 'llm_classified_action' };
1487
+ if (kind.startsWith('conversation')) return { kind: 'converse', confidence: 0.85, reason: 'llm_classified_conversation' };
1488
+ emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply, falling back to the choice menu — ${JSON.stringify(kind).slice(0, 200)}`);
1489
+ } catch (err) {
1490
+ // A degradation must announce itself: silently falling through here
1491
+ // hides the difference between "no LLM configured" (expected) and "the
1492
+ // configured LLM is failing every call" (a real problem) — both would
1493
+ // otherwise look identical from the Shell or serve UI.
1494
+ emitRuntimeLog(session, `control-classify: LLM call failed, falling back to the choice menu — ${err instanceof Error ? err.message : String(err)}`);
1495
+ }
1394
1496
  }
1395
- return { kind: 'converse', confidence: 0.62, reason: 'plain_conversation' };
1497
+ return { kind: 'ambiguous', confidence: 0.45, reason: 'action_vs_conversation_unclear' };
1396
1498
  }
1397
1499
 
1398
1500
  function isAuthorized(request, token) {
@@ -1434,10 +1434,14 @@ test('runtime server control message records active plan mutation as a proposal'
1434
1434
  }
1435
1435
  });
1436
1436
 
1437
- test('runtime server control message reports ambiguity without starting a run', async (t) => {
1437
+ test('runtime server auto-queues a clear new action while a run is active', async (t) => {
1438
1438
  const session = {
1439
1439
  workspace: 'acme',
1440
1440
  controlQueue: [],
1441
+ _onAgentEvent: () => {},
1442
+ // The classifier asks the model whether the message is a new action; a
1443
+ // keyword list is deliberately not part of the code path.
1444
+ llm: { complete: async () => 'action' },
1441
1445
  };
1442
1446
  let runCount = 0;
1443
1447
  let handle;
@@ -1451,6 +1455,7 @@ test('runtime server control message reports ambiguity without starting a run',
1451
1455
  status: 'running',
1452
1456
  plan: [{ step: 1, description: 'Generate', status: 'running' }],
1453
1457
  queue: [],
1458
+ controlQueue: session.controlQueue,
1454
1459
  approvals: [],
1455
1460
  summary: null,
1456
1461
  }),
@@ -1478,12 +1483,11 @@ test('runtime server control message reports ambiguity without starting a run',
1478
1483
  headers: { 'Content-Type': 'application/json' },
1479
1484
  body: JSON.stringify({ action: 'message', input: 'Lance aussi la publication' }),
1480
1485
  });
1481
- assert.equal(response.status, 200);
1486
+ assert.equal(response.status, 202);
1482
1487
  const body = await response.json();
1483
- assert.equal(body.kind, 'ambiguous');
1484
- assert.equal(body.choices.length, 3);
1485
- assert.equal(session.controlQueue.length, 0);
1486
- assert.equal(runCount, 0);
1488
+ assert.equal(body.kind, 'enqueue_run');
1489
+ assert.equal(body.item.status, 'queued');
1490
+ assert.equal(runCount, 0, 'the queued task must not start while the current run is active');
1487
1491
  } finally {
1488
1492
  await handle.close();
1489
1493
  }
@@ -1545,7 +1549,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
1545
1549
  assert.match(receivedBody.runId, /^[0-9a-f-]{36}$/);
1546
1550
  assert.equal(session.controlQueue[0].status, 'running');
1547
1551
  assert.equal(session.controlQueue[0].runId, receivedBody.runId);
1548
- assert.deepEqual(events.map((event) => event.type), ['control_enqueued', 'control_started']);
1552
+ assert.deepEqual(events.map((event) => event.type), ['control_enqueued', 'control_started', 'assistant_message']);
1549
1553
  } finally {
1550
1554
  await handle.close();
1551
1555
  }
@@ -202,7 +202,7 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
202
202
  // that silently fragments would show up as extra runs, not as extra objectives.
203
203
  const PERFORMANCE_TABLE = {
204
204
  pipeline: 1,
205
- 'wiki-ingest': 1,
205
+ 'wiki-ingest': 2,
206
206
  'wiki-build': 1,
207
207
  deliver: 1,
208
208
  diagnose: 1,
@@ -859,7 +859,7 @@ export function LeftPane(props: {
859
859
  above the composer keeps the current job in view while composing; the
860
860
  right pane keeps the full Plan/Queue/Logs detail.
861
861
  */}
862
- <box flexShrink={0} height={4} flexDirection="column" overflow="hidden">
862
+ <box flexShrink={0} height={4} flexDirection="column" overflow="hidden" backgroundColor="#111318">
863
863
  <ActivityPanel activities={props.activities} width={props.width - 2} />
864
864
  </box>
865
865
  <ChatInput
@@ -20,7 +20,10 @@ type LogLineParts = { time: string | null; message: string };
20
20
  // 4 slots (was 6): items can now span up to 5 lines each (wrapped label +
21
21
  // wrapped status/error), so fewer, readable entries beat more, truncated ones.
22
22
  const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
23
- const PLAN_VIEWPORT_ROWS = 12;
23
+ // Hauteur du panneau Plan : 6 lignes visibles au maximum. Les etapes suivantes
24
+ // restent atteignables en faisant defiler la scrollbox (barre de defilement
25
+ // affichee des que le plan depasse la fenetre).
26
+ const PLAN_VIEWPORT_ROWS = 6;
24
27
 
25
28
  function wrapLine(value: string, width: number) {
26
29
  const max = Math.max(8, width);
@@ -194,12 +197,10 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
194
197
  // Keep one column for the native vertical scrollbar when the plan is long.
195
198
  const lineWidth = () => Math.max(8, props.width - 3);
196
199
  const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
197
- // A running step carries a thick left border, which costs one column: the
198
- // wrap width is therefore one less for it. The same function feeds both the
199
- // row-count memo and the render, so the viewport height can never undercount
200
- // a running step's wrapped lines and clip the last one.
201
- const isRunningStep = (step: PlanStep) => String(step.status ?? '').toLowerCase() === 'running';
202
- const stepTextWidth = (step: PlanStep) => lineWidth() - (isRunningStep(step) ? 1 : 0);
200
+ // Plus de liseré bleu à gauche : l'icône et la couleur du texte suffisent à
201
+ // distinguer une étape en cours, et le cadre se voyait aussi sur une étape en
202
+ // attente (jaune). Toutes les étapes utilisent donc la même largeur.
203
+ const stepTextWidth = (_step: PlanStep) => lineWidth();
203
204
  const icon = (rawStatus: string) => {
204
205
  const status = String(rawStatus ?? '').toLowerCase();
205
206
  if (DONE_STATUSES.includes(status)) return '[✓]';
@@ -241,11 +242,10 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
241
242
  {(step) => {
242
243
  // Wrap step descriptions over up to 2 lines instead of truncating —
243
244
  // "Ingest des 39 documents raw/untrac…" hid the actual target.
244
- const running = () => isRunningStep(step());
245
245
  const textWidth = () => stepTextWidth(step());
246
246
  const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, textWidth()).slice(0, 2);
247
247
  return (
248
- <box flexShrink={0} flexDirection="column" border={running() ? ['left'] : undefined} borderStyle="heavy" borderColor="#89B4FA">
248
+ <box flexShrink={0} flexDirection="column">
249
249
  <text width={textWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
250
250
  <Show when={lines()[1]}>
251
251
  <text width={textWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, textWidth() - 4))}`} />
@@ -265,7 +265,7 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
265
265
  const visibleSlots = () => visible().map((_activity, index) => index);
266
266
  const activityAt = (index: number) => visible()[index] ?? null;
267
267
  return (
268
- <box flexShrink={0} flexDirection="column" paddingX={1}>
268
+ <box flexShrink={0} flexDirection="column" paddingX={1} backgroundColor="#111318">
269
269
  <text width={lineWidth()} fg="#D6DEE8" content="Activity" />
270
270
  <Show when={visible().length > 0} fallback={<text width={lineWidth()} fg="#7F8C8D" content="no active jobs" />}>
271
271
  <Index each={visibleSlots()}>
@@ -385,7 +385,7 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
385
385
  .filter((line) => activeLogTab() === 'agent-status' ? isAgentStatus(line) : !isAgentStatus(line));
386
386
  const allLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()));
387
387
  return (
388
- <box flexGrow={2} flexDirection="column" paddingX={1} focusable={false}>
388
+ <box flexGrow={2} flexDirection="column" paddingX={1} marginTop={6} focusable={false}>
389
389
  <text width={lineWidth()} fg="#4B5563" content={'─'.repeat(lineWidth())} />
390
390
  <box height={1} flexDirection="row">
391
391
  <text