@dotdrelle/wiki-manager 0.15.94 → 0.15.96

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.96",
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
  /**
@@ -1377,24 +1378,6 @@ export function buildAgentSystemPrompt(state) {
1377
1378
  return customPrompt ? `${customPrompt}\n\n${agentContext}` : agentContext;
1378
1379
  }
1379
1380
 
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
1381
 
1399
1382
  export function formatLlmUnavailableMessage(reason) {
1400
1383
  const clean = String(reason ?? 'unknown reason').replace(/\s+/g, ' ').trim();
@@ -1903,6 +1886,7 @@ export function createAgentGraph(options = {}) {
1903
1886
  const toolCalls = state.pendingToolCalls ?? [];
1904
1887
  const toolResultMessages = [];
1905
1888
  let terminalFailure = null;
1889
+ let skillLaunch = null;
1906
1890
 
1907
1891
  for (const call of toolCalls) {
1908
1892
  const resolved = resolveToolCallName(state.session.mcp, call.function.name, INTERNAL_TOOL_SERVERS);
@@ -2017,6 +2001,19 @@ export function createAgentGraph(options = {}) {
2017
2001
  if (skillResult?.terminal === true) {
2018
2002
  terminalFailure = skillResult.code ?? 'skill_failed';
2019
2003
  ok = false;
2004
+ } else if (skillResult?.accepted === true) {
2005
+ // The skill owns execution from here. Without this the turn kept
2006
+ // going and the model could re-delegate or refuse the very
2007
+ // objective it had just launched — the "launched, then no agent
2008
+ // capable" contradiction. End the turn with the launch
2009
+ // acknowledgement instead of another tool round.
2010
+ skillLaunch = {
2011
+ publicInput: formatPublicSkillInvocation(
2012
+ skillResult.skill ?? args.skillName ?? '',
2013
+ args.arguments && typeof args.arguments === 'object' ? args.arguments : {},
2014
+ ),
2015
+ objectives: Number(skillResult.objectiveCount ?? skillResult.objectives ?? 1) || 1,
2016
+ };
2020
2017
  }
2021
2018
  }
2022
2019
  if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
@@ -2181,6 +2178,24 @@ export function createAgentGraph(options = {}) {
2181
2178
  invalidResponseRetries: 0,
2182
2179
  };
2183
2180
  }
2181
+ if (skillLaunch) {
2182
+ // Deterministic, localized acknowledgement, generated in the session
2183
+ // language like the `/turn` skill path. No further model turn: the skill
2184
+ // is launched and owns execution, so nothing can contradict it.
2185
+ const response = await generateSkillAcknowledgment(state.session, skillLaunch).catch(
2186
+ () => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s) in progress.`,
2187
+ );
2188
+ return {
2189
+ messages: toolResultMessages,
2190
+ pendingToolCalls: null,
2191
+ response,
2192
+ forceDelegation: false,
2193
+ retryWithoutTool: false,
2194
+ terminalToolFailure: false,
2195
+ invalidToolCallRetries: 0,
2196
+ invalidResponseRetries: 0,
2197
+ };
2198
+ }
2184
2199
  return {
2185
2200
  messages: toolResultMessages,
2186
2201
  pendingToolCalls: null,
@@ -2193,6 +2208,9 @@ export function createAgentGraph(options = {}) {
2193
2208
  }
2194
2209
 
2195
2210
  function routeToolExecutor(state) {
2211
+ // A skill launch already produced its final acknowledgement: end the turn
2212
+ // instead of routing back for another model call that could contradict it.
2213
+ if (state.response != null) return END;
2196
2214
  return 'orchestrator';
2197
2215
  }
2198
2216
 
@@ -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) in progress.');
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.
@@ -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,4 +1,5 @@
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';
@@ -1240,6 +1241,3 @@ function sortedActivities(activities) {
1240
1241
  .sort((a, b) => String(a.updatedAt ?? '').localeCompare(String(b.updatedAt ?? '')));
1241
1242
  }
1242
1243
 
1243
- function cloneJson(value) {
1244
- return value == null ? value : JSON.parse(JSON.stringify(value));
1245
- }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.94",
3
- "commit": "00c41b1"
2
+ "version": "0.15.96",
3
+ "commit": "fbeddf2"
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.96';
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
- }
@@ -7,6 +7,8 @@
7
7
  * which is why the shaping lives here and not in either renderer.
8
8
  */
9
9
 
10
+ import { TERMINAL_STATUS_SET } from '../orchestrator/taskStatuses.js';
11
+
10
12
  const SYMBOLS = {
11
13
  done: '✓',
12
14
  running: '●',
@@ -29,7 +31,7 @@ export function selectionKindLabel(selectionKind) {
29
31
  return SELECTION_KIND_LABELS[selectionKind] ?? selectionKind ?? null;
30
32
  }
31
33
 
32
- export const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
34
+ export const TERMINAL = TERMINAL_STATUS_SET;
33
35
 
34
36
  // Objectives are whole paragraphs; a chain view needs a line. Keep the first
35
37
  // sentence, drop the parameter block the compiler appends, and never cut a word
@@ -38,7 +38,15 @@ export async function runBoundedToolLoop({
38
38
  // livre le texte au fil de l'eau. Sans lui, la réponse finale n'apparaissait
39
39
  // qu'une fois complète — le tour paraissait figé pendant toute sa durée.
40
40
  const canStream = typeof onTextDelta === 'function' && typeof llm?.streamWithTools === 'function';
41
+ // The exact same tool + arguments called again is a loop, not progress: a
42
+ // model that keeps re-issuing `search("x")` will never finish, and burning
43
+ // the whole iteration cap on it only produced "could not finish". Track the
44
+ // signatures and stop as soon as a turn repeats one already executed.
45
+ const seen = new Set();
46
+ const signature = (call) => `${call?.function?.name ?? ''}\u0000${String(call?.function?.arguments ?? '')}`;
47
+ let iterations = 0;
41
48
  for (let i = 0; i < cap; i += 1) {
49
+ iterations = i + 1;
42
50
  onStep?.(i + 1, cap);
43
51
  let streamedText = false;
44
52
  const result = canStream
@@ -62,10 +70,12 @@ export async function runBoundedToolLoop({
62
70
  if (calls.length === 0) {
63
71
  return {
64
72
  content: result?.content ?? result?.message?.content ?? '',
65
- iterations: i + 1,
73
+ iterations,
66
74
  capped: false,
67
75
  };
68
76
  }
77
+ if (calls.every((call) => seen.has(signature(call)))) break;
78
+ for (const call of calls) seen.add(signature(call));
69
79
  convo.push(result.message ?? { role: 'assistant', content: result.content ?? '', tool_calls: calls });
70
80
  // Tool calls within one turn are independent: dispatch concurrently, then
71
81
  // replay results in the model's call order so the transcript stays stable.
@@ -77,5 +87,49 @@ export async function runBoundedToolLoop({
77
87
  convo.push({ role: 'tool', tool_call_id: outcome.tool_call_id, content: outcome.content });
78
88
  }
79
89
  }
80
- return { content: '', iterations: cap, capped: true };
90
+ // Cap reached or a loop detected: ask once more WITHOUT tools for the best
91
+ // answer the results gathered so far support. Returning '' here is what made
92
+ // a long search end in a dead-end instead of the partial answer it had
93
+ // already collected.
94
+ const content = await finalAnswerWithoutTools({ llm, system, convo, canStream, onTextDelta, onTextReset, signal });
95
+ return { content, iterations, capped: true };
96
+ }
97
+
98
+ async function finalAnswerWithoutTools({
99
+ llm,
100
+ system,
101
+ convo,
102
+ canStream,
103
+ onTextDelta,
104
+ onTextReset,
105
+ signal,
106
+ }) {
107
+ try {
108
+ if (canStream) {
109
+ let text = '';
110
+ const result = await llm.streamWithTools({
111
+ system,
112
+ tools: [],
113
+ messages: convo,
114
+ toolChoice: 'auto',
115
+ onTextDelta: (delta) => { text += delta; onTextDelta(delta); },
116
+ signal,
117
+ });
118
+ // A tool call despite the empty toolset is not an answer: drop whatever
119
+ // it streamed and let the caller fall back to its own message.
120
+ if (result?.tool_calls?.length) { onTextReset?.(); return ''; }
121
+ return String(result?.content ?? text ?? '').trim();
122
+ }
123
+ const result = await llm.completeWithTools({
124
+ system,
125
+ tools: [],
126
+ messages: convo,
127
+ toolChoice: 'auto',
128
+ signal,
129
+ });
130
+ if (result?.tool_calls?.length) return '';
131
+ return String(result?.content ?? result?.message?.content ?? '').trim();
132
+ } catch {
133
+ return '';
134
+ }
81
135
  }
@@ -61,16 +61,47 @@ test('runs concurrent tool calls and replays results in call order', async () =>
61
61
  assert.deepEqual(order, ['a', 'b']); // preserved model call order
62
62
  });
63
63
 
64
- test('reports capped when the model keeps calling tools past the cap', async () => {
64
+ test('stops on a repeated identical tool call instead of burning the cap', async () => {
65
65
  const llm = {
66
- async completeWithTools() {
66
+ async completeWithTools({ tools }) {
67
+ if (tools.length === 0) return { content: 'Synthèse des résultats.', tool_calls: [] };
67
68
  const calls = [toolCall('x', 's__status')];
68
69
  return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
69
70
  },
70
71
  };
71
- const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'r', maxIterations: 3 });
72
+ const out = await runBoundedToolLoop({
73
+ llm,
74
+ tools: [{ function: { name: 's__status' } }],
75
+ executeCall: async () => 'r',
76
+ maxIterations: 8,
77
+ });
78
+ assert.equal(out.capped, true);
79
+ // The same call twice is a loop: it stopped well before the cap.
80
+ assert.ok(out.iterations < 8, `expected an early stop, got ${out.iterations}`);
81
+ // And the turn still answers from what it gathered instead of a dead-end.
82
+ assert.equal(out.content, 'Synthèse des résultats.');
83
+ });
84
+
85
+ test('answers from the gathered results when the cap is reached', async () => {
86
+ let round = 0;
87
+ const llm = {
88
+ async completeWithTools({ tools }) {
89
+ round += 1;
90
+ if (round <= 2 && tools.length > 0) {
91
+ const calls = [toolCall('x', 's__search', `{"q":"${round}"}`)];
92
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
93
+ }
94
+ return { content: "Voici ce que j'ai trouvé.", tool_calls: [] };
95
+ },
96
+ };
97
+ const out = await runBoundedToolLoop({
98
+ llm,
99
+ tools: [{ function: { name: 's__search' } }],
100
+ executeCall: async () => 'r',
101
+ maxIterations: 2,
102
+ });
72
103
  assert.equal(out.capped, true);
73
- assert.equal(out.iterations, 3);
104
+ assert.equal(out.content, "Voici ce que j'ai trouvé.");
74
105
  });
75
106
 
76
107
  test('propagates an abort thrown by executeCall', async () => {
@@ -1,5 +1,6 @@
1
1
  import { createAgentEvent, dispatchAgentEvent, dispatchRuntimeLog } from '../core/agentEvents.js';
2
2
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
3
+ import { cloneJson } from '../core/json.js';
3
4
  import { assertContract } from '../contracts/schemas.js';
4
5
 
5
6
  const AVAILABLE = 'available';
@@ -320,6 +321,3 @@ function cloneAgent(agent) {
320
321
  };
321
322
  }
322
323
 
323
- function cloneJson(value) {
324
- return value == null ? value : JSON.parse(JSON.stringify(value));
325
- }
@@ -174,6 +174,3 @@ function taskId(task) {
174
174
  return String(task?.id ?? task?.step);
175
175
  }
176
176
 
177
- export function isTerminalTask(task) {
178
- return isTerminal(statusOf(task));
179
- }
@@ -1,4 +1,5 @@
1
1
  import { validateContract } from '../contracts/schemas.js';
2
+ import { cloneJson } from '../core/json.js';
2
3
 
3
4
  const SUPPORTED_CONTRACT_VERSIONS = new Set(['1']);
4
5
  const MUTATING_OPERATIONS = new Set([
@@ -529,6 +530,3 @@ function issue(code, message, details = {}) {
529
530
  return { code, message, details };
530
531
  }
531
532
 
532
- function cloneJson(value) {
533
- return value == null ? value : JSON.parse(JSON.stringify(value));
534
- }
@@ -43,20 +43,6 @@ import { assertContract } from '../../contracts/schemas.js';
43
43
 
44
44
  export const RUNTIME_PROTOCOL_VERSION = '1';
45
45
 
46
- export const RUNTIME_EVENT_TYPES = [
47
- 'run_created',
48
- 'run_started',
49
- 'agent_thinking',
50
- 'tool_started',
51
- 'tool_finished',
52
- 'subagent_started',
53
- 'subagent_finished',
54
- 'message',
55
- 'approval_required',
56
- 'run_completed',
57
- 'run_failed',
58
- 'run_cancelled',
59
- ];
60
46
 
61
47
  export class RuntimeProviderUnavailableError extends Error {
62
48
  constructor(runtime, reason) {