@dotdrelle/wiki-manager 0.15.97 → 0.15.99

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.
Files changed (36) hide show
  1. package/package.json +2 -2
  2. package/src/agent/graph.js +45 -7
  3. package/src/agent/graph.test.js +45 -0
  4. package/src/cli/wiki-manager.js +47 -33
  5. package/src/commands/slash.js +7 -2
  6. package/src/contracts/schemas.js +8 -17
  7. package/src/contracts/schemas.test.js +15 -0
  8. package/src/core/agentEvents.js +45 -7
  9. package/src/core/agentEvents.test.js +65 -0
  10. package/src/core/buildInfo.json +2 -2
  11. package/src/core/mcp.js +1 -1
  12. package/src/core/runtimeEventAdapter.js +99 -1
  13. package/src/core/runtimeEventAdapter.test.js +92 -2
  14. package/src/core/skillCompiler.test.js +1 -1
  15. package/src/core/testGate.test.js +33 -0
  16. package/src/core/toolLoop.js +14 -2
  17. package/src/core/toolLoop.test.js +28 -0
  18. package/src/orchestrator/dispatcher.js +19 -0
  19. package/src/orchestrator/knowledgeSignals.js +260 -0
  20. package/src/orchestrator/knowledgeSignals.test.js +193 -0
  21. package/src/orchestrator/proactiveReviewScheduler.js +240 -0
  22. package/src/orchestrator/proactiveReviewScheduler.test.js +243 -0
  23. package/src/orchestrator/providers/deepAgentsProvider.js +134 -29
  24. package/src/orchestrator/providers/deepAgentsProvider.test.js +138 -3
  25. package/src/orchestrator/resultAggregator.js +115 -1
  26. package/src/orchestrator/resultAggregator.test.js +138 -0
  27. package/src/runtime/controlClassify.test.js +31 -0
  28. package/src/runtime/runner.js +13 -4
  29. package/src/runtime/runner.test.js +20 -0
  30. package/src/runtime/server.js +256 -4
  31. package/src/runtime/server.test.js +13 -1
  32. package/src/runtime/store.js +1 -1
  33. package/src/runtime/store.test.js +5 -1
  34. package/src/shell/openExternal.js +43 -0
  35. package/src/shell/repl.js +1 -1
  36. package/wiki-workspace +0 -1
@@ -0,0 +1,31 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { classifyControlMessage } from './server.js';
4
+
5
+ const running = { running: true };
6
+
7
+ // A bare "yes" answers the runtime's own last prompt. Anything MORE than a
8
+ // bare yes is a request, and the rule used to be prefix-anchored only: every
9
+ // message merely STARTING on a yes was classified `observe` and answered with
10
+ // a status report, shadowing the modify_run and enqueue_run branches below it.
11
+ test('a bare confirmation during a run is a status check', async () => {
12
+ for (const input of ['oui', 'OK', 'vas-y', "d'accord", 'yes.', 'entendu !']) {
13
+ const result = await classifyControlMessage(input, running);
14
+ assert.equal(result.kind, 'observe', `expected observe for ${JSON.stringify(input)}`);
15
+ }
16
+ });
17
+
18
+ test('a plan change that merely opens on a yes is still a plan change', async () => {
19
+ const result = await classifyControlMessage(
20
+ 'oui, ajoute une étape de polish après le build',
21
+ running,
22
+ );
23
+ assert.equal(result.kind, 'modify_run');
24
+ });
25
+
26
+ test('a new task that opens on a yes reaches the model classifier, not the status branch', async () => {
27
+ const result = await classifyControlMessage("vas-y lance l'export", running, {
28
+ llm: { complete: async () => 'action' },
29
+ });
30
+ assert.equal(result.kind, 'enqueue_run');
31
+ });
@@ -9,7 +9,7 @@ import { createBudgetManager, BudgetExceededError } from '../orchestrator/budget
9
9
  import { createDispatcher } from '../orchestrator/dispatcher.js';
10
10
  import { approvalCovered, approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
11
11
  import { blockedByFailedDependency, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
12
- import { isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
12
+ import { isCancelled, isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
13
13
  import { assertValidatedFragment } from '../orchestrator/planValidator.js';
14
14
  import { createResultAggregator } from '../orchestrator/resultAggregator.js';
15
15
  import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
@@ -308,6 +308,7 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
308
308
  if (plan.length === 0) return;
309
309
  let failed = 0;
310
310
  let cancelled = 0;
311
+ let skipped = 0;
311
312
  let completed = 0;
312
313
  let pending = 0;
313
314
  let firstError = null;
@@ -319,8 +320,14 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
319
320
  step?.error?.message ?? step?.error?.code ?? step?.error
320
321
  ?? step?.result?.error?.message ?? step?.result?.error?.code ?? '',
321
322
  ).trim() || null;
322
- } else if (['cancelled', 'canceled'].includes(status)) {
323
+ } else if (isCancelled(status)) {
323
324
  cancelled += 1;
325
+ } else if (isSkipped(status)) {
326
+ // A chain step abandoned because an earlier required one failed. It fell
327
+ // through every bucket, so a 3-step chain that failed at step 1
328
+ // announced "0/3 réussie(s), 1 en erreur" and never mentioned the two
329
+ // steps nobody ran — precisely the silence the announce rule forbids.
330
+ skipped += 1;
324
331
  } else if (isSuccessful(status)) {
325
332
  completed += 1;
326
333
  } else if (isPending(status) || !isTerminal(status)) {
@@ -333,13 +340,15 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
333
340
  }
334
341
  }
335
342
  const total = plan.length;
336
- const finished = ok && failed === 0 && cancelled === 0 && pending === 0 && completed === total;
343
+ const finished = ok && failed === 0 && cancelled === 0 && skipped === 0
344
+ && pending === 0 && completed === total;
337
345
  const factLine = finished
338
346
  ? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
339
347
  : `Plan non terminé — ${completed}/${total} tâche(s) réussie(s)` +
340
348
  `${pending ? `, ${pending} en attente (approbation ou exécution)` : ''}` +
341
349
  `${failed ? `, ${failed} en erreur` : ''}` +
342
- `${cancelled ? `, ${cancelled} annulée(s)` : ''}.` +
350
+ `${cancelled ? `, ${cancelled} annulée(s)` : ''}` +
351
+ `${skipped ? `, ${skipped} abandonnée(s) faute d'une étape précédente` : ''}.` +
343
352
  `${firstError ? ` Première erreur : ${firstError}.` : ''}`;
344
353
  let content = factLine;
345
354
  const llm = session.llm;
@@ -1306,6 +1306,26 @@ test('announceRunOutcome never calls a plan with pending tasks a success', async
1306
1306
  assert.doesNotMatch(message.payload.content, /succès/i);
1307
1307
  });
1308
1308
 
1309
+ test('announceRunOutcome names the steps a failed chain abandoned', async () => {
1310
+ // `skipped` fell through every bucket: a 3-step chain failing at step 1
1311
+ // announced "0/3 réussie(s), 1 en erreur" and never mentioned the two steps
1312
+ // nobody ran. "When something is skipped, say so where the panels read."
1313
+ const session = {
1314
+ agentEvents: [],
1315
+ agentProjection: null,
1316
+ headlessPlan: [
1317
+ { id: 'a', description: 'Sync', status: 'failed' },
1318
+ { id: 'b', description: 'Ingest', status: 'skipped' },
1319
+ { id: 'c', description: 'Build', status: 'skipped' },
1320
+ ],
1321
+ };
1322
+ await announceRunOutcome(session, { runId: 'run-3', ok: false });
1323
+ const message = session.agentEvents.find((event) => event.type === 'assistant_message');
1324
+ assert.match(message.payload.content, /non terminé/i);
1325
+ assert.match(message.payload.content, /1 en erreur/);
1326
+ assert.match(message.payload.content, /2 abandonnée\(s\)/);
1327
+ });
1328
+
1309
1329
  test('announceRunOutcome reports success only when every task finished', async () => {
1310
1330
  const session = {
1311
1331
  agentEvents: [],
@@ -10,6 +10,21 @@ import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
10
10
  import { isActive, isCancelled, isFailed, isSuccessful } from '../orchestrator/taskStatuses.js';
11
11
  import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
12
12
  import { RUNTIME_SHUTDOWN_ABORT_REASON } from '../orchestrator/dispatcher.js';
13
+ import {
14
+ PROACTIVE_REVIEW_CAPABILITY,
15
+ buildProactiveReviewObjective,
16
+ createProactiveReviewScheduler,
17
+ normalizeProactiveConfig,
18
+ } from '../orchestrator/proactiveReviewScheduler.js';
19
+ import {
20
+ conflictFingerprint,
21
+ detectConceptConflicts,
22
+ detectStaleKnowledge,
23
+ readConceptLeaves,
24
+ readSourceRegistry,
25
+ readWikiPages,
26
+ staleFingerprint,
27
+ } from '../orchestrator/knowledgeSignals.js';
13
28
  import { matchSkillInvocation } from '../core/skillInvocation.js';
14
29
  import { reconcileControlQueue } from './controlDrain.js';
15
30
  import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
@@ -74,6 +89,17 @@ export function startRuntimeServer({
74
89
  exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
75
90
  } = {}) {
76
91
  const clients = new Set();
92
+ // Deterministic dedup/cooldown/budget for proactive reviews; the run itself
93
+ // stays the normal control-lane path.
94
+ const proactiveScheduler = createProactiveReviewScheduler({
95
+ db: store?.db,
96
+ pendingReviews: store?.getProjection ? () => (store.getProjection().controlQueue ?? [])
97
+ .filter((item) => item.proactiveReview && ['queued', 'running'].includes(item.status))
98
+ .map((item) => item.proactiveReview) : null,
99
+ });
100
+ // runId -> the review record that started it, so the slot is released exactly
101
+ // once when the run reaches a terminal state (persisted or not).
102
+ const proactiveRuns = new Map();
77
103
  // Compiled objectives are private execution material. They deliberately do
78
104
  // not enter events, projections, SSE, audit output or the runs table.
79
105
  // When this runtime process started — used by ensureRuntime to detect that
@@ -508,6 +534,12 @@ export function startRuntimeServer({
508
534
  sendJson(response, 400, { error: 'Missing input.' });
509
535
  return;
510
536
  }
537
+ // What the reader actually typed. `input` below may be replaced by a
538
+ // system fact block for the model; the THREAD must still show the
539
+ // reader's own words — the replacement was persisted as the
540
+ // `user_message` and replayed as history on every later turn, which is
541
+ // exactly the "raw facts never enter the thread" rule it broke.
542
+ const displayInput = input;
511
543
  // Read-only chat turns intentionally remain available while an agent
512
544
  // run is active. Other interactive turns still become control
513
545
  // messages so they cannot start a competing agent decision.
@@ -603,7 +635,12 @@ export function startRuntimeServer({
603
635
  return result.body;
604
636
  }
605
637
  }
606
- return turn(context, { ...body, input, mode: readOnlyChat ? 'chat' : body.mode }, {
638
+ return turn(context, {
639
+ ...body,
640
+ input,
641
+ displayInput,
642
+ mode: readOnlyChat ? 'chat' : body.mode,
643
+ }, {
607
644
  signal: controller.signal,
608
645
  turnId,
609
646
  });
@@ -849,6 +886,26 @@ export function startRuntimeServer({
849
886
  const loginAttemptPruneTimer = setInterval(() => pruneLoginAttempts(), 10 * 60 * 1000);
850
887
  loginAttemptPruneTimer.unref?.();
851
888
 
889
+ // The corpus clock. `aged` is caused by TIME, and a workspace that stops
890
+ // ingesting — precisely the one whose knowledge ages — would otherwise never
891
+ // re-run the detector; conflicts and vanished paths are edit-caused, so the
892
+ // ingest moment covers them. This tick therefore reads only the source
893
+ // registry (staleOnly), and the opt-in gating happens inside the scan.
894
+ const corpusScanMs = Number(process.env.WIKI_MANAGER_CORPUS_SCAN_INTERVAL_MS ?? 15 * 60 * 1000);
895
+ const corpusScanTimer = Number.isFinite(corpusScanMs) && corpusScanMs > 0
896
+ ? setInterval(() => {
897
+ void (async () => {
898
+ try {
899
+ const context = await resolvedGetContext(null);
900
+ if (!context?.session) return;
901
+ const workspace = context.workspace ?? context.session.workspace ?? null;
902
+ if (workspace) emitCorpusSignals(context, workspace, { staleOnly: true });
903
+ } catch { /* a clock tick never breaks the server */ }
904
+ })();
905
+ }, corpusScanMs)
906
+ : null;
907
+ corpusScanTimer?.unref?.();
908
+
852
909
  return new Promise((resolve, reject) => {
853
910
  server.once('error', reject);
854
911
  server.listen(port, host, () => {
@@ -861,6 +918,7 @@ export function startRuntimeServer({
861
918
  drainControl: (context) => drainControlQueue(context),
862
919
  close: () => new Promise((closeResolve, closeReject) => {
863
920
  clearInterval(loginAttemptPruneTimer);
921
+ if (corpusScanTimer) clearInterval(corpusScanTimer);
864
922
  for (const client of clients) client.response.end();
865
923
  clients.clear();
866
924
  server.close((err) => (err ? closeReject(err) : closeResolve()));
@@ -1018,6 +1076,14 @@ export function startRuntimeServer({
1018
1076
  announceControlLaunch(context.session, body.publicInput ?? body.input, runWorkspace);
1019
1077
  }
1020
1078
  }
1079
+ // The trigger hook and the proactive marker belong to THIS run on the
1080
+ // session; the review that opened it is remembered so its concurrency slot
1081
+ // is released exactly once, whatever terminal state it reaches.
1082
+ if (context.session) context.session._onKnowledgeTrigger = (payload) => handleKnowledgeTrigger(context, payload);
1083
+ if (body.proactiveReview) {
1084
+ if (context.session) context.session._proactiveReview = { ...body.proactiveReview, runId };
1085
+ proactiveRuns.set(runId, body.proactiveReview);
1086
+ }
1021
1087
  const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
1022
1088
  runPromise
1023
1089
  .catch((err) => {
@@ -1028,6 +1094,12 @@ export function startRuntimeServer({
1028
1094
  context.session?._onRuntimeError?.(err, runId);
1029
1095
  })
1030
1096
  .finally(() => {
1097
+ const proactive = proactiveRuns.get(runId);
1098
+ if (proactive) {
1099
+ proactiveRuns.delete(runId);
1100
+ proactiveScheduler.release(proactive.workspace);
1101
+ }
1102
+ if (context.session?._proactiveReview?.runId === runId) context.session._proactiveReview = null;
1031
1103
  context.running = false;
1032
1104
  context.currentAbortController = null;
1033
1105
  context.currentRunId = null;
@@ -1062,6 +1134,7 @@ export function startRuntimeServer({
1062
1134
  // may still grant the pending run explicitly through --auto-approve.
1063
1135
  ...(item.chainId ? { requireApproval: true } : {}),
1064
1136
  ...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
1137
+ ...(item.proactiveReview ? { proactiveReview: item.proactiveReview } : {}),
1065
1138
  ...(item.chainId
1066
1139
  ? {
1067
1140
  skillChain: {
@@ -1082,6 +1155,179 @@ export function startRuntimeServer({
1082
1155
  });
1083
1156
  }
1084
1157
 
1158
+ /*
1159
+ A knowledge fact is not an order: it is an opportunity, and the workspace
1160
+ says (opt-in) whether it wants one. The decision is deterministic — dedup,
1161
+ cooldown, budget, concurrency — and a refusal is said out loud, never a
1162
+ silent no. An accepted trigger queues a read-only `agent.review` through the
1163
+ normal control lane; nothing here mutates, creates a worktree or sends
1164
+ anything.
1165
+ */
1166
+ function handleKnowledgeTrigger(context, payload) {
1167
+ const session = context?.session ?? null;
1168
+ const workspace = String(payload?.workspace ?? context?.workspace ?? '');
1169
+ const trigger = String(payload?.trigger ?? '');
1170
+ // The corpus detectors are reads that only make sense when the corpus just
1171
+ // changed. They feed this same scheduler — never a second one — and cannot
1172
+ // recurse, since a conflict/stale fact does not re-run the detectors.
1173
+ if (trigger === 'knowledge.ingested' || trigger === 'knowledge.rebuilt') {
1174
+ emitCorpusSignals(context, workspace);
1175
+ }
1176
+ const config = session?.wikircConfig?.proactiveReviews ?? null;
1177
+ let decision;
1178
+ try {
1179
+ decision = proactiveScheduler.decide({
1180
+ workspace, trigger, sourceVersion: payload?.sourceVersion ?? null, config,
1181
+ });
1182
+ } catch (error) {
1183
+ emitRuntimeLog(session, `proactive-review: skipped — budget state unavailable: ${error.message}`);
1184
+ return;
1185
+ }
1186
+ if (decision.action !== 'review') {
1187
+ const inFlight = proactiveScheduler.snapshot(workspace).inFlightTrigger;
1188
+ // The conflict detector runs first, so it can take the only slot; saying
1189
+ // so turns an effect of statement order into a stated precedence.
1190
+ const precedence = decision.reason === 'concurrency' && inFlight
1191
+ ? ` — a ${inFlight} review already holds the slot`
1192
+ : '';
1193
+ emitRuntimeLog(
1194
+ session,
1195
+ `proactive-review: skipped (${decision.reason}${precedence}) for ${workspace || 'workspace'} [${trigger}]`,
1196
+ );
1197
+ return;
1198
+ }
1199
+ const evidence = payload?.evidence ?? null;
1200
+ const record = {
1201
+ id: `review-${randomUUID()}`,
1202
+ workspace,
1203
+ trigger,
1204
+ sourceVersion: decision.sourceVersion,
1205
+ createdAt: new Date().toISOString(),
1206
+ budget: proactiveScheduler.snapshot(workspace),
1207
+ // The deterministic facts, so the filed note says WHAT the scan knew —
1208
+ // not only that a review happened. Persisted with the item's marker.
1209
+ ...(evidence ? { evidence } : {}),
1210
+ };
1211
+ const objective = buildProactiveReviewObjective({
1212
+ trigger,
1213
+ sourceVersion: decision.sourceVersion,
1214
+ evidence,
1215
+ });
1216
+ let item;
1217
+ try {
1218
+ item = enqueueControlRequest(context, objective, {
1219
+ publicInput: objective,
1220
+ // Routing is EXPLICIT. The objective names evidence paths, which could
1221
+ // contain another capability's alias (`report`, `plan`, `check`…), and
1222
+ // the free-text resolver returns null on two hits. Naming the
1223
+ // capability removes that risk entirely.
1224
+ capabilityPlan: { capability: PROACTIVE_REVIEW_CAPABILITY, operation: 'run' },
1225
+ proactiveReview: record,
1226
+ });
1227
+ } catch (error) {
1228
+ // A reservation that never became a queued item must be UNDONE: burning a
1229
+ // budget unit and marking the version seen would silently cancel an audit
1230
+ // that was never enqueued.
1231
+ proactiveScheduler.release(workspace, { undo: true, sourceVersion: decision.sourceVersion });
1232
+ emitRuntimeLog(
1233
+ session,
1234
+ `proactive-review: could not queue the review — ${error instanceof Error ? error.message : String(error)}`,
1235
+ );
1236
+ return;
1237
+ }
1238
+ emitRuntimeLog(
1239
+ session,
1240
+ `proactive-review: queued ${item.id} (${trigger}) for ${workspace || 'workspace'} — ${PROACTIVE_REVIEW_CAPABILITY}, read-only`,
1241
+ );
1242
+ void startNextControlRequest(context);
1243
+ }
1244
+
1245
+ /*
1246
+ The live-corpus read: two homonym leaves under one concept folder are a
1247
+ conflict the ingest plan never sees (they are already written). Pure fs +
1248
+ string work, no model. A conflict set is a stable fingerprint, so the same
1249
+ conflict dedups instead of re-firing every time the corpus is touched.
1250
+ */
1251
+ function emitConflictSignal(context, workspace, session, workspacePath) {
1252
+ const { conflicts, total, dropped } = detectConceptConflicts(readConceptLeaves(workspacePath));
1253
+ if (total === 0) return;
1254
+ if (dropped > 0) {
1255
+ emitRuntimeLog(
1256
+ session,
1257
+ `knowledge-signals: ${total} conflict(s) found, ${dropped} beyond the ceiling are not listed (the fingerprint still counts them)`,
1258
+ );
1259
+ }
1260
+ handleKnowledgeTrigger(context, {
1261
+ workspace,
1262
+ trigger: 'knowledge.conflict_detected',
1263
+ // The fingerprint includes the full count, so a conflict beyond the cap
1264
+ // still moves the version and is not deduped away.
1265
+ sourceVersion: conflictFingerprint(conflicts, total),
1266
+ // The exact facts travel to the agent and the filed note.
1267
+ evidence: { kind: 'conflict', items: conflicts },
1268
+ });
1269
+ }
1270
+
1271
+ function emitStaleSignal(context, workspace, session, workspacePath, config) {
1272
+ const { stale, total, dropped, counts } = detectStaleKnowledge(readSourceRegistry(workspacePath), {
1273
+ rootDir: workspacePath,
1274
+ staleAfterDays: config.staleAfterDays,
1275
+ wikiPages: readWikiPages(workspacePath),
1276
+ });
1277
+ if (total === 0) return;
1278
+ if (dropped > 0) {
1279
+ // Counted PER NATURE: the ceiling hides evidence of several kinds, and a
1280
+ // single "N source(s)" would be false for the others.
1281
+ const breakdown = [
1282
+ counts.aged ? `${counts.aged} aged` : null,
1283
+ counts.orphan ? `${counts.orphan} orphan page(s)` : null,
1284
+ counts.vanishedArchive ? `${counts.vanishedArchive} vanished archive(s)` : null,
1285
+ counts.vanishedPage ? `${counts.vanishedPage} vanished page(s)` : null,
1286
+ ].filter(Boolean).join(', ');
1287
+ emitRuntimeLog(
1288
+ session,
1289
+ `knowledge-signals: stale knowledge — ${breakdown}; ${dropped} beyond the ceiling are not listed`,
1290
+ );
1291
+ }
1292
+ handleKnowledgeTrigger(context, {
1293
+ workspace,
1294
+ trigger: 'knowledge.stale',
1295
+ sourceVersion: staleFingerprint(stale, total),
1296
+ evidence: { kind: 'stale', counts, items: stale },
1297
+ });
1298
+ }
1299
+
1300
+ /*
1301
+ The live-corpus read, run when an ingest/rebuild completes AND on the
1302
+ runtime's clock. The clock matters for `aged`: it is caused by TIME, and a
1303
+ workspace that stops ingesting — precisely the one whose knowledge ages —
1304
+ would otherwise never re-run the detector. Conflicts and vanished paths are
1305
+ caused by edits, so the ingest moment is enough for them; `staleOnly` keeps
1306
+ the periodic tick from re-walking every leaf it does not need.
1307
+ */
1308
+ function emitCorpusSignals(context, workspace, { staleOnly = false } = {}) {
1309
+ const session = context?.session ?? null;
1310
+ // Stay OFF the corpus until the workspace actually opted in: reading every
1311
+ // leaf synchronously on the event loop that also serves both chats' SSE is
1312
+ // a cost the default (disabled) must not pay.
1313
+ const config = normalizeProactiveConfig(session?.wikircConfig?.proactiveReviews);
1314
+ if (!config.enabled) return;
1315
+ const wantsConflicts = !staleOnly && config.triggers.includes('knowledge.conflict_detected');
1316
+ const wantsStale = config.triggers.includes('knowledge.stale');
1317
+ if (!wantsConflicts && !wantsStale) return;
1318
+ const workspacePath = session?.workspacePath;
1319
+ if (!workspacePath) return;
1320
+ try {
1321
+ if (wantsConflicts) emitConflictSignal(context, workspace, session, workspacePath);
1322
+ if (wantsStale) emitStaleSignal(context, workspace, session, workspacePath, config);
1323
+ } catch (error) {
1324
+ emitRuntimeLog(
1325
+ session,
1326
+ `knowledge-signals: a corpus scan failed — ${error instanceof Error ? error.message : String(error)}`,
1327
+ );
1328
+ }
1329
+ }
1330
+
1085
1331
  function takePrivateControlInput(session, item) {
1086
1332
  const privateControlInputs = privateControlInputsFor(session);
1087
1333
  const input = privateControlInputs.get(item.id) ?? item.input;
@@ -1581,7 +1827,7 @@ function announceControlLaunch(session, input, workspace) {
1581
1827
  Une file est un passage de témoin : ce qui doit survivre au parent voyage avec
1582
1828
  le message, pas dans l'état de celui qui l'a posté.
1583
1829
  */
1584
- function enqueueControlRequest(context, input, { publicInput = null, capabilityPlan, chainId, chainSequence, skillName, skillExecution, skillStack, selectionKind, optional = false, continueOnFailure = false } = {}) {
1830
+ function enqueueControlRequest(context, input, { publicInput = null, capabilityPlan, chainId, chainSequence, skillName, skillExecution, skillStack, selectionKind, optional = false, continueOnFailure = false, proactiveReview = null } = {}) {
1585
1831
  const now = new Date().toISOString();
1586
1832
  const item = {
1587
1833
  id: `control-${randomUUID()}`,
@@ -1598,6 +1844,9 @@ function enqueueControlRequest(context, input, { publicInput = null, capabilityP
1598
1844
  ...(skillExecution ? { skillExecution } : {}),
1599
1845
  ...(Array.isArray(skillStack) && skillStack.length ? { skillStack: [...skillStack] } : {}),
1600
1846
  ...(selectionKind ? { selectionKind } : {}),
1847
+ // The proactive marker rides on the ITEM, so it survives projection and a
1848
+ // runtime restart, and the drain can hand it back to the run it starts.
1849
+ ...(proactiveReview ? { proactiveReview } : {}),
1601
1850
  optional: optional === true,
1602
1851
  continueOnFailure: continueOnFailure === true,
1603
1852
  };
@@ -1789,7 +2038,7 @@ function asksForRunStatus(input) {
1789
2038
  // semantic judgement about the workspace's domain, so it is never a keyword
1790
2039
  // list here — it goes to the model, bounded, and falls back to the choice menu
1791
2040
  // (`ambiguous`) rather than guessing when no model is available.
1792
- async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
2041
+ export async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
1793
2042
  // Caller (the /control message route) already trims and rejects empty input.
1794
2043
  const lower = String(input ?? '').toLowerCase();
1795
2044
  const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
@@ -1823,7 +2072,10 @@ async function classifyControlMessage(input, status, { forcedIntent = null, llm
1823
2072
  // active, the only thing the runtime can act on is a status check: treating
1824
2073
  // the word as ordinary conversation made the read-only chat fallback lecture
1825
2074
  // the user about switching modes instead of answering.
1826
- if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\b/i.test(lower)) {
2075
+ // Anchored at BOTH ends: "oui" is a confirmation, "oui, ajoute une étape de
2076
+ // polish" is a plan change. Without the end anchor this branch shadowed
2077
+ // modify_run and enqueue_run for every message merely STARTING on a yes.
2078
+ if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\s*[.!…]*\s*$/i.test(lower)) {
1827
2079
  return { kind: 'observe', confidence: 0.7, reason: 'confirmation_of_runtime_prompt' };
1828
2080
  }
1829
2081
  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)) {
@@ -1747,6 +1747,7 @@ test('POST /turn hands a run status question to Donna with the runtime facts', a
1747
1747
  };
1748
1748
  let turns = 0;
1749
1749
  let turnInput = '';
1750
+ let turnDisplayInput = '';
1750
1751
  let turnMode = null;
1751
1752
  let handle;
1752
1753
  try {
@@ -1755,7 +1756,13 @@ test('POST /turn hands a run status question to Donna with the runtime facts', a
1755
1756
  store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1756
1757
  getContext: async () => context,
1757
1758
  run: async () => new Promise(() => {}),
1758
- turn: async (_context, options) => { turns += 1; turnInput = options.input; turnMode = options.mode; return { ok: true }; },
1759
+ turn: async (_context, options) => {
1760
+ turns += 1;
1761
+ turnInput = options.input;
1762
+ turnDisplayInput = options.displayInput;
1763
+ turnMode = options.mode;
1764
+ return { ok: true };
1765
+ },
1759
1766
  });
1760
1767
  } catch (err) {
1761
1768
  if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
@@ -1778,6 +1785,11 @@ test('POST /turn hands a run status question to Donna with the runtime facts', a
1778
1785
  assert.equal(turnMode, 'chat');
1779
1786
  assert.match(turnInput, /Build TechSections/);
1780
1787
  assert.match(turnInput, /runtime run, not a production job/i);
1788
+ // …and the THREAD still shows what the reader typed. `executeInteractiveTurn`
1789
+ // persists `displayInput` as the user_message; feeding it the fact block
1790
+ // put a raw English dump in the reader's own bubble and replayed it as
1791
+ // history on every later turn.
1792
+ assert.equal(turnDisplayInput, 'donne le status du job en cours');
1781
1793
  } finally {
1782
1794
  context.currentAbortController?.abort();
1783
1795
  await handle.close();
@@ -25,7 +25,7 @@ export { defaultRuntimeStateDir };
25
25
  // turn's LLM context. A persisted progress note would be re-read by the model
26
26
  // on every later turn as if it were something the user said or Donna answered,
27
27
  // growing the context with commentary about work already finished.
28
- const NON_PERSISTED_EVENT_TYPES = new Set(['runtime_log', 'assistant_progress']);
28
+ const NON_PERSISTED_EVENT_TYPES = new Set(['runtime_log', 'assistant_progress', 'runtime_heartbeat']);
29
29
  export const RUNTIME_STORE_SCHEMA_VERSION = 1;
30
30
  const RUNTIME_RETENTION_DAYS = 30;
31
31
  const TERMINAL_RUN_STATUSES = ['done', 'error', 'cancelled', 'interrupted'];
@@ -770,13 +770,17 @@ test('runtime store backfills sequence for legacy event rows', () => {
770
770
  store.close();
771
771
  });
772
772
 
773
- test('runtime store does not persist runtime logs', () => {
773
+ test('runtime store does not persist runtime logs or liveness beats', () => {
774
774
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
775
775
  const store = openRuntimeStore({ stateDir });
776
776
  store.persistEvent(createAgentEvent('runtime_log', {
777
777
  origin: 'runtime',
778
778
  payload: { message: 'agentic-loop: turn 1/20' },
779
779
  }));
780
+ store.persistEvent(createAgentEvent('runtime_heartbeat', {
781
+ origin: 'runtime_provider',
782
+ payload: { elapsedMs: 30_000 },
783
+ }));
780
784
 
781
785
  assert.equal(store.listEvents().length, 0);
782
786
  store.close();
@@ -51,3 +51,46 @@ export function openExternalUrl(value, { run = execFileSync } = {}) {
51
51
  }
52
52
  return null;
53
53
  }
54
+
55
+ // A Chromium `--app=<url>` window is chromeless from the start — no tab
56
+ // strip, no address bar — and, when the origin serves a manifest (llm-wiki's
57
+ // `/manifest.webmanifest`), it picks up the site's name/icon/theme-color and
58
+ // window-controls-overlay exactly like a formally "installed" PWA, with no
59
+ // prior install step required. `/openui`'s whole point is a desktop-feeling
60
+ // window, so it tries this before falling back to `openExternalUrl`'s plain
61
+ // tab. Same candidate/fallback shape as `openerCandidates` above, and the same
62
+ // contract: returns the opened URL, or null when no Chromium browser answered.
63
+ function appModeCandidates() {
64
+ if (process.platform === 'darwin') {
65
+ return [
66
+ ['open', ['-na', 'Google Chrome', '--args']],
67
+ ['open', ['-na', 'Microsoft Edge', '--args']],
68
+ ];
69
+ }
70
+ if (process.platform === 'win32') {
71
+ return [
72
+ ['cmd', ['/c', 'start', '', 'chrome']],
73
+ ['cmd', ['/c', 'start', '', 'msedge']],
74
+ ];
75
+ }
76
+ return [
77
+ ['google-chrome', []],
78
+ ['chromium', []],
79
+ ['chromium-browser', []],
80
+ ['microsoft-edge', []],
81
+ ];
82
+ }
83
+
84
+ export function openAppWindowUrl(value, { run = execFileSync } = {}) {
85
+ const url = normalizeExternalUrl(value);
86
+ if (!url) return null;
87
+ for (const [command, args] of appModeCandidates()) {
88
+ try {
89
+ run(command, [...args, `--app=${url}`], { stdio: 'ignore', timeout: 5_000 });
90
+ return url;
91
+ } catch {
92
+ // Missing binary or non-zero exit: try the next candidate.
93
+ }
94
+ }
95
+ return null;
96
+ }
package/src/shell/repl.js CHANGED
@@ -94,7 +94,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
94
94
  '/clear': 'Clear the conversation screen.',
95
95
  '/chat': 'Switch free text to direct LLM chat without tools.',
96
96
  '/agent': 'Switch to agent mode, or run one agent request with /agent <question>.',
97
- '/openui': 'Open the workspace web UI in the browser.',
97
+ '/openui': 'Open the workspace web UI as a chromeless desktop window (Chrome/Edge), or a browser tab as fallback.',
98
98
  '/run': 'Inspect, cancel, kill runtime runs, or start a capability run.',
99
99
  '/approve': 'Approve a pending runtime run or tool.',
100
100
  };
package/wiki-workspace CHANGED
@@ -138,7 +138,6 @@ wiki CLI commands reachable through `wiki <workspace> run`:
138
138
  index Create or update the local vector index
139
139
  refresh Regenerate only the stale deliverables
140
140
  lint Static checks: dead links, orphans, stale deliverables
141
- add-skill <source> Install a workspace method (directory, .zip, HTTPS .zip)
142
141
  config Show the effective .wikirc.yaml
143
142
  doctor, ingest, build, export Also available directly: wiki <workspace> <command>
144
143