@dotdrelle/wiki-manager 0.15.70 → 0.15.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +24 -21
  2. package/agents.docker-compose.yml +5 -1
  3. package/package.json +1 -1
  4. package/src/activity/activityAggregator.test.js +2 -2
  5. package/src/agent/graph.js +1 -0
  6. package/src/cli/wiki-manager.js +1 -1
  7. package/src/cli/wiki-manager.test.js +16 -16
  8. package/src/commands/slash.js +24 -5
  9. package/src/core/agentEvents.js +122 -25
  10. package/src/core/agentEvents.test.js +26 -1
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/commandFailure.test.js +2 -2
  13. package/src/core/currentArtifact.test.js +5 -5
  14. package/src/core/mcp.js +1 -1
  15. package/src/core/mcp.test.js +1 -1
  16. package/src/core/otherWorkspacesRunning.test.js +6 -6
  17. package/src/core/runtimeLog.js +35 -1
  18. package/src/core/runtimeLog.test.js +27 -2
  19. package/src/core/skillInvocation.test.js +1 -1
  20. package/src/core/wikiSetup.js +25 -0
  21. package/src/core/wikiSetup.test.js +35 -0
  22. package/src/core/wikirc.test.js +6 -6
  23. package/src/core/workspaceInherit.test.js +14 -14
  24. package/src/orchestrator/agentRegistry.test.js +6 -6
  25. package/src/orchestrator/dispatcher.js +70 -26
  26. package/src/orchestrator/dispatcher.test.js +46 -3
  27. package/src/orchestrator/providers/deepAgentsProvider.test.js +2 -2
  28. package/src/orchestrator/providers/runtimeProviders.js +58 -5
  29. package/src/orchestrator/providers/runtimeProviders.test.js +23 -0
  30. package/src/orchestrator/scheduler.test.js +4 -4
  31. package/src/runtime/delegation.test.js +11 -11
  32. package/src/runtime/runner.test.js +1 -1
  33. package/src/runtime/server.test.js +2 -2
  34. package/src/runtime/store.test.js +8 -5
  35. package/src/runtime/workspaceIsolation.test.js +26 -26
  36. package/src/shell/RightPane.tsx +14 -2
  37. package/src/shell/repl.js +24 -2
  38. package/wiki-workspace +34 -0
package/README.md CHANGED
@@ -126,27 +126,30 @@ Several open projects now build a Markdown wiki with an LLM. They target
126
126
  **different problems** — the useful questions are *what goes in, what comes out,
127
127
  and who operates it*. Snapshot as of 2026; all of these move quickly.
128
128
 
129
- Each cell keeps its detail and carries a score — ✅ first-class · 🟡 partial or
130
- indirect · ❌ not a goal — and the last column names the project that **covers
131
- that need best**.
132
-
133
- | Dimension / need | **wikiLLM** (this project) | **OpenWiki** — `langchain-ai/openwiki` | **DeepWiki-Open** — `asyncfuncai/deepwiki-open` | **GraphRAG** — `microsoft/graphrag` | Best coverage |
134
- | --- | --- | --- | --- | --- | --- |
135
- | Built for | Turning scattered **business documents** into a team wiki, then regenerating deliverables from it | Giving **coding agents** a readable map of a codebase | Auto-documenting a **code repository** with diagrams | Answering **global questions** over a large text corpus | *depends on your goal* |
136
- | Ingest arbitrary business documents (Confluence, PDF, Office, SaaS) | ✅ Confluence exports, PDF/Office files, notes, SaaS connectors | 🟡 personal-mode connectors only — Notion, Gmail, Slack | ❌ code repos only | 🟡 plain-text files only, and no wiki as output | **wikiLLM** |
137
- | Document a source-code repository | ❌ nothing to ingest from a repo | ✅ *code mode*, with claims linked to source evidence (OKF) | ✅ repo → interactive wiki + Mermaid diagrams | ❌ not a goal | **OpenWiki / DeepWiki-Open** |
138
- | Primary output | ✅ Maintained wiki **+ regenerated deliverables** from your templates (reports, pages, exports) | 🟡 A wiki about the codebase, for agents to read | 🟡 An interactive wiki + architecture diagrams | ❌ An entity graph + community summaries (Parquet), not a wiki | **wikiLLM** (only one producing deliverables) |
139
- | Keep the wiki current over time | ✅ Re-ingest on demand or on a schedule | ✅ `--update` flag / CI action | 🟡 Regenerated per run | ✅ `graphrag update` (delta merge) | **wikiLLM / OpenWiki** |
140
- | Knowledge structure | Deterministic concept folders + a derived community graph | Linked pages + evidence-grounded claims | LLM-generated pages + diagrams | Leiden communities over an entity/relationship graph | *task-dependent* |
141
- | Browsable wiki UI for a team | ✅ Web UI: wiki browser, dependency graph, chat, run/execution view (single-user today) | 🟡 Local-only browser visualiser (127.0.0.1) + CLI chat | ✅ Self-hostable web app (Next.js + Python) with RAG chat | ❌ Library / CLI — no UI | **DeepWiki-Open / wikiLLM** |
142
- | Evidence-grounded claims & citations | 🟡 Cites retrieved context, never invents facts | ✅ Grounded claims tied to versioned source | 🟡 RAG-cited answers | ✅ Citations to source text units | **OpenWiki / GraphRAG** |
143
- | Corpus-wide graph Q&A over the knowledge | 🟡 BM25 + vector retrieval feeding generation | ❌ not a goal | 🟡 RAG chat scoped to one repo | ✅ Entity graph + local/global community search | **GraphRAG** |
144
- | Multiple isolated projects on one install | ✅ Workspaces, each with its own services, ports and secrets | ❌ one wiki per run | ❌ one wiki per repo | ❌ one index per corpus | **wikiLLM** |
145
- | Orchestration & governance | ✅ Capability-based dispatcher (**Donna**): human approval by default, per-run budgets, idempotent writes, crash recovery | ❌ One Deep Agent loop (LangGraph) | ❌ One generation pipeline | ❌ Deterministic indexing pipeline | **wikiLLM** |
146
- | Durable runs — crash recovery, queued work | ✅ Boot-time re-attachment + extra runs queued | 🟡 Resumable page-job queue (`.run.json`) | ❌ regenerate from scratch | ❌ re-run the index | **wikiLLM** |
147
- | Source connectors as separate services | ✅ Confluence, document conversion, e-mail — each an independent MCP agent | 🟡 Built-in connector set | ❌ | ❌ | **wikiLLM** |
148
- | Run fully offline with local models | ✅ Per-workspace provider config, OpenAI-compatible or a gateway (Ollama, vLLM, MLX…) | ✅ 13+ providers incl. Ollama / LM Studio | ✅ incl. Ollama | ✅ any OpenAI-compatible endpoint | *any* |
149
- | License | ❌ PolyForm **Noncommercial** | ✅ MIT | ✅ MIT | ✅ MIT | **OpenWiki / DeepWiki-Open / GraphRAG** |
129
+ ✅ first-class · 🟡 partial or indirect · ❌ not a goal
130
+
131
+ Projects compared: [OpenWiki](https://github.com/langchain-ai/openwiki),
132
+ [DeepWiki-Open](https://github.com/asyncfuncai/deepwiki-open),
133
+ [GraphRAG](https://github.com/microsoft/graphrag).
134
+
135
+ <table>
136
+ <thead>
137
+ <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>
138
+ </thead>
139
+ <tbody>
140
+ <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>
141
+ <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>
142
+ <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>
143
+ <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>
144
+ <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>
145
+ <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>
146
+ <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>
147
+ <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>
148
+ <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>
149
+ <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>
150
+ <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>
151
+ </tbody>
152
+ </table>
150
153
 
151
154
  **The short version:**
152
155
 
@@ -164,7 +164,11 @@ services:
164
164
  # One file, two readers: the manager routes on agent-runtimes.json, the
165
165
  # gateway serves the capabilities of its own entry from the same file.
166
166
  # Absolute path, computed by wiki-workspace from the manager state dir.
167
- - ${AGENT_RUNTIMES_FILE:?Set AGENT_RUNTIMES_FILE}:/config/agent-runtimes.json:ro
167
+ # Declared AFTER the /config data mount: for overlapping bind mounts the
168
+ # child mount wins, and the reverse order silently hid the file under
169
+ # the directory — the gateway degraded to its built-in default
170
+ # capabilities on every start.
168
171
  - ${AGENTS_DATA_DIR:-./.agents-data}/gateway:/config
172
+ - ${AGENT_RUNTIMES_FILE:?Set AGENT_RUNTIMES_FILE}:/config/agent-runtimes.json:ro
169
173
  - ${WORKSPACES_ROOT:?Set WORKSPACES_ROOT to the directory containing all workspace folders}:/workspaces
170
174
  restart: unless-stopped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.70",
3
+ "version": "0.15.71",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -144,12 +144,12 @@ test('aggregateActivity keeps a healthy active task out of the error color when
144
144
  key: 'activity-b',
145
145
  status: 'running',
146
146
  terminal: false,
147
- progress: { percent: 35, stepId: 'running-b', label: 'Ingest application-orea.md', detail: 'LLM running' },
147
+ progress: { percent: 35, stepId: 'running-b', label: 'Ingest sample-application.md', detail: 'LLM running' },
148
148
  }],
149
149
  }, []);
150
150
 
151
151
  const line = aggregated.lines[0];
152
152
  assert.equal(line.status, '35 %');
153
153
  assert.match(line.label, /^\[\.\.\.\]/);
154
- assert.equal(line.progress.label, 'Ingest application-orea.md');
154
+ assert.equal(line.progress.label, 'Ingest sample-application.md');
155
155
  });
@@ -1248,6 +1248,7 @@ export function buildAgentSystemPrompt(state) {
1248
1248
  'When an action fails or is refused for lack of an authorization grant or scope (rather than a missing capability), say exactly that and name the primitive that grants it. Do not describe the feature as unavailable.',
1249
1249
  'For an action with no matching direct tool, call runtime__delegate with the user objective only. The runtime chooses the capability, operation, agent and plan, including a validated single task for executor-only agents. Never choose those identifiers yourself. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
1250
1250
  'Do not ask the user which sources, files, connectors, or templates to use for an ingest, build, or export: the specialized agent discovers them from the workspace. When the objective is clear (e.g. "lance une ingestion"), delegate it as stated, without a clarifying question.',
1251
+ 'The concept map is not a deliverable and has no build, rebuild, reclassify, group or taxonomy pass of its own. Concepts are the folders produced by ingestion itself (wiki/concepts/<concept>/<subject>.md — the concept IS the folder), and the /graph taxonomy derives from them. So "rebuild / refresh / redo the concepts" means run an ingestion (the wiki-ingest skill, or a delegated ingest objective) — never a build or export, and never a separate concept step. Do not offer "rebuild the concepts" alongside build/export as if it were the same family of action.',
1251
1252
  'Templates are instruction-only specs and deliverables are regenerated from them. When asked to change what a generated document says, edit the underlying wiki content (wiki_write_page) or the template\'s [[INSTRUCTION: ...]] sections — never write finished prose into a template, because a build copies it verbatim and it can no longer be refreshed from the wiki. template_write refuses prose outside an instruction block, so keep every sentence inside one.',
1252
1253
  'Promise only what the resolved capability actually exposes in its declared contract (the input schema the specialized agent publishes for that capability). When the user requests an execution parameter — a batch or chunk size, a count "N at a time", concurrency, ordering, priority, or any tuning knob — apply it only if that parameter exists in the target capability\'s published input schema. Otherwise do not confirm or promise it: delegate the objective, and if the user explicitly asked for that parameter, say plainly in one line that you started the work but do not control that aspect (the runtime and the specialized agent decide it). Never state or imply a parameter was applied when the agent contract cannot enforce it.',
1253
1254
  'If runtime__delegate returns a blocker or no specialized provider is available, report only that concrete blocker concisely. Never replace the missing execution path with a suggested slash command, skill, MCP tool name, manual file move, administrator escalation, or alternative workflow unless the user explicitly asks for alternatives.',
@@ -170,7 +170,7 @@ export async function resolveExecutorArguments({
170
170
  'Only fill a field when the objective explicitly states or clearly implies its value.',
171
171
  'Omit every field that is not stated. Never invent identifiers, queries, filters or counts.',
172
172
  // The objective almost always names the workspace ("export the pages of
173
- // workspace acpi"), and the orchestrator already binds it out of band. With
173
+ // workspace acme"), and the orchestrator already binds it out of band. With
174
174
  // one free-text field in the schema and no field for the workspace, a model
175
175
  // reliably misbinds the two — that is how a workspace name ended up as a
176
176
  // source name and failed the task.
@@ -66,7 +66,7 @@ test('interactive runtime refreshes configured MCP endpoints that started late',
66
66
  test('executor-only capabilities receive one manager-authored executable task', () => {
67
67
  const fragment = buildExecutorOnlyFragment({
68
68
  objective: 'donne-moi mes derniers mails',
69
- workspace: 'juno',
69
+ workspace: 'demo',
70
70
  selection: {
71
71
  capability: 'external-source.collect',
72
72
  operation: 'collect',
@@ -91,7 +91,7 @@ test('executor-only capabilities receive one manager-authored executable task',
91
91
  assert.equal(fragment.tasks[0].requiredCapability, 'external-source.collect');
92
92
  assert.equal(fragment.tasks[0].operation, 'collect');
93
93
  assert.deepEqual(fragment.tasks[0].arguments, { maxMessages: 10 });
94
- assert.deepEqual(fragment.tasks[0].locks, ['external-source.collect:juno']);
94
+ assert.deepEqual(fragment.tasks[0].locks, ['external-source.collect:demo']);
95
95
  assert.equal(fragment.tasks[0].requiresApproval, true);
96
96
  assert.equal(fragment.tasks[0].approvalClass, 'external-source');
97
97
  assert.match(fragment.tasks[0].idempotencyKey, /^[0-9a-f-]{36}$/);
@@ -263,14 +263,14 @@ const EXPORT_CAPABILITY = {
263
263
  };
264
264
 
265
265
  test('argument extraction drops a value that only echoes the active workspace', async () => {
266
- // Regression: "exporter les pages Confluence du workspace acpi" against a
266
+ // Regression: "export the Confluence pages of the acme workspace" against a
267
267
  // schema whose single free-text field is source_name. The model binds the
268
- // workspace name to it, and the executor fails with "source 'acpi' not
268
+ // workspace name to it, and the executor fails with "source 'acme' not
269
269
  // found". The workspace is already bound out of band, so the echo is noise.
270
270
  const llm = {
271
271
  completeWithTools: async () => ({
272
272
  tool_calls: [{
273
- function: { name: 'set_task_arguments', arguments: JSON.stringify({ source_name: 'acpi' }) },
273
+ function: { name: 'set_task_arguments', arguments: JSON.stringify({ source_name: 'acme' }) },
274
274
  }],
275
275
  }),
276
276
  };
@@ -278,9 +278,9 @@ test('argument extraction drops a value that only echoes the active workspace',
278
278
  assert.deepEqual(
279
279
  await resolveExecutorArguments({
280
280
  llm,
281
- objective: 'exporter les pages Confluence du workspace acpi',
281
+ objective: 'export the Confluence pages of the acme workspace',
282
282
  capability: EXPORT_CAPABILITY,
283
- workspace: 'acpi',
283
+ workspace: 'acme',
284
284
  }),
285
285
  {},
286
286
  );
@@ -303,7 +303,7 @@ test('argument extraction keeps a real value that is not the workspace name', as
303
303
  llm,
304
304
  objective: 'exporter la source EAS_Avant_projet_ACPI',
305
305
  capability: EXPORT_CAPABILITY,
306
- workspace: 'acpi',
306
+ workspace: 'acme',
307
307
  }),
308
308
  { source_name: 'EAS_Avant_projet_ACPI' },
309
309
  );
@@ -320,17 +320,17 @@ test('argument extraction tells the model the workspace is already bound', async
320
320
 
321
321
  await resolveExecutorArguments({
322
322
  llm,
323
- objective: 'exporter les pages du workspace acpi',
323
+ objective: 'export the pages of the acme workspace',
324
324
  capability: EXPORT_CAPABILITY,
325
- workspace: 'acpi',
325
+ workspace: 'acme',
326
326
  });
327
327
 
328
- assert.match(seenSystem, /already runs against workspace "acpi"/);
328
+ assert.match(seenSystem, /already runs against workspace "acme"/);
329
329
  });
330
330
 
331
331
  test('argument extraction rejects a value outside the closed vocabulary', async () => {
332
- // Regression: "exporter les pages Confluence du workspace juno" — the
333
- // workspace guard removes "juno", so the model reaches for the next noun and
332
+ // Regression: "export the Confluence pages of the demo workspace" — the
333
+ // workspace guard removes "demo", so the model reaches for the next noun and
334
334
  // emits "Confluence". Only the agent knows the valid names; once it publishes
335
335
  // them as an enum, the orchestrator can check without guessing at meaning.
336
336
  const capability = {
@@ -354,9 +354,9 @@ test('argument extraction rejects a value outside the closed vocabulary', async
354
354
  assert.deepEqual(
355
355
  await resolveExecutorArguments({
356
356
  llm,
357
- objective: 'exporter les pages Confluence du workspace juno',
357
+ objective: 'export the Confluence pages of the demo workspace',
358
358
  capability,
359
- workspace: 'juno',
359
+ workspace: 'demo',
360
360
  }),
361
361
  {},
362
362
  );
@@ -389,7 +389,7 @@ test('argument extraction keeps a value the vocabulary allows', async () => {
389
389
  llm,
390
390
  objective: 'exporter la source EAS_Avant_projet_ACPI',
391
391
  capability,
392
- workspace: 'acpi',
392
+ workspace: 'acme',
393
393
  }),
394
394
  { source_name: 'EAS_Avant_projet_ACPI' },
395
395
  );
@@ -674,10 +674,14 @@ function runtimeProvidersSection(session) {
674
674
  for (const [runtimeId, list] of byRuntime) {
675
675
  const health = list[0]?.health ?? 'unknown';
676
676
  const capabilities = list
677
- .map((agent) => agent.description?.capabilities?.[0]?.id ?? agent.agentInstanceId)
678
- .join(', ');
677
+ .map((agent) => agent.description?.capabilities?.[0]?.id ?? agent.agentInstanceId);
679
678
  lines.push(`${runtimeId}: ${health}`);
680
- lines.push(`capabilities: ${capabilities}`);
679
+ // One capability per line: the status pane is a fixed-width box that
680
+ // TRUNCATES long lines, and a joined list ended mid-word
681
+ // ("capabilities: agent.review, agent.research, agent"). A per-line list
682
+ // never truncates and stays scannable.
683
+ lines.push('capabilities:');
684
+ for (const capability of capabilities) lines.push(` ${capability}`);
681
685
  }
682
686
  return sectionBlock('Agentic runtime', lines);
683
687
  }
@@ -882,12 +886,13 @@ function componentInstallAction(missingImages) {
882
886
  return missingImages.length > 0 ? 'downloaded-and-installed-missing-components' : null;
883
887
  }
884
888
 
885
- export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [] }) {
889
+ export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [], detail = null }) {
886
890
  const facts = JSON.stringify({
887
891
  operation,
888
892
  target,
889
893
  status,
890
894
  ...(componentAction ? { componentAction, images } : {}),
895
+ ...(detail ? { detail } : {}),
891
896
  });
892
897
  return {
893
898
  output: facts,
@@ -969,7 +974,10 @@ export async function handleSlashCommand(line, context) {
969
974
  const runAgentCommand = async (fn, verb) => {
970
975
  try {
971
976
  step(`Agents: ${verb}ing external agents…`);
972
- const missingImages = await collectMissingImages(step, fn);
977
+ let outcome = null;
978
+ const missingImages = await collectMissingImages(step, async (options) => {
979
+ outcome = await fn(options);
980
+ });
973
981
  if (verb === 'start') {
974
982
  // `wiki-workspace agents up` generates the agent tokens into the
975
983
  // manager .env and adds the profiled entries to mcp.endpoints.json.
@@ -983,6 +991,17 @@ export async function handleSlashCommand(line, context) {
983
991
  loadManagerEnv({ override: true });
984
992
  await refreshMcpRuntimeStatus(context.session);
985
993
  }
994
+ if (outcome?.degraded) {
995
+ step('Agents: stack started degraded — one optional agent failed; the base agents are up.');
996
+ return localizedOperationResult({
997
+ operation: verb,
998
+ target: 'agents',
999
+ status: 'degraded',
1000
+ componentAction: componentInstallAction(missingImages),
1001
+ images: missingImages,
1002
+ detail: rawFailureText(outcome.degradedError),
1003
+ });
1004
+ }
986
1005
  return localizedOperationResult({
987
1006
  operation: verb,
988
1007
  target: 'agents',
@@ -1,7 +1,7 @@
1
1
  import { normalizeActivity } from './activity.js';
2
2
  import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
3
  import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
4
- import { formatRuntimeLogPayload, normalizeRuntimeLog } from './runtimeLog.js';
4
+ import { formatRuntimeLogPayload, isDispatchPlumbingLine, normalizeRuntimeLog, shortTaskLabel } from './runtimeLog.js';
5
5
  import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
6
6
  import { projectWorkflow } from './workflow.js';
7
7
  import { validateContractInDev } from '../contracts/schemas.js';
@@ -329,16 +329,13 @@ function applyEvent(state, event) {
329
329
  : state.planRevision + 1;
330
330
  return;
331
331
  case 'plan.received':
332
- state.logs.push(`Plan received for run ${String(event.runId ?? event.payload?.runId ?? '')}`.trim());
333
- state.logs = state.logs.slice(-200);
332
+ appendLog(state, `${logTime(event.ts)} Plan received for run ${String(event.runId ?? event.payload?.runId ?? '')}`.trim());
334
333
  return;
335
334
  case 'plan.validated':
336
- state.logs.push(`Plan validated for run ${String(event.runId ?? event.payload?.runId ?? '')}`.trim());
337
- state.logs = state.logs.slice(-200);
335
+ appendLog(state, `${logTime(event.ts)} Plan validated for run ${String(event.runId ?? event.payload?.runId ?? '')}`.trim());
338
336
  return;
339
337
  case 'plan.rejected':
340
- state.logs.push(`Plan rejected: ${formatPlanErrors(event.payload?.errors)}`);
341
- state.logs = state.logs.slice(-200);
338
+ appendLog(state, `${logTime(event.ts)} Plan rejected: ${formatPlanErrors(event.payload?.errors)}`.trim());
342
339
  return;
343
340
  case 'task_group.created':
344
341
  return;
@@ -346,28 +343,24 @@ function applyEvent(state, event) {
346
343
  appendCreatedTask(state, event.payload?.task);
347
344
  return;
348
345
  case 'task.assigned':
349
- state.logs.push(`Task assigned: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
350
- state.logs = state.logs.slice(-200);
346
+ appendLog(state, taskLogLine(state, event, 'assigned'));
351
347
  return;
352
348
  case 'task.started':
353
- state.logs.push(`Task started: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
354
- state.logs = state.logs.slice(-200);
349
+ // Silent: always follows `task.assigned` (same task, milliseconds apart),
350
+ // which already printed the "started" line.
355
351
  return;
356
352
  case 'task.retry_scheduled':
357
- state.logs.push(`Task retry scheduled: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
358
- state.logs = state.logs.slice(-200);
353
+ appendLog(state, taskLogLine(state, event, 'retry'));
359
354
  return;
360
355
  case 'task.result_returned':
361
- state.logs.push(`Task result returned: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
362
- state.logs = state.logs.slice(-200);
356
+ // Silent: an internal transition immediately followed by
357
+ // `task.completed`/`task.failed`, which carry the same result.
363
358
  return;
364
359
  case 'task.completed':
365
- state.logs.push(`Task completed: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
366
- state.logs = state.logs.slice(-200);
360
+ appendLog(state, taskLogLine(state, event, 'completed'));
367
361
  return;
368
362
  case 'task.failed':
369
- state.logs.push(`Task failed: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
370
- state.logs = state.logs.slice(-200);
363
+ appendLog(state, taskLogLine(state, event, 'failed'));
371
364
  return;
372
365
  case 'plan.revision_changed':
373
366
  if (Array.isArray(event.payload?.tasks)) {
@@ -381,12 +374,11 @@ function applyEvent(state, event) {
381
374
  // référence au state : `updatePlanStep` reste une fonction sur un
382
375
  // plan, et le journal reste la responsabilité de l'appelant.
383
376
  const anomaly = updatePlanStep(state.plan, event.payload ?? {});
384
- if (anomaly) state.logs.push(anomaly);
377
+ if (anomaly) appendLog(state, `${logTime(event.ts)} ${anomaly}`.trim());
385
378
  }
386
379
  return;
387
380
  case 'control_message_received':
388
- state.logs.push(`Control message: ${String(event.payload?.input ?? '')}`);
389
- state.logs = state.logs.slice(-200);
381
+ appendLog(state, `${logTime(event.ts)} Control message: ${String(event.payload?.input ?? '')}`.trim());
390
382
  return;
391
383
  case 'plan_patch_proposed':
392
384
  upsertPlanPatch(state, {
@@ -568,7 +560,7 @@ function applyEvent(state, event) {
568
560
  return;
569
561
  case 'run_cancelled':
570
562
  state.status = 'cancelled';
571
- state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
563
+ appendLog(state, `${logTime(event.ts)} ${String(event.payload?.message ?? 'Agent run cancelled.')}`.trim());
572
564
  // A cancelled run must not leave its plan steps "running/pending" and
573
565
  // its activities spinning in the panels: mark every non-terminal one
574
566
  // cancelled so the display reflects reality immediately.
@@ -590,7 +582,7 @@ function applyEvent(state, event) {
590
582
  already in hand. What ends a run is essential by construction; the
591
583
  prefix states that instead of hoping the wording says so.
592
584
  */
593
- state.logs.push(`Run failed: ${String(event.payload?.message ?? 'Agent run failed.')}`);
585
+ appendLog(state, `${logTime(event.ts)} Run failed: ${String(event.payload?.message ?? 'Agent run failed.')}`.trim());
594
586
  // A dead run must not leave "pending" plan steps and spinning
595
587
  // activities in the persisted projection: they reappeared as ghosts
596
588
  // at every relaunch ("des trucs dans le plan qui n'existent pas") and
@@ -667,7 +659,7 @@ function applyEvent(state, event) {
667
659
  }, event.ts);
668
660
  return;
669
661
  case 'runtime_log':
670
- state.logs.push(formatRuntimeLogPayload(event.payload ?? {}, event.ts));
662
+ appendLog(state, formatRuntimeLogPayload(event.payload ?? {}, event.ts));
671
663
  return;
672
664
  default:
673
665
  return;
@@ -1052,6 +1044,111 @@ function formatPlanErrors(errors) {
1052
1044
  : 'unknown';
1053
1045
  }
1054
1046
 
1047
+ const LOG_TIME_PREFIX = /^\d{2}:\d{2}:\d{2}\s+/;
1048
+ const LOG_REPEAT_SUFFIX = / \(×\d+\)$/;
1049
+
1050
+ /*
1051
+ The single writer into `state.logs` — every push goes through here.
1052
+
1053
+ Two jobs the ad-hoc `push(...); logs = logs.slice(-200)` pairs did unevenly:
1054
+ the 200-entry cap is now applied on every path (the `runtime_log` case never
1055
+ capped and grew without bound during a long run), and a *plumbing* entry
1056
+ (isDispatchPlumbingLine) identical to the one before it — once the HH:MM:SS
1057
+ prefix is dropped — is collapsed into a `(×N)` counter instead of being
1058
+ printed again. `agent_status` polling and repeated progress ticks otherwise
1059
+ bury every readable event under dozens of identical rows. A business line (a
1060
+ ▸/✓/✗/↻ transition, "Run failed:", a control message) is never collapsed, so
1061
+ a second genuine failure and its timing are never folded away.
1062
+ */
1063
+ function appendLog(state, line) {
1064
+ const text = String(line ?? '').trim();
1065
+ if (!text) return;
1066
+ const last = state.logs.at(-1);
1067
+ if (last != null && isDispatchPlumbingLine(text) && isDispatchPlumbingLine(last)) {
1068
+ const bare = (value) => String(value).replace(LOG_TIME_PREFIX, '').replace(LOG_REPEAT_SUFFIX, '');
1069
+ if (bare(last) === bare(text)) {
1070
+ const count = Number(String(last).match(/ \(×(\d+)\)$/)?.[1] ?? '1') + 1;
1071
+ // Keep the LATEST timestamp so the panel shows when it last repeated.
1072
+ state.logs[state.logs.length - 1] = `${text.replace(LOG_REPEAT_SUFFIX, '')} (×${count})`;
1073
+ return;
1074
+ }
1075
+ }
1076
+ state.logs.push(text);
1077
+ if (state.logs.length > 200) state.logs = state.logs.slice(-200);
1078
+ }
1079
+
1080
+ function logTime(ts) {
1081
+ const date = ts ? new Date(ts) : new Date();
1082
+ return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(11, 19);
1083
+ }
1084
+
1085
+ function planTaskById(state, taskId) {
1086
+ const id = String(taskId ?? '');
1087
+ if (!id) return null;
1088
+ return (state.plan ?? []).find((step) => String(step.id ?? step.step) === id) ?? null;
1089
+ }
1090
+
1091
+ /*
1092
+ The persisted taskId is `<runId-uuid>:<slug>-<hash8>` — neither the UUID nor
1093
+ the trailing hash means anything to a reader. Prefer the plan step's business
1094
+ label ("Polish deliverable: proposition/presentation"), then its description,
1095
+ and only fall back to a de-slugified task name when the plan carries neither.
1096
+ */
1097
+ function taskLabelFor(state, taskId) {
1098
+ const step = planTaskById(state, taskId);
1099
+ const label = step?.label ?? step?.description ?? null;
1100
+ if (label && !/^Step \d+$/.test(label)) return label;
1101
+ return shortTaskLabel(taskId) || String(taskId ?? '') || 'task';
1102
+ }
1103
+
1104
+ // One readable line per real task transition. `task.started` and
1105
+ // `task.result_returned` are deliberately silent in the reducer — each is an
1106
+ // internal step between two lines this function already prints (`assigned`
1107
+ // then `completed`/`failed`), and printing them doubled every task in the
1108
+ // Runtime panel.
1109
+ function taskLogLine(state, event, kind) {
1110
+ const payload = event.payload ?? {};
1111
+ const taskId = event.taskId ?? payload.taskId ?? '';
1112
+ const label = taskLabelFor(state, taskId);
1113
+ const time = logTime(event.ts);
1114
+ const step = planTaskById(state, taskId);
1115
+ const capability = payload.assignment?.capability ?? step?.requiredCapability ?? null;
1116
+ const agent = payload.assignment?.agentInstanceId
1117
+ ?? payload.agentInstanceId
1118
+ ?? payload.result?.assignment?.agentInstanceId
1119
+ ?? payload.assignment?.agentId
1120
+ ?? null;
1121
+
1122
+ if (kind === 'assigned') {
1123
+ const context = [capability, agent && `→ ${agent}`].filter(Boolean).join(' ');
1124
+ return `${time} ▸ ${label} — started${context ? ` (${context})` : ''}`.trim();
1125
+ }
1126
+ if (kind === 'retry') {
1127
+ const attempt = payload.attempts ?? payload.attempt ?? null;
1128
+ const max = payload.maxAttempts ?? null;
1129
+ const reason = payload.reason
1130
+ ?? payload.error?.code
1131
+ ?? payload.error?.message
1132
+ ?? 'retryable error';
1133
+ const nth = attempt != null ? ` ${attempt}${max ? `/${max}` : ''}` : '';
1134
+ return `${time} ↻ ${label} — retry${nth} (${reason})`.trim();
1135
+ }
1136
+
1137
+ const result = payload.result ?? {};
1138
+ if (kind === 'failed') {
1139
+ const error = result.error?.code
1140
+ ?? result.error?.message
1141
+ ?? payload.error?.code
1142
+ ?? payload.error?.message
1143
+ ?? (result.status && result.status !== 'succeeded' ? result.status : null);
1144
+ return `${time} ✗ ${label} — failed${error ? `: ${error}` : ''}`.trim();
1145
+ }
1146
+ // completed
1147
+ const outputs = result.outputRefs ?? result.result?.outputRefs ?? [];
1148
+ const count = Array.isArray(outputs) ? outputs.length : 0;
1149
+ return `${time} ✓ ${label} — done${count ? ` (${count} output${count > 1 ? 's' : ''})` : ''}`.trim();
1150
+ }
1151
+
1055
1152
  function cloneRef(value) {
1056
1153
  return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : String(value);
1057
1154
  }
@@ -720,8 +720,33 @@ test('run_error names the failure so the essential journal cannot filter it out'
720
720
 
721
721
  assert.equal(state.status, 'error');
722
722
  const line = state.logs.at(-1);
723
- assert.match(line, /^Run failed: /);
723
+ assert.match(line, /^\d{2}:\d{2}:\d{2} Run failed: /);
724
724
  assert.match(line, /workspace\.restore/);
725
725
  // Le mot qui rend l'entrée « essentielle » pour le journal serve.
726
726
  assert.match(line, /failed/i);
727
727
  });
728
+
729
+ test('appendLog collapses repeated dispatch plumbing but never a repeated business failure', () => {
730
+ const ev = (type, payload, ts, taskId = null) => ({ id: `${type}-${ts}`, ts, type, payload, taskId });
731
+ // Two identical agent_status polls, seconds apart → one (×2) row.
732
+ const polling = reduceAgentEvents([
733
+ ev('runtime_log', { event: 'agent_status', runId: 'r', taskId: 't', detail: 'production_status' }, '2026-07-08T14:00:01.000Z'),
734
+ ev('runtime_log', { event: 'agent_status', runId: 'r', taskId: 't', detail: 'production_status' }, '2026-07-08T14:00:04.000Z'),
735
+ ]);
736
+ assert.equal(polling.logs.length, 1);
737
+ assert.match(polling.logs[0], / \(×2\)$/);
738
+ assert.match(polling.logs[0], /^14:00:04 /, 'the counter keeps the most recent timestamp');
739
+
740
+ // A retry that fails the SAME way twice produces two byte-identical failure
741
+ // lines (bar the timestamp) — both must stay visible, a degradation must
742
+ // announce itself (root CLAUDE.md). The guard is that ✗ lines are not
743
+ // plumbing, not that the text differs.
744
+ const failPayload = { taskId: 'a', result: { status: 'failed', error: { code: 'rate_limit' } } };
745
+ const failures = reduceAgentEvents([
746
+ ev('task.failed', failPayload, '2026-07-08T14:00:01.000Z', 'a'),
747
+ ev('task.failed', failPayload, '2026-07-08T14:05:09.000Z', 'a'),
748
+ ]);
749
+ const failLines = failures.logs.filter((l) => /✗ a — failed: rate_limit/.test(l));
750
+ assert.equal(failLines.length, 2);
751
+ assert.equal(failLines.some((l) => / \(×\d+\)$/.test(l)), false);
752
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.70",
3
- "commit": "1cb3a19"
2
+ "version": "0.15.71",
3
+ "commit": "d5f51af"
4
4
  }
@@ -28,7 +28,7 @@ test('the fallback hint drops the command echo, compose warnings and host paths'
28
28
  message: [
29
29
  COMPOSE_COMMAND,
30
30
  'time="2026-07-28T11:37:41+02:00" level=warning msg="The \\"CONNECTORS_MCP_PORT\\" variable is not set."',
31
- 'error while creating mount source path /mnt/c/Users/p/Documents/docker/llm-wiki/workspaces/juno: denied',
31
+ 'error while creating mount source path /mnt/c/Users/p/Documents/docker/llm-wiki/workspaces/demo: denied',
32
32
  ].join('\n'),
33
33
  };
34
34
 
@@ -39,7 +39,7 @@ test('the fallback hint drops the command echo, compose warnings and host paths'
39
39
  // Only the basename survives: absolute paths describe this machine's install
40
40
  // layout and mean nothing to the person reading the answer.
41
41
  assert.doesNotMatch(hint, /\/mnt\/c/);
42
- assert.match(hint, /juno/);
42
+ assert.match(hint, /demo/);
43
43
  });
44
44
 
45
45
  test('a failed operation reaches Donna as facts, never as docker output', () => {
@@ -38,10 +38,10 @@ test('artifactFromToolCall ignores read tools and tools without a path', () => {
38
38
  });
39
39
 
40
40
  test('currentArtifactFor is workspace-scoped', () => {
41
- const artifact = { workspace: 'acpi', path: 'templates/notes/basic.md', kind: 'template' };
42
- assert.equal(currentArtifactFor({ workspace: 'acpi', currentArtifact: artifact }), artifact);
41
+ const artifact = { workspace: 'acme', path: 'templates/notes/basic.md', kind: 'template' };
42
+ assert.equal(currentArtifactFor({ workspace: 'acme', currentArtifact: artifact }), artifact);
43
43
  assert.equal(currentArtifactFor({ workspace: 'other', currentArtifact: artifact }), null);
44
- assert.equal(currentArtifactFor({ workspace: 'acpi' }), null);
44
+ assert.equal(currentArtifactFor({ workspace: 'acme' }), null);
45
45
  });
46
46
 
47
47
  test('currentArtifactPromptLine names the artifact for follow-up edits', () => {
@@ -52,10 +52,10 @@ test('currentArtifactPromptLine names the artifact for follow-up edits', () => {
52
52
  });
53
53
 
54
54
  test('rememberArtifact records a workspace-scoped artifact and ignores empty paths', () => {
55
- const session = { workspace: 'acpi' };
55
+ const session = { workspace: 'acme' };
56
56
  rememberArtifact(session, { path: 'templates/notes/basic.md', kind: 'template' });
57
57
  assert.equal(session.currentArtifact.path, 'templates/notes/basic.md');
58
- assert.equal(session.currentArtifact.workspace, 'acpi');
58
+ assert.equal(session.currentArtifact.workspace, 'acme');
59
59
  assert.equal(session.currentArtifact.kind, 'template');
60
60
  rememberArtifact(session, { path: ' ', kind: 'template' });
61
61
  assert.equal(session.currentArtifact.path, 'templates/notes/basic.md');
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.70';
4
+ const WIKI_MANAGER_VERSION = '0.15.71';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -830,7 +830,7 @@ test('callMcpTool re-negotiates and replays once when the agent drops the sessio
830
830
 
831
831
  try {
832
832
  const endpoint = { status: 'connected', url: 'http://127.0.0.1:3336/mcp/' };
833
- const result = await callMcpTool({ cme: endpoint }, 'cme', 'cme_status', { workspace: 'juno' });
833
+ const result = await callMcpTool({ cme: endpoint }, 'cme', 'cme_status', { workspace: 'demo' });
834
834
 
835
835
  assert.equal(result.content[0].text, 'status: configured');
836
836
  assert.deepEqual(requests, [