@dotdrelle/wiki-manager 0.15.94 → 0.15.97

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/README.md CHANGED
@@ -121,47 +121,60 @@ in isolated workspaces.
121
121
 
122
122
  ## How wikiLLM compares
123
123
 
124
- Several open projects now build a Markdown wiki with an LLM. They target
124
+ Several open projects now build an LLM-maintained knowledge layer over your
125
+ sources — some as a wiki, some as a retrieval and answer platform. They target
125
126
  **different problems** — the useful questions are *what goes in, what comes out,
126
- and who operates it*. Snapshot as of 2026; all of these move quickly.
127
+ and who operates it*. Snapshot as of **September 2026**; all of these move quickly.
127
128
 
128
129
  ✅ first-class · 🟡 partial or indirect · ❌ not a goal
129
130
 
130
- Projects compared: [OpenWiki](https://github.com/langchain-ai/openwiki),
131
- [DeepWiki-Open](https://github.com/asyncfuncai/deepwiki-open),
131
+ Projects compared: [OpenWiki](https://github.com/langchain-ai/openwiki) and
132
+ [DeepWiki-Open](https://github.com/asyncfuncai/deepwiki-open) (code / knowledge
133
+ wikis), [RAGFlow](https://github.com/infiniflow/ragflow) and
134
+ [Onyx](https://github.com/onyx-dot-app/onyx) (RAG platforms), and
132
135
  [GraphRAG](https://github.com/microsoft/graphrag).
133
136
 
134
137
  <table>
135
138
  <thead>
136
- <tr><th><small>Need</small></th><th><small><strong>wikiLLM</strong></small></th><th><small><strong>OpenWiki</strong></small></th><th><small><strong>DeepWiki-Open</strong></small></th><th><small><strong>GraphRAG</strong></small></th></tr>
139
+ <tr>
140
+ <th><small>Need</small></th>
141
+ <th><small><strong>wikiLLM</strong></small></th>
142
+ <th><small><strong>Code / knowledge wikis</strong><br>OpenWiki · DeepWiki-Open</small></th>
143
+ <th><small><strong>RAG platforms</strong><br>RAGFlow · Onyx</small></th>
144
+ <th><small><strong>Graph pipeline</strong><br>GraphRAG</small></th>
145
+ </tr>
137
146
  </thead>
138
147
  <tbody>
139
- <tr><td><small>Input</small></td><td><small>✅ Business docs — Confluence, PDF, Office, SaaS</small></td><td><small>✅ Codebase (code mode)</small></td><td><small>✅ Code repo → diagrams</small></td><td><small>🟡 Plain-text corpus only</small></td></tr>
140
- <tr><td><small>Output</small></td><td><small>✅ Maintained wiki <strong>+ deliverables</strong> from your templates</small></td><td><small>🟡 Wiki about the code, for agents</small></td><td><small>🟡 Interactive wiki + diagrams</small></td><td><small>❌ Entity graph + summaries — no wiki</small></td></tr>
141
- <tr><td><small>Keep current</small></td><td><small>✅ Re-ingest / scheduled</small></td><td><small>✅ <code>--update</code>, CI action</small></td><td><small>🟡 Regenerated per run</small></td><td><small>✅ <code>graphrag update</code> (delta)</small></td></tr>
142
- <tr><td><small>Evidence &amp; citations</small></td><td><small>🟡 Cites retrieved context</small></td><td><small>✅ Claims tied to versioned source</small></td><td><small>🟡 RAG-cited answers</small></td><td><small>✅ Citations to text units</small></td></tr>
143
- <tr><td><small>Corpus-wide Q&amp;A</small></td><td><small>🟡 BM25 + vector feeding generation</small></td><td><small>❌</small></td><td><small>🟡 Repo-scoped RAG chat</small></td><td><small>✅ Local/global community search</small></td></tr>
144
- <tr><td><small>Team UI</small></td><td><small>✅ Web console — wiki, graph, chat, runs (single-user today)</small></td><td><small>🟡 Local viewer + CLI chat</small></td><td><small>✅ Self-hosted web app + RAG</small></td><td><small>❌ Library / CLI</small></td></tr>
145
- <tr><td><small>Multi-project isolation</small></td><td><small>✅ Workspaces, own services, ports, secrets</small></td><td><small>❌ One wiki per run</small></td><td><small>❌ One wiki per repo</small></td><td><small>❌ One index per corpus</small></td></tr>
146
- <tr><td><small>Orchestration &amp; governance</small></td><td><small>✅ Approval-gated dispatcher, budgets, idempotent writes, crash recovery</small></td><td><small>❌ One agent loop</small></td><td><small>❌ One generation pipeline</small></td><td><small>❌ Indexing pipeline</small></td></tr>
147
- <tr><td><small>Connectors as services</small></td><td><small>✅ Independent MCP agents (Confluence, docs, e-mail…)</small></td><td><small>🟡 Built-in connector set</small></td><td><small>❌</small></td><td><small>❌</small></td></tr>
148
- <tr><td><small>Offline / local models</small></td><td><small>✅ Per-workspace OpenAI-compatible or gateway (Ollama, vLLM, MLX…)</small></td><td><small>✅ 13+ providers</small></td><td><small>✅ Ollama</small></td><td><small>✅ Any OpenAI-compatible</small></td></tr>
149
- <tr><td><small>License</small></td><td><small>❌ PolyForm <strong>Noncommercial</strong></small></td><td><small>✅ MIT</small></td><td><small>✅ MIT</small></td><td><small>✅ MIT</small></td></tr>
148
+ <tr><td><small>Input</small></td><td><small>✅ Business docs — Confluence, PDF, Office, SaaS</small></td><td><small>✅ Code repo; OpenWiki also personal sources (Notion, Gmail, Slack…)</small></td><td><small>✅ PDF/Office/images, Confluence, S3, 50+ connectors</small></td><td><small>🟡 Plain text</small></td></tr>
149
+ <tr><td><small>Output</small></td><td><small>✅ Maintained wiki <strong>+ deliverables</strong></small></td><td><small>✅ Linked Markdown wiki (+ graph / diagrams)</small></td><td><small>🟡 Grounded RAG answers, not a wiki</small></td><td><small>❌ Entity graph + summaries</small></td></tr>
150
+ <tr><td><small>Agents &amp; actions</small></td><td><small>✅ Capability dispatcher, external agentic runtime, MCP tools</small></td><td><small>✅ Doc-generation agents, MCP connectors</small></td><td><small>✅ Agentic workflow, MCP actions, deep research</small></td><td><small>❌ Indexing pipeline</small></td></tr>
151
+ <tr><td><small>Keep current</small></td><td><small>✅ Re-ingest / scheduled</small></td><td><small>✅ <code>--update</code> / regenerate, CI</small></td><td><small>✅ Scheduled connector sync</small></td><td><small>🟡 Maintenance mode</small></td></tr>
152
+ <tr><td><small>Team UI</small></td><td><small>✅ Web console — wiki, graph, chat, runs</small></td><td><small>🟡 CLI + visualizer; DeepWiki-Open web app</small></td><td><small>✅ Self-hosted web app</small></td><td><small>❌ Library / CLI</small></td></tr>
153
+ <tr><td><small>Multi-project isolation</small></td><td><small>✅ Workspaces, own services, ports, secrets</small></td><td><small>❌ One wiki per repo / directory</small></td><td><small>🟡 Datasets / teams, not isolated services</small></td><td><small>❌ One index per corpus</small></td></tr>
154
+ <tr><td><small>Human approval &amp; governance</small></td><td><small>✅ Approval-gated dispatcher, budgets, crash recovery</small></td><td><small>❌ PR review only</small></td><td><small>🟡 RBAC / SSO (Onyx enterprise)</small></td><td><small>❌</small></td></tr>
155
+ <tr><td><small>License</small></td><td><small>❌ PolyForm <strong>Noncommercial</strong></small></td><td><small>✅ MIT</small></td><td><small>✅ Apache-2.0 / MIT CE</small></td><td><small>✅ MIT</small></td></tr>
150
156
  </tbody>
151
157
  </table>
152
158
 
159
+ <small>Per-project details: OpenWiki emits OKF v0.2 and maintains a versioned
160
+ evidence graph of “grounded claims”; GraphRAG is a research project now in
161
+ maintenance mode; Onyx’s SSO/RBAC live in its enterprise edition.</small>
162
+
153
163
  **The short version:**
154
164
 
155
- - **OpenWiki** and **DeepWiki-Open** document *source code*. Point wikiLLM at a
156
- repository and there is nothing for it to ingest; point either of them at a
157
- stack of Confluence pages and a Word document and that is not their job.
158
- - **GraphRAG** builds *retrieval structure*, not a wiki you read or deliverables
159
- you ship — it is a strong back end for corpus-wide Q&A, and complementary
160
- rather than competing.
161
- - **wikiLLM** is the only one of the four whose output is *both* a browsable wiki
162
- *and* regenerated business documents, and the only one with the operational
163
- layer — isolated projects, bounded approvals, automatic recovery, a web
164
- console — that a shared internal tool needs.
165
+ - **OpenWiki** and **DeepWiki-Open** build wikis from a **code repository**
166
+ (OpenWiki also ingests personal sources — Notion, Gmail, Slack…). Their input
167
+ is code or personal feeds, not a stack of business documents feeding your
168
+ deliverable templates.
169
+ - **RAGFlow** and **Onyx** are RAG platforms: they index sources and answer
170
+ questions from them, with agents and connectors, but the output is an answer,
171
+ not a wiki you browse or a document you ship.
172
+ - **GraphRAG** builds retrieval structure and is now in maintenance mode — a
173
+ complementary back end, not a wiki.
174
+ - **wikiLLM** is the only one whose output is *both* a browsable wiki *and*
175
+ regenerated business documents, and the only one with the operational layer —
176
+ isolated projects, bounded approvals, automatic recovery, a web console —
177
+ that a shared internal tool needs.
165
178
 
166
179
  **What wikiLLM does *not* try to do (today):**
167
180
 
@@ -170,8 +183,6 @@ Projects compared: [OpenWiki](https://github.com/langchain-ai/openwiki),
170
183
  - Serve a true multi-user instance with per-user identity and an attributed
171
184
  audit trail. This is a single-user deployment baseline (see the scope note
172
185
  above).
173
- - Expose a graph-query API over the corpus the way GraphRAG does; retrieval is
174
- BM25 plus a vector index feeding generation.
175
186
  - Ship or host the multi-provider AI gateway — routing to several providers is
176
187
  supported, the gateway itself is infrastructure you bring.
177
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.94",
3
+ "version": "0.15.97",
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/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/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/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/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/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/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",
@@ -31,6 +31,7 @@ import { artifactFromToolCall, currentArtifactFor, currentArtifactPromptLine, re
31
31
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
32
32
  import { objectiveForResolution } from '../orchestrator/objectiveResolver.js';
33
33
  import { fetchRuntimeState, postRuntimeCancel, postRuntimeControl, postRuntimeDelegate, postRuntimeKill, postRuntimeSkill } from '../runtime/client.js';
34
+ import { formatPublicSkillInvocation, generateSkillAcknowledgment } from '../runtime/skillRun.js';
34
35
 
35
36
  const MAX_TOOL_ITERATIONS = 80;
36
37
  /**
@@ -435,6 +436,19 @@ export function bareToolCallJson(content, tools = []) {
435
436
  return hasArguments ? name : null;
436
437
  }
437
438
 
439
+ /**
440
+ * A model that has NO tool to call sometimes writes the call as text:
441
+ * `runtime__delegate{"objective":"…"}`. Unlike `bareToolCallJson` it is not
442
+ * JSON and cannot be validated against the offered set (there is none), but the
443
+ * `<namespace>__<tool>{` shape is never legitimate prose. Observed after a
444
+ * terminal failure stripped the tools from the synthesis turn: the raw call
445
+ * reached the user and nothing ran.
446
+ */
447
+ export function narratedToolCallText(content) {
448
+ const match = String(content ?? '').trim().match(/^([a-z][a-z0-9_-]*__[a-z][a-z0-9_-]*)\s*\{/i);
449
+ return match ? match[1] : null;
450
+ }
451
+
438
452
  function parseActionJson(text) {
439
453
  const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
440
454
  if (!cleaned) return null;
@@ -978,7 +992,7 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
978
992
  }
979
993
  if (tool === 'run_skill') {
980
994
  const skillName = String(args.skillName ?? '').trim();
981
- if (!skillName) return JSON.stringify({ ok: false, terminal: true, code: 'skill_not_found', availableSkills: [] });
995
+ if (!skillName) return JSON.stringify({ ok: false, terminal: false, code: 'skill_not_found', message: 'skillName is required: pass the exact skill name, or delegate the objective with runtime__delegate.', availableSkills: [] });
982
996
  const selectedSkill = findSkill(session, skillName);
983
997
  const suppliedArguments = args.arguments && typeof args.arguments === 'object' && !Array.isArray(args.arguments)
984
998
  ? args.arguments
@@ -1377,24 +1391,6 @@ export function buildAgentSystemPrompt(state) {
1377
1391
  return customPrompt ? `${customPrompt}\n\n${agentContext}` : agentContext;
1378
1392
  }
1379
1393
 
1380
- export function buildLimitedAgentResponse(state, reason = 'no workspace loaded with .wikirc.yaml') {
1381
- const workspace = state.session.workspace ?? 'no workspace selected';
1382
- const wikirc = state.session.wikirc?.profile ?? 'no profile loaded';
1383
- return [
1384
- `Donna is active. Current workspace: ${workspace}.`,
1385
- `Current wikirc profile: ${wikirc}.`,
1386
- '',
1387
- 'I am the shell agent mode: use `/agent` to route free text through this LangGraph graph, or `/chat` for direct chat.',
1388
- `LLM connection: limited mode (${reason}).`,
1389
- `Available primitives: ${commandList(state.session)}.`,
1390
- '',
1391
- 'Connected MCP tools:',
1392
- formatMcpToolsForAgent(state.session.mcp),
1393
- '',
1394
- 'Limited mode: workspace, Docker Compose tools, MCP calls, /wiki fallback, skill discovery, and headless mode are wired.',
1395
- 'Use `/help` to see deterministic shell commands.',
1396
- ].join('\n');
1397
- }
1398
1394
 
1399
1395
  export function formatLlmUnavailableMessage(reason) {
1400
1396
  const clean = String(reason ?? 'unknown reason').replace(/\s+/g, ' ').trim();
@@ -1713,10 +1709,14 @@ export function createAgentGraph(options = {}) {
1713
1709
  // text really is a call to one of them: a legitimate answer that happens
1714
1710
  // to contain JSON (a config excerpt, an API sample) must go through
1715
1711
  // untouched, which is why this is not a "content starts with {" test.
1716
- const bareCall = tools.length > 0 ? bareToolCallJson(result.content, tools) : null;
1712
+ const bareCall = (tools.length > 0 ? bareToolCallJson(result.content, tools) : null)
1713
+ ?? narratedToolCallText(result.content);
1717
1714
  if (bareCall) {
1718
1715
  const retries = Number(state.invalidToolCallRetries ?? 0);
1719
- if (retries < 2) {
1716
+ // Retry only when a tool can still be called; when none are offered (the
1717
+ // synthesis turn after a terminal failure) the call is unexecutable and
1718
+ // the honest failure below is the whole answer.
1719
+ if (tools.length > 0 && retries < 2) {
1720
1720
  state.session._onStreamReset?.();
1721
1721
  state.session._onStep?.('Agent: tool call written as JSON text rejected; retrying…');
1722
1722
  return {
@@ -1903,6 +1903,7 @@ export function createAgentGraph(options = {}) {
1903
1903
  const toolCalls = state.pendingToolCalls ?? [];
1904
1904
  const toolResultMessages = [];
1905
1905
  let terminalFailure = null;
1906
+ let skillLaunch = null;
1906
1907
 
1907
1908
  for (const call of toolCalls) {
1908
1909
  const resolved = resolveToolCallName(state.session.mcp, call.function.name, INTERNAL_TOOL_SERVERS);
@@ -2017,6 +2018,24 @@ export function createAgentGraph(options = {}) {
2017
2018
  if (skillResult?.terminal === true) {
2018
2019
  terminalFailure = skillResult.code ?? 'skill_failed';
2019
2020
  ok = false;
2021
+ } else if (skillResult?.accepted === true) {
2022
+ // The skill owns execution from here. Without this the turn kept
2023
+ // going and the model could re-delegate or refuse the very
2024
+ // objective it had just launched — the "launched, then no agent
2025
+ // capable" contradiction. End the turn with the launch
2026
+ // acknowledgement instead of another tool round.
2027
+ skillLaunch = {
2028
+ publicInput: formatPublicSkillInvocation(
2029
+ skillResult.skill ?? args.skillName ?? '',
2030
+ args.arguments && typeof args.arguments === 'object' ? args.arguments : {},
2031
+ ),
2032
+ objectives: Number(skillResult.objectiveCount ?? skillResult.objectives ?? 1) || 1,
2033
+ };
2034
+ } else if (skillResult?.ok === false) {
2035
+ // A recoverable refusal (guessed skill, missing input): keep the
2036
+ // turn alive so the model can correct itself or delegate, but do
2037
+ // not let the progress note call it a success.
2038
+ ok = false;
2020
2039
  }
2021
2040
  }
2022
2041
  if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
@@ -2181,6 +2200,24 @@ export function createAgentGraph(options = {}) {
2181
2200
  invalidResponseRetries: 0,
2182
2201
  };
2183
2202
  }
2203
+ if (skillLaunch) {
2204
+ // Deterministic, localized acknowledgement, generated in the session
2205
+ // language like the `/turn` skill path. No further model turn: the skill
2206
+ // is launched and owns execution, so nothing can contradict it.
2207
+ const response = await generateSkillAcknowledgment(state.session, skillLaunch).catch(
2208
+ () => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s) in progress.`,
2209
+ );
2210
+ return {
2211
+ messages: toolResultMessages,
2212
+ pendingToolCalls: null,
2213
+ response,
2214
+ forceDelegation: false,
2215
+ retryWithoutTool: false,
2216
+ terminalToolFailure: false,
2217
+ invalidToolCallRetries: 0,
2218
+ invalidResponseRetries: 0,
2219
+ };
2220
+ }
2184
2221
  return {
2185
2222
  messages: toolResultMessages,
2186
2223
  pendingToolCalls: null,
@@ -2193,6 +2230,9 @@ export function createAgentGraph(options = {}) {
2193
2230
  }
2194
2231
 
2195
2232
  function routeToolExecutor(state) {
2233
+ // A skill launch already produced its final acknowledgement: end the turn
2234
+ // instead of routing back for another model call that could contradict it.
2235
+ if (state.response != null) return END;
2196
2236
  return 'orchestrator';
2197
2237
  }
2198
2238
 
@@ -624,7 +624,10 @@ test('an explicitly selected skill runs through the intra-runtime path with name
624
624
  },
625
625
  });
626
626
  const result = await createAgentGraph().invoke({ input: 'lance le skill deliver avec le template Quarterly report', session });
627
- assert.equal(result.response, 'Skill mis en file.');
627
+ // Launching a skill ends the turn: the acknowledgement is generated once and
628
+ // the model is not given a second chance to re-delegate or contradict it.
629
+ assert.equal(result.response, 'Started /deliver deliverable="Quarterly report" — 1 step(s) queued.');
630
+ assert.equal(mainCalls, 1);
628
631
  // `skillStack` accompagne désormais la demande : le run imbriqué démarre après
629
632
  // le nettoyage de celui-ci, et c'est le seul canal par lequel il peut savoir
630
633
  // quelles compétences sont déjà ouvertes au-dessus de lui.
@@ -706,6 +709,73 @@ test('a terminal skill refusal stops the whole turn before a delegate fallback',
706
709
  assert.equal(delegated, false);
707
710
  });
708
711
 
712
+ test('a narrated tool call on the tool-less synthesis turn is never shown, and nothing runs', async () => {
713
+ // After a terminal failure the synthesis turn offers no tools; a model that
714
+ // still wants to act writes the call as text (`runtime__delegate{"…"}`) and
715
+ // the turn did nothing. That raw call must never reach the user.
716
+ let delegated = false;
717
+ const narrated = 'runtime__delegate{"objective":"nettoyer le wiki"}';
718
+ const session = sessionBase({
719
+ runtime: { url: 'http://runtime.test' },
720
+ _runSkillWithinRun: async () => ({ ok: false, terminal: true, code: 'skill_not_found', availableSkills: [] }),
721
+ _delegateWithinRun: async () => { delegated = true; return { runId: 'bad' }; },
722
+ llm: {
723
+ async completeWithTools({ tools }) {
724
+ if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
725
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
726
+ }
727
+ if (tools.length > 0) {
728
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [
729
+ { id: 'missing', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"missing","selectionKind":"explicit_name"}' } },
730
+ ] };
731
+ }
732
+ return { content: narrated, message: { role: 'assistant', content: narrated }, tool_calls: null };
733
+ },
734
+ },
735
+ });
736
+ const result = await createAgentGraph().invoke({ input: 'nettoie le wiki', session });
737
+ assert.equal(delegated, false);
738
+ assert.match(result.response, /printed an internal tool request/);
739
+ assert.doesNotMatch(result.response, /runtime__delegate/);
740
+ });
741
+
742
+ test('a recoverable skill refusal lets the delegate fallback run in the same turn', async () => {
743
+ // The observed defect: the model guessed `/diagnose`, the skill runner
744
+ // answered skill_not_found, and because that was terminal the tools were
745
+ // stripped from the next turn — the model then wrote
746
+ // `runtime__delegate{...}` as plain text and the turn did nothing. A guessed
747
+ // skill must be recoverable so the fallback can actually run.
748
+ let delegated = false;
749
+ const session = sessionBase({
750
+ runtime: { url: 'http://runtime.test' },
751
+ _runSkillWithinRun: async () => ({
752
+ ok: false,
753
+ terminal: false,
754
+ code: 'skill_not_found',
755
+ message: 'No skill named "/diagnose". Pass the exact name without a leading slash, or delegate the objective with runtime__delegate.',
756
+ availableSkills: ['diagnose'],
757
+ }),
758
+ _delegateWithinRun: async () => { delegated = true; return { runId: 'run-1', summary: { tasks: 1, agent: 'gateway' } }; },
759
+ llm: {
760
+ async completeWithTools({ tools }) {
761
+ if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
762
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
763
+ }
764
+ return {
765
+ content: null, message: { role: 'assistant', content: null },
766
+ tool_calls: [
767
+ { id: 'guess', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"/diagnose","arguments":{}}' } },
768
+ { id: 'fallback', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"nettoyer le wiki, corriger les doublons et les affirmations non sourcées"}' } },
769
+ ],
770
+ };
771
+ },
772
+ },
773
+ });
774
+ const result = await createAgentGraph().invoke({ input: 'nettoie le wiki, corrige les doublons et les affirmations non sourcées', session });
775
+ assert.equal(delegated, true);
776
+ assert.notEqual(result.terminalToolFailure, true);
777
+ });
778
+
709
779
  test('tool argument normalization repairs only an unambiguous schema-compatible field name', () => {
710
780
  const schema = {
711
781
  type: 'object',
@@ -29,6 +29,8 @@ import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities,
29
29
  import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
30
30
  import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
31
31
  import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
32
+ import { createDeltaCoalescer } from '../runtime/deltaCoalescer.js';
33
+ import { TERMINAL_STATUS_SET } from '../orchestrator/taskStatuses.js';
32
34
  import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
33
35
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
34
36
  import { CapabilityUnavailableError, resolve as resolveCapability } from '../orchestrator/capabilityResolver.js';
@@ -827,7 +829,7 @@ async function runHeadless(argv, agent) {
827
829
  // export finished, before the ingest had even started. The control queue is the
828
830
  // only place where the whole chain is observable, so the wait is scoped to
829
831
  // chainId and ends when every item of that chain is terminal.
830
- const CHAIN_TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'skipped']);
832
+ const CHAIN_TERMINAL_STATUSES = TERMINAL_STATUS_SET;
831
833
 
832
834
  export async function waitForRuntimeChain(session, log, {
833
835
  chainId,
@@ -1739,7 +1741,21 @@ async function runRuntime(argv, agent) {
1739
1741
  workspace: context.workspace ?? ephemeral.workspace ?? null,
1740
1742
  }));
1741
1743
  const messages = conversationSeed({ agentProjection: persistedProjection }, input);
1744
+ // Streaming fragments are coalesced before they are persisted and pushed —
1745
+ // one synchronous SQLite insert (plus one SSE write) per token stalled the
1746
+ // event loop, freezing both chats (serve and ShellUI) while a long answer
1747
+ // was still being produced. Flushed before any non-delta event so ordering
1748
+ // and the final tail are preserved.
1749
+ const deltaCoalescer = createDeltaCoalescer((delta) => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta', {
1750
+ origin: 'runtime_turn',
1751
+ turnId,
1752
+ workspace: context.workspace ?? null,
1753
+ payload: { delta },
1754
+ })), { intervalMs: 80 });
1742
1755
  ephemeral._onAgentEvent = (event) => {
1756
+ // Never let a final message (or any other event) overtake the buffered
1757
+ // fragments that precede it: flush them first, in order.
1758
+ if (event.type !== 'assistant_delta' && event.type !== 'assistant_delta_reset') deltaCoalescer.flush();
1743
1759
  const interactiveEvent = {
1744
1760
  ...event,
1745
1761
  origin: 'runtime_turn',
@@ -1785,28 +1801,30 @@ async function runRuntime(argv, agent) {
1785
1801
  response = await runHeadlessChatTurn(ephemeral, input, {
1786
1802
  history,
1787
1803
  onStep: ephemeral._onStep,
1788
- // Fragments de réponse publiés au fil de l'eau. Le réducteur les
1789
- // agrège dans la dernière entrée de conversation (`assistant_delta`),
1790
- // que `assistant_message` vient ensuite figer : les deux interfaces
1791
- // voient la réponse s'écrire, au lieu d'attendre le tour complet.
1792
- onTextDelta: (delta) => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta', {
1793
- origin: 'runtime_turn',
1794
- turnId,
1795
- workspace: context.workspace ?? null,
1796
- payload: { delta },
1797
- })),
1798
- onTextReset: () => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta_reset', {
1799
- origin: 'runtime_turn',
1800
- turnId,
1801
- workspace: context.workspace ?? null,
1802
- payload: {},
1803
- })),
1804
+ // Fragments de réponse publiés au fil de l'eau, coalescés (voir
1805
+ // deltaCoalescer ci-dessus). Le réducteur les agrège dans la dernière
1806
+ // entrée de conversation (`assistant_delta`), que `assistant_message`
1807
+ // vient ensuite figer : les deux interfaces voient la réponse s'écrire
1808
+ // sans qu'un insert SQLite par token ne bloque le flux.
1809
+ onTextDelta: (delta) => deltaCoalescer.push(delta),
1810
+ onTextReset: () => {
1811
+ deltaCoalescer.reset();
1812
+ dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta_reset', {
1813
+ origin: 'runtime_turn',
1814
+ turnId,
1815
+ workspace: context.workspace ?? null,
1816
+ payload: {},
1817
+ }));
1818
+ },
1804
1819
  openWikiPages,
1805
1820
  });
1806
1821
  } else {
1807
1822
  ephemeral.openWikiPages = openWikiPages;
1808
1823
  response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
1809
1824
  }
1825
+ // Flush the tail before the turn is finalized, then stop the timer.
1826
+ deltaCoalescer.flush();
1827
+ deltaCoalescer.dispose();
1810
1828
  // Persist the artifact the turn may have opened/edited (template_write,
1811
1829
  // template_read, …) back onto the long-lived session, so the next /turn —
1812
1830
  // chat or agent — sees it. The ephemeral session is otherwise discarded.
@@ -1,11 +1,12 @@
1
1
  import { normalizeActivity } from './activity.js';
2
+ import { cloneJson } from './json.js';
2
3
  import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
4
  import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
4
5
  import { formatRuntimeLogPayload, isDispatchPlumbingLine, normalizeRuntimeLog, shortTaskLabel } from './runtimeLog.js';
5
6
  import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
6
7
  import { projectWorkflow } from './workflow.js';
7
8
  import { validateContractInDev } from '../contracts/schemas.js';
8
- import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
9
+ import { isActive, isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
9
10
 
10
11
  const SESSION_PROJECTION_EVENTS = new Set([
11
12
  'run_started',
@@ -291,6 +292,11 @@ export function applyAgentProjectionToSession(session, projection) {
291
292
  } : session.productionActivity ?? null;
292
293
  }
293
294
 
295
+ function hasRunningPlanStep(state) {
296
+ return (Array.isArray(state.plan) ? state.plan : [])
297
+ .some((step) => isActive(step?.status));
298
+ }
299
+
294
300
  function applyEvent(state, event) {
295
301
  switch (event.type) {
296
302
  case 'run_started':
@@ -597,6 +603,13 @@ function applyEvent(state, event) {
597
603
  reason: event.payload?.reason ?? null,
598
604
  createdAt: event.ts,
599
605
  });
606
+ // A run waiting for a human is not "running": showing it as running is
607
+ // how the chat could claim a rebuild was executing before anyone
608
+ // approved it. Mirror `run_pending_approval` (AGENTS.md: the run status
609
+ // is pending_approval while the decision is outstanding), but only when
610
+ // no task is actually executing — a parallel run may have work in flight
611
+ // while one branch waits.
612
+ if (!hasRunningPlanStep(state)) state.status = 'pending_approval';
600
613
  return;
601
614
  case 'approval.granted': {
602
615
  const grant = {
@@ -616,6 +629,13 @@ function applyEvent(state, event) {
616
629
  };
617
630
  upsertApproval(state, grant);
618
631
  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
+ }
619
639
  return;
620
640
  }
621
641
  case 'approval.rejected':
@@ -1240,6 +1260,3 @@ function sortedActivities(activities) {
1240
1260
  .sort((a, b) => String(a.updatedAt ?? '').localeCompare(String(b.updatedAt ?? '')));
1241
1261
  }
1242
1262
 
1243
- function cloneJson(value) {
1244
- return value == null ? value : JSON.parse(JSON.stringify(value));
1245
- }
@@ -127,6 +127,40 @@ test('reduceAgentEvents: interactive (user) run_started clears state but is not
127
127
  assert.notEqual(projection.status, 'running');
128
128
  });
129
129
 
130
+ test('reduceAgentEvents: a run blocked on approval is not shown as running', () => {
131
+ // The chat claimed a rebuild was executing while its only task was still
132
+ // waiting for a human: the run status stayed 'running' through the per-task
133
+ // approval request. It must mirror run_pending_approval.
134
+ const projection = reduceAgentEvents([
135
+ createAgentEvent('run_started', { origin: 'runtime' }),
136
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
137
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
138
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
139
+ ]);
140
+ assert.equal(projection.status, 'pending_approval');
141
+ });
142
+
143
+ test('reduceAgentEvents: granting the approval puts the run back to running', () => {
144
+ const projection = reduceAgentEvents([
145
+ createAgentEvent('run_started', { origin: 'runtime' }),
146
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
147
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
148
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
149
+ createAgentEvent('approval.granted', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
150
+ ]);
151
+ assert.equal(projection.status, 'running');
152
+ });
153
+
154
+ test('reduceAgentEvents: an approval request does not hide a genuinely running task', () => {
155
+ const projection = reduceAgentEvents([
156
+ createAgentEvent('run_started', { origin: 'runtime' }),
157
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Export', 'Build'] } }),
158
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'running' } }),
159
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't2' } }),
160
+ ]);
161
+ assert.equal(projection.status, 'running');
162
+ });
163
+
130
164
  test('reduceAgentEvents: tracks manual plan and step updates', () => {
131
165
  const projection = reduceAgentEvents([
132
166
  createAgentEvent('plan_set', {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.94",
3
- "commit": "00c41b1"
2
+ "version": "0.15.97",
3
+ "commit": "b90e735"
4
4
  }
@@ -33,6 +33,3 @@ export function defaultGoogleGrants() {
33
33
  return [...GOOGLE_GRANTS];
34
34
  }
35
35
 
36
- export function describeGoogleGrant(grant) {
37
- return GOOGLE_GRANT_LABELS[grant] ?? null;
38
- }
@@ -0,0 +1,9 @@
1
+ /*
2
+ * Deep clone via a JSON round-trip; `null`/`undefined` pass through.
3
+ *
4
+ * Three modules had grown their own byte-identical copy (agentEvents,
5
+ * agentRegistry, planValidator). One implementation, imported.
6
+ */
7
+ export function cloneJson(value) {
8
+ return value == null ? value : JSON.parse(JSON.stringify(value));
9
+ }
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.94';
4
+ const WIKI_MANAGER_VERSION = '0.15.97';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
package/src/core/plan.js CHANGED
@@ -48,10 +48,6 @@ export function extractHeadlessPlan(text) {
48
48
  return steps;
49
49
  }
50
50
 
51
- export function matchCompletedToPlan(plan, completed) {
52
- if (!plan) return;
53
- syncActivitiesToPlan(plan, completed.filter((activity) => activity.terminal));
54
- }
55
51
 
56
52
  export function syncActivitiesToPlan(plan, activities) {
57
53
  if (!plan) return;
@@ -48,7 +48,3 @@ export function toolResultNote(name, ok, detail) {
48
48
  return reason ? `${tool} done: ${reason}` : `${tool} done.`;
49
49
  }
50
50
 
51
- export function turnDoneNote(steps) {
52
- if (!steps) return 'Done.';
53
- return `Done — ${steps} step${steps > 1 ? 's' : ''}.`;
54
- }