@dotdrelle/wiki-manager 0.15.57 → 0.15.60

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.
@@ -130,7 +130,7 @@ services:
130
130
  # error. Every compose-deployed ingest then ran without the Lot 4 barrier
131
131
  # and left the published map stale — the very defect that work fixed.
132
132
  # `copy` stays out on purpose: it is the legacy step, opt-in only.
133
- - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,taxonomy,build,export,polish,restore,pipeline}
133
+ - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,concepts,reclassify-concepts,taxonomy,build,export,polish,restore,pipeline}
134
134
  - PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
135
135
  # Parallelism levers — effective concurrency ≈ recommendedConcurrency.
136
136
  # Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.57",
3
+ "version": "0.15.60",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,6 @@ import { loadWorkspaceProfile, updateWorkspaceProfilePreference } from '../core/
28
28
  import { artifactFromToolCall, currentArtifactFor, currentArtifactPromptLine, rememberArtifact } from '../core/currentArtifact.js';
29
29
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
30
30
  import { fetchRuntimeState, postRuntimeCancel, postRuntimeControl, postRuntimeDelegate, postRuntimeKill, postRuntimeSkill } from '../runtime/client.js';
31
- import { controlLanguage } from '../runtime/controlMessages.js';
32
31
 
33
32
  const MAX_TOOL_ITERATIONS = 80;
34
33
  /**
@@ -433,10 +432,6 @@ export function bareToolCallJson(content, tools = []) {
433
432
  return hasArguments ? name : null;
434
433
  }
435
434
 
436
- function localizedFailure(session, english, french) {
437
- return controlLanguage(session) === 'fr' ? french : english;
438
- }
439
-
440
435
  function parseActionJson(text) {
441
436
  const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
442
437
  if (!cleaned) return null;
@@ -1548,11 +1543,7 @@ export function createAgentGraph(options = {}) {
1548
1543
  invalidToolCallRetries: retries + 1,
1549
1544
  };
1550
1545
  }
1551
- const failure = localizedFailure(
1552
- state.session,
1553
- 'Action not executed: the model generated an incomplete tool call.',
1554
- 'Action non exécutée : l’appel d’outil généré par le modèle était incomplet.',
1555
- );
1546
+ const failure = 'Action not executed: the model generated an incomplete tool call.';
1556
1547
  emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: failure });
1557
1548
  return { response: failure, pendingToolCalls: null, readyToStream: false };
1558
1549
  }
@@ -1616,11 +1607,7 @@ export function createAgentGraph(options = {}) {
1616
1607
  };
1617
1608
  }
1618
1609
  state.session._onStreamReset?.();
1619
- const failure = localizedFailure(
1620
- state.session,
1621
- 'Action not executed: Donna repeatedly printed an internal tool request instead of calling it. No result was created.',
1622
- 'Action non exécutée : Donna a affiché à plusieurs reprises une requête interne au lieu d’appeler l’outil. Aucun résultat n’a été créé.',
1623
- );
1610
+ const failure = 'Action not executed: Donna repeatedly printed an internal tool request instead of calling it. No result was created.';
1624
1611
  emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: failure });
1625
1612
  return { response: failure, pendingToolCalls: null, readyToStream: false };
1626
1613
  }
@@ -1689,11 +1676,7 @@ export function createAgentGraph(options = {}) {
1689
1676
 
1690
1677
  if (runtimeExecution && state.retryWithoutTool) {
1691
1678
  state.session._onStreamReset?.();
1692
- const failure = localizedFailure(
1693
- state.session,
1694
- 'Action not executed: Donna did not call any available tool. No job or result was created.',
1695
- 'Action non exécutée : Donna n’a appelé aucun outil disponible. Aucun job ni résultat n’a été créé.',
1696
- );
1679
+ const failure = 'Action not executed: Donna did not call any available tool. No job or result was created.';
1697
1680
  emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: failure });
1698
1681
  return {
1699
1682
  response: failure,
@@ -2281,8 +2281,8 @@ test('LOT F: repeated bare tool-call JSON never reaches the user', async () => {
2281
2281
  assert.ok(offered.includes('production__production_start_job'), 'the turn must really offer the tool');
2282
2282
  assert.equal(calls, 3, 'two retries, then the guard');
2283
2283
  assert.doesNotMatch(result.response, /production__production_start_job/);
2284
- assert.match(result.response, /a affiché à plusieurs reprises une requête interne/);
2285
- assert.match(result.response, /Aucun .*résultat n’a été créé/);
2284
+ assert.match(result.response, /repeatedly printed an internal tool request/);
2285
+ assert.match(result.response, /No .*result was created/);
2286
2286
  });
2287
2287
 
2288
2288
  /*
@@ -2326,7 +2326,7 @@ test('LOT F: a skill re-invoking itself as bare JSON is neither executed nor sho
2326
2326
  assert.deepEqual(ran, [], 'a call written as text must never reach the skill runner');
2327
2327
  assert.doesNotMatch(result.response, /runtime__run_skill/);
2328
2328
  assert.doesNotMatch(result.response, /[{}]/, 'no JSON fragment may survive in the user-facing answer');
2329
- assert.match(result.response, /a affiché à plusieurs reprises une requête interne/);
2329
+ assert.match(result.response, /repeatedly printed an internal tool request/);
2330
2330
  assert.ok(streamResets >= 1, 'the partially streamed payload must be wiped before the guard message');
2331
2331
  assert.ok(calls >= 2, 'the first occurrence is retried, not surfaced');
2332
2332
 
@@ -2338,7 +2338,7 @@ test('LOT F: a skill re-invoking itself as bare JSON is neither executed nor sho
2338
2338
  assert.equal(guard[0].payload.content, result.response);
2339
2339
  });
2340
2340
 
2341
- test('LOT F: the guard message follows the session language', async () => {
2341
+ test('LOT F: the guard message stays English-only for every session language', async () => {
2342
2342
  const raw = '{"name":"production__production_start_job","arguments":{"type":"build"}}';
2343
2343
  const answer = async (language) => {
2344
2344
  const session = sessionBase({
@@ -2353,10 +2353,10 @@ test('LOT F: the guard message follows the session language', async () => {
2353
2353
  return result.response;
2354
2354
  };
2355
2355
 
2356
- // Le message de garde est produit par le code, pas par le modèle : il doit
2357
- // suivre la langue configurée au lieu d'imposer l'anglais comme le faisait
2358
- // l'ancien texte injecté par l'UI.
2356
+ // The guard message is produced by deterministic code, not by the model, and
2357
+ // this lane deliberately does not run an LLM turn — so it is English-only
2358
+ // rather than a hardcoded catalog that would leave most languages unanswered.
2359
2359
  assert.match(await answer('en-US'), /repeatedly printed an internal tool request/);
2360
2360
  assert.match(await answer(undefined), /repeatedly printed an internal tool request/);
2361
- assert.match(await answer('fr'), /a affiché à plusieurs reprises une requête interne/);
2361
+ assert.match(await answer('fr'), /repeatedly printed an internal tool request/);
2362
2362
  });
package/src/agent/llm.js CHANGED
@@ -26,9 +26,10 @@ export function createLlmClientFromWikiConfig(config) {
26
26
  }
27
27
 
28
28
  return {
29
- async complete({ system, input }) {
29
+ async complete({ system, input, signal }) {
30
30
  const response = await fetch(`${baseUrl}/chat/completions`, {
31
31
  method: 'POST',
32
+ signal,
32
33
  headers: {
33
34
  Authorization: `Bearer ${apiKey}`,
34
35
  'Content-Type': 'application/json',
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.57",
3
- "commit": "8c83b75"
2
+ "version": "0.15.60",
3
+ "commit": "8c5c615"
4
4
  }
@@ -45,6 +45,36 @@ test('every shipped default allows the taxonomy step', async () => {
45
45
  assert.match(String(allowed), /(?:^|,)taxonomy(?:,|})/);
46
46
  });
47
47
 
48
+ test('every shipped default allows the concepts step', async () => {
49
+ /*
50
+ Same silent-omission risk as `taxonomy` above, one lot earlier: without
51
+ `concepts`, `wiki concepts --apply` (which writes wiki/concepts-grid.md) is
52
+ never reachable through /wiki-sync, /pipeline, or any orchestrated flow.
53
+ Every ingest then files every concept page under the reserved
54
+ `unclassified` class forever, with nothing surfacing why.
55
+ */
56
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
57
+ const compose = YAML.parse(raw);
58
+ const allowed = compose.services['production-mcp'].environment
59
+ .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
60
+ assert.match(String(allowed), /(?:^|,)concepts(?:,|})/);
61
+ });
62
+
63
+ test('every shipped default allows the reclassify-concepts step', async () => {
64
+ /*
65
+ One step further than `concepts`: without `reclassify-concepts`, a page
66
+ already stuck under wiki/concepts/unclassified stays there even after a
67
+ grid exists — re-ingesting its source is not a reliable fix, since the
68
+ ingest prompt updates an existing leaf at its existing path instead of
69
+ moving it.
70
+ */
71
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
72
+ const compose = YAML.parse(raw);
73
+ const allowed = compose.services['production-mcp'].environment
74
+ .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
75
+ assert.match(String(allowed), /(?:^|,)reclassify-concepts(?:,|})/);
76
+ });
77
+
48
78
  test('shipped compose files never carry a build context', async () => {
49
79
  // Ces deux fichiers partent dans le paquet npm, où les dépôts frères
50
80
  // (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
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.57';
4
+ const WIKI_MANAGER_VERSION = '0.15.60';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -76,6 +76,17 @@ function normalizeExternalUrlForRuntime(url) {
76
76
  // server's entry shaped differently from every other. It is folded into
77
77
  // `allow` on read so existing installs keep working without regenerating
78
78
  // their endpoints file, but nothing writes it any more.
79
+ // The packaged example and every scaffolded mcp.endpoints.json declare the
80
+ // two built-in workspace servers' chatAccess under their public/documented
81
+ // names ("llm-wiki", "wiki-production" — see PROTECTED_SERVERS in
82
+ // mcpEndpoints.js and the root CLAUDE.md). Those servers are actually
83
+ // discovered into session.mcp under different internal keys ("wiki",
84
+ // "production" — MCP_SERVICE_MAP below). Without this alias, chatAllowedTools'
85
+ // intersection of session.mcp against chatAccess.servers never matches the
86
+ // built-in servers, so /chat silently gets zero wiki/production tools no
87
+ // matter what is configured — alias both spellings onto the internal key.
88
+ const BUILTIN_CHAT_ACCESS_ALIASES = { 'llm-wiki': 'wiki', 'wiki-production': 'production' };
89
+
79
90
  export function readChatAccessConfig() {
80
91
  const filePath = managerMcpEndpointsFile();
81
92
  if (!existsSync(filePath)) return null;
@@ -84,19 +95,21 @@ export function readChatAccessConfig() {
84
95
  const chatAccess = raw?.chatAccess;
85
96
  if (!chatAccess || typeof chatAccess !== 'object' || Array.isArray(chatAccess)) return null;
86
97
  const servers = {};
87
- for (const [name, entry] of Object.entries(chatAccess.servers ?? {})) {
98
+ for (const [rawName, entry] of Object.entries(chatAccess.servers ?? {})) {
99
+ const name = BUILTIN_CHAT_ACCESS_ALIASES[rawName] ?? rawName;
88
100
  // "*" is also commonly written as a one-element array (["*"]) since every
89
101
  // other "allow" example in this config is an array of tool names — treat
90
102
  // both forms as the same wildcard rather than silently allowing nothing.
91
103
  const legacyActions = Array.isArray(entry?.allowActions)
92
104
  ? entry.allowActions.map(String).filter(Boolean)
93
105
  : [];
94
- if (entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*')) {
106
+ const isWildcard = entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*');
107
+ const priorAllow = servers[name]?.allow;
108
+ if (isWildcard || priorAllow === '*') {
95
109
  servers[name] = { allow: '*' };
96
- } else if (Array.isArray(entry?.allow)) {
97
- servers[name] = { allow: [...new Set([...entry.allow.map(String).filter(Boolean), ...legacyActions])] };
98
- } else if (legacyActions.length > 0) {
99
- servers[name] = { allow: legacyActions };
110
+ } else if (Array.isArray(entry?.allow) || legacyActions.length > 0) {
111
+ const merged = [...(Array.isArray(priorAllow) ? priorAllow : []), ...(Array.isArray(entry?.allow) ? entry.allow.map(String).filter(Boolean) : []), ...legacyActions];
112
+ servers[name] = { allow: [...new Set(merged)] };
100
113
  }
101
114
  }
102
115
  const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
@@ -9,6 +9,7 @@ import {
9
9
  callMcpTool,
10
10
  discoverMcpTools,
11
11
  formatMcpToolsForAgent,
12
+ readChatAccessConfig,
12
13
  resetMcpSessionsForTests,
13
14
  resetMcpThrottleForTests,
14
15
  resolveRetryPolicy,
@@ -159,6 +160,71 @@ test('buildMcpStatus interpolates external endpoints from manager .env', async (
159
160
  }
160
161
  });
161
162
 
163
+ test('readChatAccessConfig aliases the built-in servers\' public names onto their internal session.mcp keys', async () => {
164
+ // Every shipped example and scaffolded mcp.endpoints.json declares
165
+ // chatAccess for the built-in workspace servers under their public names
166
+ // ("llm-wiki", "wiki-production"), but session.mcp discovers them under
167
+ // different internal keys ("wiki", "production" — MCP_SERVICE_MAP).
168
+ // Without aliasing, chatAllowedTools' intersection against session.mcp
169
+ // never matches these entries and /chat silently gets zero wiki/production
170
+ // tools no matter what is configured.
171
+ const originalCwd = process.cwd();
172
+ const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-chat-access-'));
173
+ await writeFile(
174
+ path.join(root, 'mcp.endpoints.json'),
175
+ JSON.stringify({
176
+ mcpServers: {},
177
+ chatAccess: {
178
+ maxToolIterations: 8,
179
+ servers: {
180
+ 'llm-wiki': { allow: ['wiki_search_context', 'wiki_read_page'] },
181
+ 'wiki-production': { allow: ['production_job_status'] },
182
+ cme: { allow: ['cme_status'] },
183
+ },
184
+ },
185
+ }),
186
+ 'utf8',
187
+ );
188
+
189
+ try {
190
+ process.chdir(root);
191
+ const config = readChatAccessConfig();
192
+ assert.deepEqual(config.servers.wiki, { allow: ['wiki_search_context', 'wiki_read_page'] });
193
+ assert.deepEqual(config.servers.production, { allow: ['production_job_status'] });
194
+ assert.deepEqual(config.servers.cme, { allow: ['cme_status'] });
195
+ assert.equal(config.servers['llm-wiki'], undefined);
196
+ assert.equal(config.servers['wiki-production'], undefined);
197
+ } finally {
198
+ process.chdir(originalCwd);
199
+ }
200
+ });
201
+
202
+ test('readChatAccessConfig merges a wildcard from either the public or internal built-in name', async () => {
203
+ const originalCwd = process.cwd();
204
+ const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-chat-access-wildcard-'));
205
+ await writeFile(
206
+ path.join(root, 'mcp.endpoints.json'),
207
+ JSON.stringify({
208
+ mcpServers: {},
209
+ chatAccess: {
210
+ servers: {
211
+ wiki: { allow: ['wiki_read_page'] },
212
+ 'llm-wiki': { allow: '*' },
213
+ },
214
+ },
215
+ }),
216
+ 'utf8',
217
+ );
218
+
219
+ try {
220
+ process.chdir(root);
221
+ const config = readChatAccessConfig();
222
+ assert.deepEqual(config.servers.wiki, { allow: '*' });
223
+ } finally {
224
+ process.chdir(originalCwd);
225
+ }
226
+ });
227
+
162
228
  test('buildMcpStatus reloads external endpoint keys changed in manager .env', async () => {
163
229
  const originalCwd = process.cwd();
164
230
  const originalToken = process.env.TEST_EXTERNAL_TOKEN;
@@ -106,9 +106,22 @@ function resolveMentionedRegistryOperation(objective, candidates) {
106
106
  const text = normalizeText(objective);
107
107
 
108
108
  const aliasHits = candidates
109
- .filter((candidate) => (candidate.aliases ?? []).some((alias) =>
110
- phraseIn(normalizePhrase(alias), words, text)))
111
- .map((candidate) => ({ capability: candidate.id, operation: candidate.operations[0] }));
109
+ .map((candidate) => {
110
+ const matchedAlias = (candidate.aliases ?? []).find((alias) =>
111
+ phraseIn(normalizePhrase(alias), words, text));
112
+ if (matchedAlias === undefined) return null;
113
+ // A capability with more than one operation may declare
114
+ // aliasOperations, mapping the specific alias phrase that matched to
115
+ // the operation it actually names. Without it, operations[0]
116
+ // (alphabetical) is a silent guess: for knowledge.concepts this always
117
+ // picked the destructive grid-rebuild "concepts" operation, even when
118
+ // the matched alias ("reclassify concepts", "file unclassified
119
+ // concepts") named the safe, mechanical "reclassify-concepts" one —
120
+ // making that operation structurally unreachable from natural language.
121
+ const operation = candidate.aliasOperations?.[matchedAlias] ?? candidate.operations[0];
122
+ return { capability: candidate.id, operation };
123
+ })
124
+ .filter(Boolean);
112
125
  if (aliasHits.length === 1) return aliasHits[0];
113
126
  if (aliasHits.length > 1) return null;
114
127
 
@@ -142,8 +155,12 @@ export function capabilityCandidates(session) {
142
155
  const id = versionedId.includes('@') ? versionedId.slice(0, versionedId.lastIndexOf('@')) : versionedId;
143
156
  const operations = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.supportedOperations ?? []))].sort();
144
157
  const aliases = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.aliases ?? []))].sort();
158
+ const aliasOperations = Object.assign(
159
+ {},
160
+ ...(providers ?? []).map((provider) => provider?.capability?.aliasOperations ?? {}),
161
+ );
145
162
  const description = (providers ?? []).map((provider) => provider?.capability?.description).find(Boolean) ?? '';
146
- byId.set(id, { id, description, operations, aliases });
163
+ byId.set(id, { id, description, operations, aliases, aliasOperations });
147
164
  }
148
165
  return [...byId.values()].filter((item) => item.operations.length > 0).sort((a, b) => a.id.localeCompare(b.id));
149
166
  }
@@ -7,8 +7,8 @@ import {
7
7
  ObjectiveNotOrchestrableError,
8
8
  } from './objectiveResolver.js';
9
9
 
10
- function makeCapability(id, { operations = [], aliases = [], description = '' } = {}) {
11
- return { id, version: '1', description, supportedOperations: operations, aliases };
10
+ function makeCapability(id, { operations = [], aliases = [], aliasOperations = {}, description = '' } = {}) {
11
+ return { id, version: '1', description, supportedOperations: operations, aliases, aliasOperations };
12
12
  }
13
13
 
14
14
  function provider(agentInstanceId, capability) {
@@ -63,6 +63,7 @@ test('capabilityCandidates exposes aliases from the closed live registry', () =>
63
63
  description: 'Update knowledge from pending sources.',
64
64
  operations: ['ingest', 'ingest_apply', 'ingest_plan'],
65
65
  aliases: ['ingest', 'ingestion'],
66
+ aliasOperations: {},
66
67
  }]);
67
68
  });
68
69
 
@@ -121,6 +122,67 @@ test('resolveObjective resolves diagnose via alias despite the notification "sen
121
122
  assert.equal(result.operation, 'doctor');
122
123
  });
123
124
 
125
+ const concepts = makeCapability('knowledge.concepts', {
126
+ operations: ['concepts', 'reclassify-concepts'],
127
+ aliases: ['concept grid', 'reclassify concepts', 'file unclassified concepts'],
128
+ aliasOperations: {
129
+ 'concept grid': 'concepts',
130
+ 'reclassify concepts': 'reclassify-concepts',
131
+ 'file unclassified concepts': 'reclassify-concepts',
132
+ },
133
+ description: 'Synthesize the concept grid or file unclassified pages into it.',
134
+ });
135
+
136
+ /*
137
+ Regression: with two operations, [...new Set(supportedOperations)].sort()
138
+ alphabetizes to ["concepts", "reclassify-concepts"], so a naive alias hit
139
+ defaulting to operations[0] would ALWAYS resolve to "concepts" — the
140
+ destructive grid rebuild — even for aliases explicitly authored to reach the
141
+ safe "reclassify-concepts" operation. aliasOperations must be consulted
142
+ first.
143
+ */
144
+ test('resolveObjective routes "reclassify concepts" to reclassify-concepts, not operations[0]', async () => {
145
+ const session = sessionWith([provider('production-1', concepts)]);
146
+ session.llm.completeWithTools = async () => {
147
+ throw new Error('the aliased operation must not depend on LLM selection');
148
+ };
149
+ const result = await resolveObjective('Please reclassify concepts in the workspace', session);
150
+ assert.equal(result.capability, 'knowledge.concepts');
151
+ assert.equal(result.operation, 'reclassify-concepts');
152
+ });
153
+
154
+ test('resolveObjective routes "file unclassified concepts" to reclassify-concepts', async () => {
155
+ const session = sessionWith([provider('production-1', concepts)]);
156
+ session.llm.completeWithTools = async () => {
157
+ throw new Error('the aliased operation must not depend on LLM selection');
158
+ };
159
+ const result = await resolveObjective('File unclassified concepts into the grid', session);
160
+ assert.equal(result.capability, 'knowledge.concepts');
161
+ assert.equal(result.operation, 'reclassify-concepts');
162
+ });
163
+
164
+ test('resolveObjective routes "concept grid" to the concepts operation', async () => {
165
+ const session = sessionWith([provider('production-1', concepts)]);
166
+ session.llm.completeWithTools = async () => {
167
+ throw new Error('the aliased operation must not depend on LLM selection');
168
+ };
169
+ const result = await resolveObjective('Rebuild the concept grid', session);
170
+ assert.equal(result.capability, 'knowledge.concepts');
171
+ assert.equal(result.operation, 'concepts');
172
+ });
173
+
174
+ test('resolveObjective falls back to operations[0] when a matched alias has no aliasOperations entry', async () => {
175
+ // A capability that never declares aliasOperations (every existing
176
+ // single-operation capability) must keep working exactly as before.
177
+ const session = sessionWith(
178
+ [provider('production-1', diagnose)],
179
+ { capability: 'workspace.diagnose', operation: 'doctor' },
180
+ );
181
+ const result = await resolveObjective('diagnose the workspace', session);
182
+ assert.equal(result.capability, 'workspace.diagnose');
183
+ assert.equal(result.operation, 'doctor');
184
+ });
185
+
124
186
  test('objectiveForResolution strips notification and negative guardrails', () => {
125
187
  const clean = objectiveForResolution(
126
188
  'Ingest files. Do not build or publish deliverables. If a messaging connector is available, send a summary; otherwise skip notification silently.',
@@ -1,43 +1,26 @@
1
- // Deterministic, localized messages for the runtime control lane.
1
+ // Deterministic, English-only messages for the runtime control lane.
2
2
  //
3
3
  // Control-lane acknowledgements (run queued, ambiguous input, conversation
4
4
  // fallback…) are intentionally NOT generated by Donna: spending an LLM turn
5
5
  // to say "your request is queued" would reintroduce exactly the per-message
6
- // cost the orchestration refactor removed. But they are user-facing, so they
7
- // must follow the session's configured reply language. This catalog is the
8
- // single source for those strings — never hardcode a control-lane message in
9
- // the shell or the server directly.
6
+ // cost the orchestration refactor removed. They are therefore English-only —
7
+ // the one language this deterministic lane can guarantee — and are never
8
+ // localized by a hardcoded fr/en catalog (a French-only fallback would still
9
+ // leave every other language unanswered). Localized, personalised replies are
10
+ // Donna's job on the conversational paths (see generateSkillAcknowledgment).
11
+ // This catalog is the single source for these strings — never hardcode a
12
+ // control-lane message in the shell or the server directly.
10
13
 
11
14
  const CONTROL_MESSAGES = {
12
- queued_for_future_run: {
13
- en: 'Request added to the queue — it will start automatically after the current run.',
14
- fr: 'Demande ajoutée à la file — elle démarrera automatiquement à la fin du run en cours.',
15
- },
16
- plan_patch_proposed: {
17
- en: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
18
- fr: 'Modification de plan proposée. Approuvez-la explicitement pour l’appliquer au plan actif.',
19
- },
20
- ambiguous_control: {
21
- en: 'A run is already active, and this looks like a new action. Say "queue it" to run it after the current run, "modify the run" to change the active plan, "cancel" to stop the current run first — or wait for it to finish.',
22
- fr: 'Un run est déjà actif et ta demande ressemble à une nouvelle action. Dis « mets en file » pour l\'exécuter après le run en cours, « modifie le run » pour changer le plan actif, « annule » pour arrêter le run actuel — ou attends la fin.',
23
- },
24
- converse_while_running: {
25
- en: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
26
- fr: 'Un run est toujours actif. Ce message a été traité comme conversation et n’a pas créé de run en file.',
27
- },
28
- converse_while_idle: {
29
- en: 'Runtime is idle. This message was treated as conversation and did not create a run.',
30
- fr: 'Le runtime est inactif. Ce message a été traité comme conversation et n’a pas créé de run.',
31
- },
15
+ queued_for_future_run: 'Request added to the queue — it will start automatically after the current run.',
16
+ plan_patch_proposed: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
17
+ ambiguous_control: 'A run is already active, and this looks like a new action. Say "queue it" to run it after the current run, "modify the run" to change the active plan, "cancel" to stop the current run first — or wait for it to finish.',
18
+ converse_while_running: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
19
+ converse_while_idle: 'Runtime is idle. This message was treated as conversation and did not create a run.',
32
20
  };
33
21
 
34
- export function controlLanguage(session) {
35
- const raw = String(session?.language ?? 'en').toLowerCase();
36
- return raw.startsWith('fr') ? 'fr' : 'en';
37
- }
38
-
39
- export function controlMessage(session, key) {
22
+ export function controlMessage(_session, key) {
40
23
  const entry = CONTROL_MESSAGES[key];
41
24
  if (!entry) throw new Error(`Unknown control message key: ${key}`);
42
- return entry[controlLanguage(session)] ?? entry.en;
25
+ return entry;
43
26
  }
@@ -1,21 +1,15 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { controlLanguage, controlMessage } from './controlMessages.js';
3
+ import { controlMessage } from './controlMessages.js';
4
4
 
5
- test('controlLanguage maps fr locales to fr and everything else to en', () => {
6
- assert.equal(controlLanguage({ language: 'fr-FR' }), 'fr');
7
- assert.equal(controlLanguage({ language: 'fr' }), 'fr');
8
- assert.equal(controlLanguage({ language: 'en-US' }), 'en');
9
- assert.equal(controlLanguage({ language: null }), 'en');
10
- assert.equal(controlLanguage(null), 'en');
5
+ test('controlMessage returns the deterministic English acknowledgement regardless of locale', () => {
6
+ assert.match(controlMessage({ language: 'fr-FR' }, 'queued_for_future_run'), /added to the queue/);
7
+ assert.match(controlMessage({ language: 'es' }, 'queued_for_future_run'), /added to the queue/);
8
+ assert.match(controlMessage(null, 'queued_for_future_run'), /added to the queue/);
11
9
  });
12
10
 
13
- test('controlMessage returns the localized queued acknowledgement', () => {
14
- assert.match(controlMessage({ language: 'fr-FR' }, 'queued_for_future_run'), /ajoutée à la file/);
15
- assert.match(controlMessage({ language: 'en-US' }, 'queued_for_future_run'), /added to the queue/);
16
- });
17
-
18
- test('controlMessage falls back to en for unknown locales and throws on unknown keys', () => {
19
- assert.match(controlMessage({ language: 'de-DE' }, 'queued_for_future_run'), /added to the queue/);
11
+ test('controlMessage keeps every key in English and throws on unknown keys', () => {
12
+ assert.match(controlMessage({ language: 'fr' }, 'ambiguous_control'), /queue it/);
13
+ assert.match(controlMessage({ language: 'fr' }, 'converse_while_idle'), /treated as conversation/);
20
14
  assert.throws(() => controlMessage({ language: 'fr-FR' }, 'nope'), /Unknown control message key/);
21
15
  });
@@ -11,7 +11,7 @@ import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
11
11
  import { matchSkillInvocation } from '../core/skillInvocation.js';
12
12
  import { reconcileControlQueue } from './controlDrain.js';
13
13
  import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
14
- import { runSkillChain } from './skillRun.js';
14
+ import { generateSkillAcknowledgment, runSkillChain } from './skillRun.js';
15
15
  import { findSkill, listSkills } from '../core/skills.js';
16
16
 
17
17
  const PRIVATE_CONTROL_INPUTS = new WeakMap();
@@ -347,7 +347,8 @@ export function startRuntimeServer({
347
347
  if (skillMatch) {
348
348
  try {
349
349
  const result = await enqueueSkillInvocation(context, skillMatch);
350
- sendJson(response, 202, { accepted: true, kind: 'skill_chain', ...result, ...controlStatus(context, store) });
350
+ const explanation = await generateSkillAcknowledgment(context.session, result);
351
+ sendJson(response, 202, { accepted: true, kind: 'skill_chain', explanation, ...result, ...controlStatus(context, store) });
351
352
  } catch (err) {
352
353
  const error = skillInvocationErrorMessage(err);
353
354
  publishSkillInvocationFailure(context, input, error);
@@ -421,7 +422,8 @@ export function startRuntimeServer({
421
422
  if (skillMatch) {
422
423
  try {
423
424
  const result = await enqueueSkillInvocation(context, skillMatch);
424
- sendJson(response, 202, { accepted: true, kind: 'skill_chain', ...result, ...controlStatus(context, store) });
425
+ const explanation = await generateSkillAcknowledgment(context.session, result);
426
+ sendJson(response, 202, { accepted: true, kind: 'skill_chain', explanation, ...result, ...controlStatus(context, store) });
425
427
  } catch (err) {
426
428
  const error = skillInvocationErrorMessage(err);
427
429
  publishSkillInvocationFailure(context, input, error);
@@ -142,7 +142,7 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
142
142
  assert.equal(body.objectives, 2);
143
143
  assert.equal(env.runs.length, 2, 'the second objective must run after the first');
144
144
  assert.match(env.runs[0].input, /^Export the requested Confluence source/);
145
- assert.match(env.runs[1].input, /^Ingest the newly exported Markdown/);
145
+ assert.match(env.runs[1].input, /^Run the production pipeline over the newly exported Markdown/);
146
146
  // CME first, Production second — and the parameter reaches the step that
147
147
  // consumes it, not only the last objective.
148
148
  for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { applyLegacySkillPlaceholders, parseSkillArguments } from '../core/skillInvocation.js';
3
3
  import { compileSkillObjectives, createSkillCompilerFallback } from '../core/skillCompiler.js';
4
+ import { emitRuntimeLog } from './supervisor.js';
4
5
 
5
6
  const SKILL_PARAM_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
6
7
  const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
@@ -86,6 +87,7 @@ export async function runSkillChain(context, skill, {
86
87
  skill: skill.name,
87
88
  objectives: objectives.length,
88
89
  items,
90
+ publicInput,
89
91
  deprecatedPlaceholders: legacy.deprecatedPlaceholders,
90
92
  };
91
93
  }
@@ -97,6 +99,48 @@ export function formatPublicSkillInvocation(name, args = {}) {
97
99
  return `/${String(name ?? '').trim()}${values.length ? ` ${values.join(' ')}` : ''}`;
98
100
  }
99
101
 
102
+ /**
103
+ * Donna's launch acknowledgement for an executable skill.
104
+ *
105
+ * The reply is user-facing and echoes the caller's own arguments (a source, a
106
+ * file, a deliverable), so it must be generated by Donna in the session's
107
+ * configured language — a hardcoded fr/en catalog cannot cover every language
108
+ * and would drop the very parameters the user asked for. This is the single
109
+ * place that turns a compiled skill chain into a conversational reply; the
110
+ * browser and the shell both read the resulting `explanation`, never a
111
+ * language-specific string of their own.
112
+ *
113
+ * Degrades honestly: without an LLM client (no `.wikirc` LLM configured) there
114
+ * is nothing to localize with, so it falls back to a neutral, language-free
115
+ * acknowledgement instead of guessing a language.
116
+ */
117
+ export async function generateSkillAcknowledgment(session, { publicInput, objectives }) {
118
+ const count = Number(objectives) || 1;
119
+ const language = String(session?.language ?? '').trim().toLowerCase() || 'en';
120
+ const llm = session?.llm;
121
+ if (llm && typeof llm.complete === 'function') {
122
+ try {
123
+ // Same bound as compileSkillObjectives' llmFallback above: a hung or
124
+ // slow provider must not block the skill-launch HTTP response forever.
125
+ const reply = await llm.complete({
126
+ system: 'You are Donna, the workspace assistant. You acknowledge a launched workflow in the user\'s language. Be concise: exactly one short sentence.',
127
+ input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and is now running.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Return only that sentence, nothing else.`,
128
+ signal: AbortSignal.timeout(8_000),
129
+ });
130
+ const text = String(reply ?? '').trim();
131
+ if (text) return text;
132
+ emitRuntimeLog(session, 'skill-acknowledgment: LLM returned an empty reply, using the neutral fallback');
133
+ } catch (err) {
134
+ // A degradation must announce itself: silently falling through here
135
+ // hides the difference between "no LLM configured" (expected) and "the
136
+ // configured LLM is failing every call" (a real problem) — both would
137
+ // otherwise look identical from the Shell or serve UI.
138
+ emitRuntimeLog(session, `skill-acknowledgment: LLM call failed, using the neutral fallback — ${err instanceof Error ? err.message : String(err)}`);
139
+ }
140
+ }
141
+ return `Started ${publicInput} — ${count} step(s) in progress.`;
142
+ }
143
+
100
144
  function argumentError(message) {
101
145
  const error = new Error(message);
102
146
  error.code = 'skill_arguments_invalid';
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { formatPublicSkillInvocation, runSkillChain, validateNamedSkillArguments } from './skillRun.js';
3
+ import { formatPublicSkillInvocation, generateSkillAcknowledgment, runSkillChain, validateNamedSkillArguments } from './skillRun.js';
4
4
 
5
5
  const skill = { name: 'deliver', params: ['deliverable', 'polish'], body: 'Deliver the requested output.' };
6
6
 
@@ -82,3 +82,38 @@ test('runSkillChain starts a fresh stack for a top-level invocation', async () =
82
82
 
83
83
  assert.deepEqual(queued[0].skillStack, ['deliver']);
84
84
  });
85
+
86
+ test('generateSkillAcknowledgment asks Donna in the session language and echoes the invocation', async () => {
87
+ const calls = [];
88
+ const session = {
89
+ language: 'es',
90
+ llm: { complete: async (request) => { calls.push(request); return 'Lanzado /deliver deliverable="Informe" — 1 paso en cola.'; } },
91
+ };
92
+ const reply = await generateSkillAcknowledgment(session, { publicInput: '/deliver deliverable="Informe"', objectives: 1 });
93
+ assert.equal(reply, 'Lanzado /deliver deliverable="Informe" — 1 paso en cola.');
94
+ assert.equal(calls.length, 1);
95
+ assert.match(calls[0].input, /es/);
96
+ assert.match(calls[0].input, /\/deliver deliverable="Informe"/);
97
+ });
98
+
99
+ test('generateSkillAcknowledgment degrades to a neutral message without an LLM client', async () => {
100
+ const reply = await generateSkillAcknowledgment({ language: 'fr' }, { publicInput: '/wiki-ingest docs', objectives: 2 });
101
+ assert.equal(reply, 'Started /wiki-ingest docs — 2 step(s) in progress.');
102
+ });
103
+
104
+ test('generateSkillAcknowledgment falls back when the LLM call fails', async () => {
105
+ const session = { language: 'en', llm: { complete: async () => { throw new Error('down'); } } };
106
+ const reply = await generateSkillAcknowledgment(session, { publicInput: '/deliver', objectives: 1 });
107
+ assert.equal(reply, 'Started /deliver — 1 step(s) in progress.');
108
+ });
109
+
110
+ test('generateSkillAcknowledgment announces an LLM failure instead of degrading silently', async () => {
111
+ // A degradation must announce itself: falling back to the neutral message
112
+ // with no trace anywhere makes "LLM unconfigured" (expected) and "LLM
113
+ // failing every call" (a real problem) look identical in the UI.
114
+ const session = { language: 'en', llm: { complete: async () => { throw new Error('provider timeout'); } } };
115
+ await generateSkillAcknowledgment(session, { publicInput: '/deliver', objectives: 1 });
116
+ const runtimeLogs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log');
117
+ assert.equal(runtimeLogs.length, 1);
118
+ assert.match(runtimeLogs[0].payload.detail ?? runtimeLogs[0].payload.message, /provider timeout/);
119
+ });
package/src/shell/repl.js CHANGED
@@ -1110,19 +1110,23 @@ export function shouldHandleFreeTextLocally(_line, session, { llmAvailable = Boo
1110
1110
  return { local: true, classification };
1111
1111
  }
1112
1112
 
1113
- // Shared by both the one-shot `/agent <question>` path in runLine and the
1114
- // interactive free-text-to-runtime path below: maps a submitRuntimeRun()
1115
- // outcome to the conversation message(s) it produces and a short log line,
1113
+ // Shared by every caller that turns a submitRuntimeRun()/submitRuntimeTurn()
1114
+ // outcome into the conversation message(s) it produces and a short log line —
1115
+ // the legacy TTY shell (runLine, the interactive free-text path, /approve's
1116
+ // ambiguous-choice resubmission below) and the OpenTUI shell (useAgent.ts) —
1116
1117
  // so the classification → message mapping isn't duplicated per call site.
1117
- function applyRuntimeOutcome(session, outcome, onLog, {
1118
+ // 'turn' (submitRuntimeTurn's default kind for a plain accepted turn) is
1119
+ // treated the same as submitRuntimeRun's 'accepted': the actual reply arrives
1120
+ // separately over the event stream, this call only logs.
1121
+ export function applyRuntimeOutcome(session, outcome, onLog, {
1118
1122
  ambiguousFallback = 'Runtime could not classify that message.',
1119
1123
  } = {}) {
1120
- if (outcome.kind === 'accepted') {
1124
+ if (outcome.kind === 'accepted' || outcome.kind === 'turn') {
1121
1125
  onLog('runtime: run accepted');
1122
- } else if (outcome.kind === 'queued') {
1126
+ } else if (outcome.kind === 'queued' || outcome.kind === 'enqueue_run') {
1123
1127
  conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Request added to the queue.') });
1124
1128
  onLog('runtime: control queued');
1125
- } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
1129
+ } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'modify_run') {
1126
1130
  conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime control message accepted.') });
1127
1131
  onLog(`runtime: ${outcome.kind}`);
1128
1132
  } else if (outcome.kind === 'ambiguous') {
@@ -1,6 +1,6 @@
1
1
  import { createSignal } from 'solid-js';
2
2
  import { postRuntimeCancel } from '../runtime/client.js';
3
- import { conversationMessages, recordRuntimeUnavailableAgentInput, runLine, shouldHandleFreeTextLocally, submitRuntimeTurn } from './repl.js';
3
+ import { applyRuntimeOutcome, conversationMessages, recordRuntimeUnavailableAgentInput, runLine, shouldHandleFreeTextLocally, submitRuntimeTurn } from './repl.js';
4
4
 
5
5
  export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; runtimeUnavailableReason?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
6
6
  const [busy, setBusy] = createSignal(false);
@@ -41,24 +41,12 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
41
41
  runtime: { url: props.runtimeUrl },
42
42
  session: props.session,
43
43
  });
44
- if (outcome.kind === 'turn' || (outcome as any).result?.accepted === true) {
45
- props.addLog('runtime: agent turn accepted');
46
- } else if (outcome.kind === 'queued') {
47
- // The server localizes control-lane acknowledgements from the
48
- // session language (src/runtime/controlMessages.js) — always prefer
49
- // its explanation over a local hardcoded string.
50
- const explanation = (outcome as any).result?.explanation ?? 'Request added to the queue.';
51
- conversationMessages(props.session).push({ role: 'command', content: String(explanation) });
52
- props.addLog('runtime: control queued');
53
- } else if ((outcome as any).result?.explanation) {
54
- // Control-lane kinds (cancel / approve / observe / modify_run…):
55
- // surface the server's localized explanation instead of an error.
56
- conversationMessages(props.session).push({ role: 'command', content: String((outcome as any).result.explanation) });
57
- props.addLog(`runtime: ${outcome.kind}`);
58
- } else {
59
- conversationMessages(props.session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
60
- props.addLog(`runtime error: ${outcome.message}`);
61
- }
44
+ // Same classification → message mapping as the legacy TTY shell —
45
+ // see applyRuntimeOutcome in repl.js. Control-lane acknowledgements
46
+ // (src/runtime/controlMessages.js) are deterministic and English-only
47
+ // by design, never localized; this only supplies the last-resort
48
+ // fallback text for a response that carries neither.
49
+ applyRuntimeOutcome(props.session, outcome, props.addLog);
62
50
  props.refresh();
63
51
  return { exit: false, runtime: true };
64
52
  }