@dotdrelle/wiki-manager 0.15.98 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.98",
3
+ "version": "0.15.99",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "start": "bun ./bin/wiki-manager.js",
22
- "test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/testGate.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/mcpEndpoints.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikiWorkspaceStart.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/controlClassify.test.js src/runtime/deltaCoalescer.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js src/runtime/totp.test.js src/runtime/loginSession.test.js src/runtime/loginRoutes.test.js",
22
+ "test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/testGate.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/mcpEndpoints.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikiWorkspaceStart.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/proactiveReviewScheduler.test.js src/orchestrator/knowledgeSignals.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/controlClassify.test.js src/runtime/deltaCoalescer.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js src/runtime/totp.test.js src/runtime/loginSession.test.js src/runtime/loginRoutes.test.js",
23
23
  "check-versions": "node scripts/check-versions.js",
24
24
  "prepack": "node scripts/check-versions.js",
25
25
  "prepublishOnly": "node scripts/check-versions.js",
@@ -1905,7 +1905,11 @@ export function createAgentGraph(options = {}) {
1905
1905
  let terminalFailure = null;
1906
1906
  let skillLaunch = null;
1907
1907
 
1908
- for (const call of toolCalls) {
1908
+ // The index is kept so a break can name the calls that never ran: every
1909
+ // iteration pushes a tool result before it continues or completes, so
1910
+ // everything AFTER the break is exactly the unexecuted set.
1911
+ let stoppedAt = toolCalls.length;
1912
+ for (const [callIndex, call] of toolCalls.entries()) {
1909
1913
  const resolved = resolveToolCallName(state.session.mcp, call.function.name, INTERNAL_TOOL_SERVERS);
1910
1914
  const { server, tool } = resolved;
1911
1915
  const argsSummary = summarizeToolArguments(call.function.arguments);
@@ -2193,7 +2197,7 @@ export function createAgentGraph(options = {}) {
2193
2197
  tool_call_id: call.id,
2194
2198
  content: boundedResult,
2195
2199
  });
2196
- if (terminalFailure || skillLaunch) break;
2200
+ if (terminalFailure || skillLaunch) { stoppedAt = callIndex + 1; break; }
2197
2201
  }
2198
2202
 
2199
2203
  // A skill launch owns execution and ends the turn, so the rest of the
@@ -2203,8 +2207,7 @@ export function createAgentGraph(options = {}) {
2203
2207
  // that sees tool_calls without matching results on a replayed history
2204
2208
  // rejects the conversation.
2205
2209
  if (skillLaunch) {
2206
- const executed = new Set(toolResultMessages.map((message) => message.tool_call_id));
2207
- const dropped = toolCalls.filter((call) => !executed.has(call.id));
2210
+ const dropped = toolCalls.slice(stoppedAt);
2208
2211
  const notRun = `Not executed: ${skillLaunch.publicInput} was launched earlier in this `
2209
2212
  + 'turn and owns execution from here. Do not start a second run for the same objective.';
2210
2213
  for (const call of dropped) {
@@ -369,23 +369,14 @@ const runtimeEventSchema = {
369
369
  required: ['type'],
370
370
  additionalProperties: true,
371
371
  properties: {
372
- type: {
373
- type: 'string',
374
- enum: [
375
- 'run_created',
376
- 'run_started',
377
- 'agent_thinking',
378
- 'tool_started',
379
- 'tool_finished',
380
- 'subagent_started',
381
- 'subagent_finished',
382
- 'message',
383
- 'approval_required',
384
- 'run_completed',
385
- 'run_failed',
386
- 'run_cancelled',
387
- ],
388
- },
372
+ // Deliberately OPEN: a gateway newer than the manager emits event types
373
+ // this version has never heard of, and a closed enum here threw inside
374
+ // `normalizeRuntimeEvent` before `runtimeEventAdapter` could decide what to
375
+ // do with them — the provider's catch swallowed the frame, so the whole
376
+ // activity contract was invisible in production while its unit tests
377
+ // passed. The adapter owns the vocabulary and journals an unknown type;
378
+ // the schema must let it see one.
379
+ type: { type: 'string' },
389
380
  runId: { type: 'string' },
390
381
  tool: { type: 'string' },
391
382
  durationMs: { type: 'number' },
@@ -250,3 +250,18 @@ test('capability status contract carries dynamic pending inputs without prescrib
250
250
  assert.equal(validateContract('capabilityStatus', status).ok, true);
251
251
  assert.equal(validateContract('capabilityStatus', { ...status, pendingInputs: [{ type: 'file' }] }).ok, false);
252
252
  });
253
+
254
+ test('the runtime event contract tolerates a type this version does not know', () => {
255
+ // A newer gateway emits types this manager has never heard of. A closed enum
256
+ // made `normalizeRuntimeEvent` throw before the adapter could journal it, so
257
+ // the whole activity contract was silently invisible. The schema must let an
258
+ // unknown type through; deciding what to do with it belongs to the adapter.
259
+ for (const type of ['phase_started', 'progress', 'heartbeat', 'finding', 'degraded', 'notice', 'stream_epoch', 'a_future_type']) {
260
+ assert.equal(
261
+ validateContract('runtimeEvent', { type, runId: 'r1' }).ok,
262
+ true,
263
+ `${type} must be accepted`,
264
+ );
265
+ }
266
+ assert.equal(validateContract('runtimeEvent', { runId: 'r1' }).ok, false, 'a missing type is still refused');
267
+ });
@@ -231,6 +231,10 @@ function createProjectionState() {
231
231
  agents: {},
232
232
  summary: null,
233
233
  status: 'idle',
234
+ // Liveness from the external runtime's heartbeat (lot 2). Display-only:
235
+ // never persisted, never in the conversation.
236
+ lastHeartbeatAt: null,
237
+ lastHeartbeatElapsedMs: 0,
234
238
  };
235
239
  }
236
240
 
@@ -263,6 +267,8 @@ function publicProjection(state) {
263
267
  .sort((a, b) => a.agentInstanceId.localeCompare(b.agentInstanceId)),
264
268
  summary: state.summary,
265
269
  status: state.status,
270
+ lastHeartbeatAt: state.lastHeartbeatAt ?? null,
271
+ lastHeartbeatElapsedMs: state.lastHeartbeatElapsedMs ?? 0,
266
272
  };
267
273
  return {
268
274
  ...projection,
@@ -292,6 +298,21 @@ export function applyAgentProjectionToSession(session, projection) {
292
298
  } : session.productionActivity ?? null;
293
299
  }
294
300
 
301
+ /**
302
+ * Clears the `pending_approval` latch once no approval is outstanding.
303
+ *
304
+ * Called after EVERY approval decision, granted or rejected. Only `granted`
305
+ * used to clear it, so a refusal left the projection reporting
306
+ * `pending_approval` for the rest of the run: both UIs kept asking for a
307
+ * decision already made, while `explainControlState` found nothing pending and
308
+ * answered "run is active". One function so the two verdicts cannot drift.
309
+ */
310
+ function releaseApprovalLatch(state) {
311
+ if (state.status !== 'pending_approval') return;
312
+ if ((state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) return;
313
+ state.status = 'running';
314
+ }
315
+
295
316
  function hasRunningPlanStep(state) {
296
317
  return (Array.isArray(state.plan) ? state.plan : [])
297
318
  .some((step) => isActive(step?.status));
@@ -318,8 +339,17 @@ function applyEvent(state, event) {
318
339
  state.planPatches = [];
319
340
  state.summary = null;
320
341
  state.subagents = [];
342
+ state.lastHeartbeatAt = null;
343
+ state.lastHeartbeatElapsedMs = 0;
321
344
  pruneTerminalControlItems(state.controlQueue);
322
345
  return;
346
+ case 'runtime_heartbeat':
347
+ // Liveness only: the external runtime saying "still working" during a
348
+ // long, tool-less phase. A timestamp the run strip reads; never a
349
+ // conversation entry, never persisted (store.js NON_PERSISTED_EVENT_TYPES).
350
+ state.lastHeartbeatAt = event.ts ?? new Date().toISOString();
351
+ state.lastHeartbeatElapsedMs = Number(event.payload?.elapsedMs) || 0;
352
+ return;
323
353
  case 'user_message':
324
354
  state.conversation.push({ role: 'user', content: String(event.payload?.content ?? '') });
325
355
  return;
@@ -450,6 +480,15 @@ function applyEvent(state, event) {
450
480
  case 'task.failed':
451
481
  appendLog(state, taskLogLine(state, event, 'failed'));
452
482
  return;
483
+ // Stable business facts: a workspace's knowledge changed. They are
484
+ // published for the proactive scheduler and shown as one journal line.
485
+ case 'knowledge.ingested':
486
+ case 'knowledge.rebuilt': {
487
+ const workspace = String(event.payload?.workspace ?? 'workspace');
488
+ const version = String(event.payload?.sourceVersion ?? 'unknown version');
489
+ appendLog(state, `${logTime(event.ts)} ${event.type} — ${workspace} (${version})`.trim());
490
+ return;
491
+ }
453
492
  case 'plan.revision_changed':
454
493
  if (Array.isArray(event.payload?.tasks)) {
455
494
  state.plan = normalizePlan(event.payload.tasks, { owner: 'orchestrator', planRevision: state.planRevision });
@@ -629,13 +668,7 @@ function applyEvent(state, event) {
629
668
  };
630
669
  upsertApproval(state, grant);
631
670
  markCoveredApprovalsApproved(state.approvals, grant, event.ts);
632
- // The decision is in: the run goes back to running unless another
633
- // approval is still outstanding (a run-scoped grant clears its covered
634
- // ones, markCoveredApprovalsApproved above).
635
- if (state.status === 'pending_approval'
636
- && !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
637
- state.status = 'running';
638
- }
671
+ releaseApprovalLatch(state);
639
672
  return;
640
673
  }
641
674
  case 'approval.rejected':
@@ -654,17 +687,7 @@ function applyEvent(state, event) {
654
687
  reason: event.payload?.reason ?? null,
655
688
  rejectedAt: event.ts,
656
689
  });
657
- // Same latch release as `approval.granted`: a refusal IS a decision. Only
658
- // `granted` cleared the status, so a rejected approval left the
659
- // projection reporting `pending_approval` for the rest of the run —
660
- // both UIs kept asking for a decision the user had already made, while
661
- // `explainControlState` found no pending approval and answered "run is
662
- // active". The two surfaces disagreed until some later run_done
663
- // overwrote it.
664
- if (state.status === 'pending_approval'
665
- && !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
666
- state.status = 'running';
667
- }
690
+ releaseApprovalLatch(state);
668
691
  return;
669
692
  case 'run_done':
670
693
  state.status = 'done';
@@ -731,6 +754,10 @@ function applyEvent(state, event) {
731
754
  : {}),
732
755
  ...(event.payload?.selectionKind ? { selectionKind: event.payload.selectionKind } : {}),
733
756
  ...(Number.isInteger(event.payload?.chainSequence) ? { chainSequence: event.payload.chainSequence } : {}),
757
+ // A proactive review's identity must survive projection and replay: the
758
+ // drain hands it back to the run it starts, which is what lets the
759
+ // result be filed as a review rather than lost as an anonymous audit.
760
+ ...(event.payload?.proactiveReview ? { proactiveReview: event.payload.proactiveReview } : {}),
734
761
  optional: event.payload?.optional === true,
735
762
  continueOnFailure: event.payload?.continueOnFailure === true,
736
763
  });
@@ -899,3 +899,43 @@ test('subagent_started/finished track the collective timeline, reset per run', (
899
899
  dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime', runId: 'r2', payload: {} }));
900
900
  assert.equal(session.agentProjection.subagents.length, 0, 'a new run starts a fresh timeline');
901
901
  });
902
+
903
+ test('a runtime heartbeat sets liveness only, and a new run clears it', () => {
904
+ const session = {};
905
+ dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime', runId: 'r1', payload: {} }));
906
+ dispatchAgentEvent(session, createAgentEvent('runtime_heartbeat', {
907
+ origin: 'runtime_provider', runId: 'r1', payload: { elapsedMs: 30_000 },
908
+ }));
909
+
910
+ assert.ok(session.agentProjection.lastHeartbeatAt, 'the beat is visible to the strip');
911
+ assert.equal(session.agentProjection.lastHeartbeatElapsedMs, 30_000);
912
+ // A heartbeat is not an event the conversation projection can seed from.
913
+ assert.equal(session.agentProjection.conversation.length, 0);
914
+
915
+ dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime', runId: 'r2', payload: {} }));
916
+ assert.equal(session.agentProjection.lastHeartbeatAt, null, 'a new run starts with no stale beat');
917
+ });
918
+
919
+ test('a queued control item keeps its proactive-review marker across projection', () => {
920
+ const session = {};
921
+ const marker = { id: 'review-1', workspace: 'docs', trigger: 'knowledge.ingested', sourceVersion: 'v1' };
922
+ dispatchAgentEvent(session, createAgentEvent('control_enqueued', {
923
+ origin: 'runtime',
924
+ workspace: 'docs',
925
+ payload: { id: 'control-1', workspace: 'docs', input: 'audit the workspace', proactiveReview: marker },
926
+ }));
927
+
928
+ assert.deepEqual(session.agentProjection.controlQueue[0].proactiveReview, marker);
929
+ });
930
+
931
+ test('streamed deltas are replaced by the final message, never duplicated', () => {
932
+ const session = {};
933
+ dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Hello ' } }));
934
+ dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'world' } }));
935
+ assert.equal(session.agentProjection.conversation.at(-1).content, 'Hello world');
936
+
937
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'Hello world' } }));
938
+ assert.equal(session.agentProjection.conversation.length, 1, 'the final message replaces the streamed one');
939
+ assert.equal(session.agentProjection.conversation[0].content, 'Hello world');
940
+ assert.ok(!session.agentProjection.conversation[0].streaming);
941
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.98",
3
- "commit": "acf1986"
2
+ "version": "0.15.99",
3
+ "commit": "8a91b62"
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.98';
4
+ const WIKI_MANAGER_VERSION = '0.15.99';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -23,6 +23,19 @@ export function mapRuntimeEvent(event) {
23
23
  const content = String(event?.content ?? event?.message ?? '').trim();
24
24
  return content ? [{ type: 'assistant_message', payload: { content } }] : [];
25
25
  }
26
+ // Progressive final stream (lot 7). The gateway only streams the MAIN
27
+ // assembly, never a role, so a delta can only belong to the answer. The
28
+ // reducer replaces the streamed text with the final `assistant_message`
29
+ // (finalizeAssistantMessage), so streaming cannot duplicate it.
30
+ case 'assistant_delta': {
31
+ const delta = String(event?.delta ?? '');
32
+ return delta ? [{ type: 'assistant_delta', payload: { delta } }] : [];
33
+ }
34
+ // A tool call interrupted the streamed answer: the text so far was
35
+ // reasoning, not the answer — discard it (the reducer empties, never pops,
36
+ // the streaming entry).
37
+ case 'assistant_delta_reset':
38
+ return [{ type: 'assistant_delta_reset', payload: {} }];
26
39
  case 'tool_started':
27
40
  return log(`tool ${toolLabel(event)} started`);
28
41
  case 'tool_finished': {
@@ -60,17 +73,102 @@ export function mapRuntimeEvent(event) {
60
73
  },
61
74
  }];
62
75
  }
76
+ // ── Activity (lot 2) ────────────────────────────────────────────────────
77
+ //
78
+ // The runtime's phases enrich the EXISTING business activity line; they do
79
+ // not open a second axis of "phases" beside `projectWorkflow`. That is why
80
+ // they travel as runtime_log here and are aggregated downstream, rather
81
+ // than minting a new event type the reducer would have to reconcile.
82
+ case 'phase_started':
83
+ return log(`phase ${phaseLabel(event)} started`);
84
+ case 'phase_finished': {
85
+ const counters = phaseCounters(event);
86
+ const outcome = event?.ok === false ? 'interrupted' : 'done';
87
+ return log(`phase ${phaseLabel(event)} ${outcome}${counters}`);
88
+ }
89
+ case 'progress': {
90
+ const label = String(event?.label ?? event?.phase ?? '').trim();
91
+ return label ? log(`progress ${label}${phaseCounters(event)}`) : [];
92
+ }
93
+ // A heartbeat is liveness, not history: it proves the run is alive to
94
+ // whoever is watching right now. It travels as its own NON-persisted event
95
+ // so the run strip can read it, but it never reaches the journal — one
96
+ // line per beat would bury what actually happened. Persisting is the
97
+ // store's decision (NON_PERSISTED_EVENT_TYPES); dropping it here would
98
+ // leave the strip with no liveness signal at all.
99
+ case 'heartbeat':
100
+ return [{
101
+ type: 'runtime_heartbeat',
102
+ payload: { elapsedMs: Number(event?.elapsedMs) || 0 },
103
+ }];
104
+ case 'finding': {
105
+ const severity = String(event?.severity ?? '').trim();
106
+ const summary = String(event?.summary ?? '').trim();
107
+ if (!summary) return [];
108
+ const path = String(event?.path ?? '').trim();
109
+ const where = path ? ` at ${path}` : '';
110
+ return log(`finding${severity ? ` [${severity}]` : ''} from ${String(event?.role ?? 'runtime')}${where}: ${summary}`);
111
+ }
112
+ // Maintenance the gateway performed on its own memory (eviction of an
113
+ // inactive workspace, compaction of a thread). Not a failure — a notice,
114
+ // so a reader can tell "the agent forgot an old workspace" from "the
115
+ // agent broke".
116
+ case 'notice': {
117
+ const topic = String(event?.topic ?? 'notice').trim();
118
+ const detail = String(event?.detail ?? '').trim();
119
+ return log(`notice ${topic}${detail ? `: ${detail}` : ''}`);
120
+ }
121
+ // A degradation must announce itself — that is the whole contract. It is
122
+ // never filtered, whatever else this adapter decides to keep quiet.
123
+ case 'degraded': {
124
+ const capability = String(event?.capability ?? 'capability').trim();
125
+ const cause = String(event?.cause ?? 'unknown cause').trim();
126
+ const fallback = String(event?.fallback ?? '').trim();
127
+ return log(`degraded ${capability}: ${cause}${fallback ? ` — ${fallback}` : ''}`);
128
+ }
63
129
  case 'run_started':
64
130
  case 'run_created':
65
131
  case 'agent_thinking':
66
132
  case 'run_completed':
67
133
  case 'run_failed':
68
134
  case 'run_cancelled':
69
- default:
135
+ // Deliberately silent, and listed BY NAME so the silence is a decision
136
+ // rather than a default: `agent_thinking` is private reasoning the chat
137
+ // never shows, and the terminal events are already carried by the
138
+ // dispatcher's own `status()` poll.
70
139
  return [];
140
+ default:
141
+ // Everything else is a type this manager does not know — most likely a
142
+ // newer gateway talking to an older manager. Dropping it made that
143
+ // version skew invisible: the events simply never arrived, and nothing
144
+ // said so. One bounded line is the cost of knowing.
145
+ return log(`unrecognized runtime event "${type || 'unnamed'}"${unknownDetail(event)}`);
71
146
  }
72
147
  }
73
148
 
149
+ function phaseLabel(event) {
150
+ return String(event?.phase ?? event?.label ?? 'unnamed');
151
+ }
152
+
153
+ function phaseCounters(event) {
154
+ const parts = [];
155
+ const tools = Number(event?.tools);
156
+ const pages = Number(event?.pages);
157
+ if (Number.isFinite(tools) && tools > 0) parts.push(`${tools} tool(s)`);
158
+ if (Number.isFinite(pages) && pages > 0) parts.push(`${pages} page(s) read`);
159
+ return parts.length > 0 ? ` — ${parts.join(', ')}` : '';
160
+ }
161
+
162
+ // Bounded on purpose: this is a diagnostic breadcrumb for a version skew, not
163
+ // a channel for an unknown payload to reach the journal whole.
164
+ const UNKNOWN_EVENT_DETAIL_MAX = 200;
165
+ function unknownDetail(event) {
166
+ const keys = Object.keys(event ?? {})
167
+ .filter((key) => !['type', 'runId', 'ts', 'sequence'].includes(key));
168
+ if (keys.length === 0) return '';
169
+ return ` (fields: ${keys.join(', ')})`.slice(0, UNKNOWN_EVENT_DETAIL_MAX);
170
+ }
171
+
74
172
  function log(message) {
75
173
  return [{ type: 'runtime_log', payload: { message } }];
76
174
  }
@@ -61,6 +61,96 @@ test('private reasoning and terminal events are never re-emitted', () => {
61
61
  assert.deepEqual(mapRuntimeEvent({ type: 'run_cancelled' }), []);
62
62
  });
63
63
 
64
- test('an unknown event type produces nothing', () => {
65
- assert.deepEqual(mapRuntimeEvent({ type: 'made_up' }), []);
64
+ // (An unknown type used to produce nothing. It now produces one journal line —
65
+ // see "an unknown event type is journalled instead of vanishing" below. The
66
+ // old assertion pinned the silence that hid a version skew.)
67
+
68
+ // ── Activity events (lot 2) ──────────────────────────────────────────────────
69
+
70
+ test('phase and progress events enrich the journal with bounded counters', () => {
71
+ assert.deepEqual(
72
+ mapRuntimeEvent({ type: 'phase_started', phase: 'discover' }),
73
+ [{ type: 'runtime_log', payload: { message: 'phase discover started' } }],
74
+ );
75
+ const [finished] = mapRuntimeEvent({
76
+ type: 'phase_finished', phase: 'discover', ok: true, tools: 7, pages: 4,
77
+ });
78
+ assert.match(finished.payload.message, /phase discover done — 7 tool\(s\), 4 page\(s\) read/);
79
+
80
+ const [interrupted] = mapRuntimeEvent({ type: 'phase_finished', phase: 'critique', ok: false });
81
+ assert.match(interrupted.payload.message, /phase critique interrupted/);
82
+ });
83
+
84
+ // A beat proves the run is alive to whoever watches NOW. It still travels — as
85
+ // its own non-persisted event, so the run strip can read it — but one journal
86
+ // line per beat would bury what actually happened under "still alive".
87
+ test('a heartbeat becomes a non-persisted liveness event, not a journal line', () => {
88
+ assert.deepEqual(
89
+ mapRuntimeEvent({ type: 'heartbeat', elapsedMs: 30_000 }),
90
+ [{ type: 'runtime_heartbeat', payload: { elapsedMs: 30_000 } }],
91
+ );
92
+ });
93
+
94
+ test('a finding carries its severity, its author and its path', () => {
95
+ const [entry] = mapRuntimeEvent({
96
+ type: 'finding',
97
+ role: 'critique',
98
+ severity: 'blocking',
99
+ path: 'wiki/concepts/demo/a.md',
100
+ summary: 'cites no source',
101
+ });
102
+ assert.match(
103
+ entry.payload.message,
104
+ /finding \[blocking\] from critique at wiki\/concepts\/demo\/a\.md: cites no source/,
105
+ );
106
+ });
107
+
108
+ test('a degradation is never filtered', () => {
109
+ const [entry] = mapRuntimeEvent({
110
+ type: 'degraded',
111
+ capability: 'role:critique',
112
+ cause: 'model timeout',
113
+ fallback: 'the run continues without this role',
114
+ });
115
+ assert.match(entry.payload.message, /degraded role:critique: model timeout — the run continues/);
116
+ });
117
+
118
+ /*
119
+ The version-skew guard. A newer gateway talking to an older manager used to
120
+ lose EVERY new event here, silently — the adapter ended on `default: return []`.
121
+ The deliberate silences stay silent, but they are now listed by name, so the
122
+ difference between "we chose not to show this" and "we did not recognise it"
123
+ is visible in the journal instead of being the same thing.
124
+ */
125
+ test('an unknown event type is journalled instead of vanishing', () => {
126
+ const [entry] = mapRuntimeEvent({ type: 'sub_phase_started', detail: 'x', weight: 2 });
127
+ assert.equal(entry.type, 'runtime_log');
128
+ assert.match(entry.payload.message, /unrecognized runtime event "sub_phase_started"/);
129
+ assert.match(entry.payload.message, /fields: detail, weight/);
130
+ });
131
+
132
+ test('the deliberate silences stay silent', () => {
133
+ for (const type of ['agent_thinking', 'run_started', 'run_completed', 'run_failed', 'run_cancelled']) {
134
+ assert.deepEqual(mapRuntimeEvent({ type }), [], `${type} must stay silent`);
135
+ }
136
+ });
137
+
138
+ test('a memory notice is journalled as maintenance, not as a failure', () => {
139
+ const [entry] = mapRuntimeEvent({
140
+ type: 'notice', topic: 'memory.evicted', detail: 'old-workspace',
141
+ });
142
+ assert.equal(entry.type, 'runtime_log');
143
+ assert.match(entry.payload.message, /^notice memory\.evicted: old-workspace$/);
144
+ });
145
+
146
+ test('the final stream maps as deltas, and a reset clears them', () => {
147
+ assert.deepEqual(
148
+ mapRuntimeEvent({ type: 'assistant_delta', delta: 'Hi' }),
149
+ [{ type: 'assistant_delta', payload: { delta: 'Hi' } }],
150
+ );
151
+ assert.deepEqual(
152
+ mapRuntimeEvent({ type: 'assistant_delta_reset' }),
153
+ [{ type: 'assistant_delta_reset', payload: {} }],
154
+ );
155
+ assert.deepEqual(mapRuntimeEvent({ type: 'assistant_delta', delta: '' }), []);
66
156
  });
@@ -31,7 +31,7 @@ test('validation rejects technical routing details', () => {
31
31
  });
32
32
 
33
33
  test('every shipped scaffold skill compiles to a single intention, deterministically', async () => {
34
- const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-rebuild': 1 };
34
+ const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-rebuild': 1, curate: 1 };
35
35
  // Passing no llmFallback used to make this test assert the one path
36
36
  // production never takes: an ambiguous body silently returns the safe
37
37
  // mono-intention fallback, so the count was 1 and the test was green while
@@ -1,3 +1,5 @@
1
+ import { truncateToolResult } from './mcp.js';
2
+
1
3
  // Minimal, side-effect-free bounded tool-use loop.
2
4
  //
3
5
  // This is the shared mechanic of "ask the LLM with a tool set, run the tool
@@ -79,9 +81,13 @@ export async function runBoundedToolLoop({
79
81
  convo.push(result.message ?? { role: 'assistant', content: result.content ?? '', tool_calls: calls });
80
82
  // Tool calls within one turn are independent: dispatch concurrently, then
81
83
  // replay results in the model's call order so the transcript stays stable.
84
+ // Bound what enters the LLM context, exactly like the /agent loop
85
+ // (graph.js). Without it a wide read — a CME Confluence search at limit 50
86
+ // can weigh ~35 kB — is re-sent on every iteration (up to the cap), and the
87
+ // chat answer pays for tokens the model never needed.
82
88
  const outcomes = await Promise.all(calls.map(async (call) => ({
83
89
  tool_call_id: call.id,
84
- content: await executeCall(call),
90
+ content: truncateToolResult(await executeCall(call)),
85
91
  })));
86
92
  for (const outcome of outcomes) {
87
93
  convo.push({ role: 'tool', tool_call_id: outcome.tool_call_id, content: outcome.content });
@@ -104,6 +104,34 @@ test('answers from the gathered results when the cap is reached', async () => {
104
104
  assert.equal(out.content, "Voici ce que j'ai trouvé.");
105
105
  });
106
106
 
107
+ test('bounds a wide tool result before it enters the LLM context', async () => {
108
+ // A CME Confluence search at limit 50 can weigh ~35 kB and would otherwise be
109
+ // re-sent on every iteration. The /agent loop already truncates at 16 kB
110
+ // (graph.js); the chat loop must not be the one unbounded path.
111
+ let round = 0;
112
+ let toolContent = '';
113
+ const llm = {
114
+ async completeWithTools({ messages }) {
115
+ round += 1;
116
+ if (round === 1) {
117
+ const calls = [toolCall('c1', 'cme__cme_confluence_search')];
118
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
119
+ }
120
+ toolContent = messages.find((m) => m.role === 'tool')?.content ?? '';
121
+ return { content: 'ok', tool_calls: [] };
122
+ },
123
+ };
124
+ const wide = 'x'.repeat(50000);
125
+ await runBoundedToolLoop({
126
+ llm,
127
+ tools: [{ function: { name: 'cme__cme_confluence_search' } }],
128
+ executeCall: async () => wide,
129
+ });
130
+ assert.ok(toolContent.length < wide.length, 'the result must be bounded');
131
+ assert.ok(toolContent.length <= 16200, `bounded length was ${toolContent.length}`);
132
+ assert.match(toolContent, /tronqu/);
133
+ });
134
+
107
135
  test('propagates an abort thrown by executeCall', async () => {
108
136
  const llm = {
109
137
  async completeWithTools() {
@@ -255,6 +255,7 @@ async function executeExternalRuntime(task, assignment, {
255
255
  capability: task.requiredCapability ?? null,
256
256
  arguments: task.arguments && typeof task.arguments === 'object' ? task.arguments : {},
257
257
  workspace: workspaceRequest(session),
258
+ memoryScope: memoryScopeRequest(session),
258
259
  model: activeProfileModel(session),
259
260
  language: session?.language ?? session?.wikircConfig?.language ?? null,
260
261
  mcp: mcpPool,
@@ -471,6 +472,24 @@ function executeRequest(task, session, runId, assignment) {
471
472
  };
472
473
  }
473
474
 
475
+ /**
476
+ * Which past conversation this run resumes, on the external runtime.
477
+ *
478
+ * The workspace alone today. The multi-user lot turns this into
479
+ * `<workspace>:<actorId>` — the shape is already the one the gateway accepts,
480
+ * so identity lands here and nowhere else. Returning null is legitimate and
481
+ * silent: the gateway then scopes to the workspace it resolved itself.
482
+ *
483
+ * Never a value a caller supplied: the runtime treats the scope as a read
484
+ * capability and refuses one that leaves its own workspace.
485
+ */
486
+ function memoryScopeRequest(session) {
487
+ const actorId = session?._currentRunIdentity?.actorId ?? session?.actorId ?? null;
488
+ if (!actorId) return null;
489
+ const workspace = workspaceRequest(session)?.name;
490
+ return workspace ? `${workspace}:${String(actorId)}` : null;
491
+ }
492
+
474
493
  function workspaceRequest(session) {
475
494
  const workspace = session.workspace ?? session._currentRunIdentity?.workspace;
476
495
  if (workspace && typeof workspace === 'object' && !Array.isArray(workspace)) return { ...workspace };