@dotdrelle/wiki-manager 0.14.20 → 0.14.23

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.
@@ -403,7 +403,8 @@ test('Donna refuses to delegate connector authentication to an export capability
403
403
  globalThis.fetch = async (url, options = {}) => {
404
404
  fetchedUrls.push(String(url));
405
405
  const body = JSON.parse(String(options.body ?? '{}'));
406
- assert.equal(body.params?.name, 'start_google_auth');
406
+ assert.equal(body.params?.name, 'connectors_google_oauth_start');
407
+ assert.deepEqual(body.params?.arguments, { workspace: 'docs' });
407
408
  return {
408
409
  ok: true,
409
410
  status: 200,
@@ -415,16 +416,16 @@ test('Donna refuses to delegate connector authentication to an export capability
415
416
  const session = sessionBase({
416
417
  runtime: { url: 'http://runtime.test' },
417
418
  mcp: {
418
- 'google-workspace': {
419
+ connectors: {
419
420
  status: 'connected',
420
421
  url: 'http://google.test/mcp',
421
422
  tools: [
422
423
  {
423
- name: 'start_google_auth',
424
+ name: 'connectors_google_oauth_start',
424
425
  description: 'Manually initiate Google OAuth authentication flow.',
425
426
  inputSchema: { type: 'object', additionalProperties: true },
426
427
  },
427
- { name: 'search_gmail_messages', inputSchema: { type: 'object', additionalProperties: true } },
428
+ { name: 'connectors_google_status', inputSchema: { type: 'object', additionalProperties: true } },
428
429
  ],
429
430
  },
430
431
  },
@@ -439,7 +440,7 @@ test('Donna refuses to delegate connector authentication to an export capability
439
440
  if (turn === 2) return {
440
441
  content: null,
441
442
  message: { role: 'assistant', content: null },
442
- tool_calls: [{ id: 'google-auth', type: 'function', function: { name: 'google-workspace__start_google_auth', arguments: '{}' } }],
443
+ tool_calls: [{ id: 'google-auth', type: 'function', function: { name: 'connectors__connectors_google_oauth_start', arguments: '{}' } }],
443
444
  };
444
445
  return {
445
446
  content: 'J’ai lancé l’authentification. Ouvre le lien fourni.',
@@ -1146,7 +1147,8 @@ test('agent graph accepts plan steps with known capabilities and null capability
1146
1147
  test('buildAgentSystemPrompt assigns capability resolution exclusively to the runtime', () => {
1147
1148
  const withAgents = buildAgentSystemPrompt({ session: sessionBase({ agentRegistrySnapshot: orchestrableAgentSnapshot() }) });
1148
1149
  assert.match(withAgents, /call runtime__delegate with the user objective only/);
1149
- assert.match(withAgents, /Never choose a capability, operation, agent, plan, or implementation yourself/);
1150
+ assert.match(withAgents, /executor-only single-task agents/);
1151
+ assert.match(withAgents, /Never choose those identifiers yourself/);
1150
1152
  assert.doesNotMatch(withAgents, /ONLY values allowed in requiredCapability/);
1151
1153
  });
1152
1154
 
@@ -1310,6 +1312,106 @@ test('runtime action retries a text-only hallucination and requires a real tool
1310
1312
  }
1311
1313
  });
1312
1314
 
1315
+ test('interactive action delegates after a connector status check did not execute the requested collection', async () => {
1316
+ const originalFetch = globalThis.fetch;
1317
+ let delegatedObjective = null;
1318
+ globalThis.fetch = async (url, options = {}) => {
1319
+ if (String(url).includes('/delegate')) {
1320
+ const body = JSON.parse(String(options.body ?? '{}'));
1321
+ delegatedObjective = body.objective;
1322
+ return {
1323
+ ok: true,
1324
+ status: 202,
1325
+ json: async () => ({
1326
+ accepted: true,
1327
+ runId: 'collect-run',
1328
+ delegation: { tasks: 1, agent: 'connectors' },
1329
+ }),
1330
+ };
1331
+ }
1332
+ return {
1333
+ ok: true,
1334
+ status: 200,
1335
+ headers: { get: () => null },
1336
+ text: async () => JSON.stringify({
1337
+ result: { content: [{ type: 'text', text: '{"ok":true,"status":"configured"}' }] },
1338
+ }),
1339
+ };
1340
+ };
1341
+ let modelCalls = 0;
1342
+ const session = sessionBase({
1343
+ runtime: { url: 'http://runtime.test' },
1344
+ mcp: {
1345
+ connectors: {
1346
+ status: 'connected',
1347
+ url: 'http://connectors.test/mcp/',
1348
+ tools: [{
1349
+ name: 'connectors_google_status',
1350
+ description: 'Read Google authorization status.',
1351
+ inputSchema: { type: 'object', properties: { workspace: { type: 'string' } } },
1352
+ }],
1353
+ },
1354
+ },
1355
+ llm: {
1356
+ async completeWithTools({ tools }) {
1357
+ if (tools.some((tool) => tool.function.name === 'classify_action_request')) {
1358
+ return {
1359
+ content: null,
1360
+ tool_calls: [{
1361
+ id: 'classification',
1362
+ type: 'function',
1363
+ function: { name: 'classify_action_request', arguments: '{"action":true}' },
1364
+ }],
1365
+ };
1366
+ }
1367
+ modelCalls += 1;
1368
+ if (modelCalls === 1) {
1369
+ return {
1370
+ content: null,
1371
+ message: { role: 'assistant', content: null },
1372
+ tool_calls: [{
1373
+ id: 'status',
1374
+ type: 'function',
1375
+ function: { name: 'connectors__connectors_google_status', arguments: '{"workspace":"docs"}' },
1376
+ }],
1377
+ };
1378
+ }
1379
+ if (modelCalls === 2) {
1380
+ return {
1381
+ content: 'Le statut est configuré mais je ne peux pas collecter.',
1382
+ message: { role: 'assistant', content: 'Le statut est configuré mais je ne peux pas collecter.' },
1383
+ tool_calls: null,
1384
+ };
1385
+ }
1386
+ if (modelCalls === 3) {
1387
+ return {
1388
+ content: null,
1389
+ message: { role: 'assistant', content: null },
1390
+ tool_calls: [{
1391
+ id: 'delegate',
1392
+ type: 'function',
1393
+ function: { name: 'runtime__delegate', arguments: '{"objective":"charge les 10 derniers mails"}' },
1394
+ }],
1395
+ };
1396
+ }
1397
+ return {
1398
+ content: 'Collecte lancée.',
1399
+ message: { role: 'assistant', content: 'Collecte lancée.' },
1400
+ tool_calls: null,
1401
+ };
1402
+ },
1403
+ },
1404
+ });
1405
+
1406
+ try {
1407
+ const result = await createAgentGraph().invoke({ input: 'charge les 10 derniers mails', session });
1408
+ assert.equal(delegatedObjective, 'charge les 10 derniers mails');
1409
+ assert.equal(result.response, 'Collecte lancée.');
1410
+ } finally {
1411
+ globalThis.fetch = originalFetch;
1412
+ }
1413
+ });
1414
+
1313
1415
  test('agent graph auto-declares the plan from an agent_plan task-graph fragment', async () => {
1314
1416
  // The bridge that makes parallel ingestion real: when the LLM calls
1315
1417
  // production__agent_plan, the shell integrates the fragment as the plan
@@ -0,0 +1,14 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import assert from 'node:assert/strict';
3
+ import test from 'node:test';
4
+
5
+ test('runtime startup is not blocked by optional Docker image maintenance', async () => {
6
+ const source = await readFile(new URL('./wiki-manager.js', import.meta.url), 'utf8');
7
+ const runtimeBranch = source.slice(
8
+ source.indexOf("if (argv[0] === 'runtime')"),
9
+ source.indexOf("if (argv.includes('--setup-wizard'))"),
10
+ );
11
+
12
+ assert.match(runtimeBranch, /await runRuntime\(argv\.slice\(1\), agent\)/);
13
+ assert.doesNotMatch(runtimeBranch, /refreshRunningContainers/);
14
+ });
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { mkdir, writeFile } from 'node:fs/promises';
3
4
  import { dirname, join, resolve } from 'node:path';
@@ -25,7 +26,7 @@ import { listWorkspaces } from '../core/workspaces.js';
25
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
27
  const packageJsonPath = resolve(__dirname, '../../package.json');
27
28
  const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
28
- const SHELL_COMMANDS = ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'clear', 'chat', 'agent', 'approve'];
29
+ const SHELL_COMMANDS = ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'connector', 'wiki', 'skills', 'clear', 'chat', 'agent', 'approve'];
29
30
 
30
31
  function valueAfter(argv, flag) {
31
32
  const index = argv.indexOf(flag);
@@ -54,6 +55,144 @@ function unavailableRuntime(err) {
54
55
  return { url: null, error: reason };
55
56
  }
56
57
 
58
+ export function buildExecutorOnlyFragment({ objective, workspace, selection }) {
59
+ const provider = selection.provider;
60
+ const capability = provider.capability ?? {};
61
+ const agentInstanceId = String(provider.agentInstanceId ?? provider.serverName);
62
+ const label = String(objective ?? '').trim() || `${selection.operation} ${selection.capability}`;
63
+ const mutationClass = typeof capability.mutationClass === 'string'
64
+ ? capability.mutationClass
65
+ : null;
66
+ const requiresApproval = capability.defaultRequiresApproval === true || Boolean(mutationClass);
67
+ return {
68
+ contractVersion: String(provider.description?.contractVersion ?? '1'),
69
+ agentInstanceId,
70
+ capability: selection.capability,
71
+ summary: {
72
+ label,
73
+ initialSynthesis: [],
74
+ estimatedTasks: 1,
75
+ },
76
+ groups: [],
77
+ tasks: [{
78
+ id: `${selection.operation}-${randomUUID()}`,
79
+ label,
80
+ requiredCapability: selection.capability,
81
+ operation: selection.operation,
82
+ arguments: selection.arguments ?? {},
83
+ dependsOn: [],
84
+ parallelizable: false,
85
+ inputRefs: [],
86
+ expectedOutputRefs: [],
87
+ locks: [`${selection.capability}:${String(workspace)}`],
88
+ requiresApproval,
89
+ ...(mutationClass ? { approvalClass: mutationClass } : {}),
90
+ ...(requiresApproval ? { approvalSummary: label } : {}),
91
+ idempotencyKey: randomUUID(),
92
+ progressWeight: 1,
93
+ }],
94
+ expectedOutputs: [],
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Fill a single-task executor's arguments from the natural-language objective,
100
+ * generically — against the capability's own declared `inputSchema`, with no
101
+ * per-agent or per-provider knowledge in the manager. This lets Donna honour
102
+ * stated constraints ("les 10 derniers mails", "de LinkedIn") while keeping
103
+ * `runtime__delegate` agnostic (it still only carries the objective).
104
+ *
105
+ * Degrades gracefully (cf. provider compatibility): forced tool_choice first,
106
+ * then a JSON-text completion, then no arguments — the executor uses its own
107
+ * defaults. It never throws and never invents identifiers.
108
+ */
109
+ export async function resolveExecutorArguments({ llm, objective, capability, signal } = {}) {
110
+ const schema = capability?.inputSchema;
111
+ const objectiveText = String(objective ?? '').trim();
112
+ if (
113
+ !objectiveText
114
+ || !llm
115
+ || typeof llm.completeWithTools !== 'function'
116
+ || !schema
117
+ || typeof schema !== 'object'
118
+ || !schema.properties
119
+ || typeof schema.properties !== 'object'
120
+ || Object.keys(schema.properties).length === 0
121
+ ) {
122
+ return {};
123
+ }
124
+ const system = [
125
+ 'Extract structured arguments for a task from the user objective.',
126
+ 'Only fill a field when the objective explicitly states or clearly implies its value.',
127
+ 'Omit every field that is not stated. Never invent identifiers, queries, filters or counts.',
128
+ 'Return the arguments object only.',
129
+ ].join('\n');
130
+ const tool = {
131
+ type: 'function',
132
+ function: {
133
+ name: 'set_task_arguments',
134
+ description: String(capability.description ?? 'Task arguments for the selected capability.'),
135
+ parameters: {
136
+ type: 'object',
137
+ additionalProperties: schema.additionalProperties ?? false,
138
+ properties: schema.properties,
139
+ },
140
+ },
141
+ };
142
+ const messages = [{ role: 'user', content: objectiveText }];
143
+ try {
144
+ const result = await llm.completeWithTools({
145
+ system,
146
+ tools: [tool],
147
+ toolChoice: { type: 'function', function: { name: 'set_task_arguments' } },
148
+ messages,
149
+ signal,
150
+ });
151
+ const call = (result?.tool_calls ?? []).find((item) => item?.function?.name === 'set_task_arguments');
152
+ const fromCall = call ? safeParseArgumentObject(call.function?.arguments) : null;
153
+ if (fromCall) return pruneArgumentsToSchema(fromCall, schema);
154
+ const fromText = safeParseArgumentObject(result?.content);
155
+ if (fromText) return pruneArgumentsToSchema(fromText, schema);
156
+ } catch {
157
+ // Fall through to the tool-less path.
158
+ }
159
+ try {
160
+ const result = await llm.completeWithTools({
161
+ system: `${system}\nReturn a single JSON object only.`,
162
+ tools: [],
163
+ messages,
164
+ signal,
165
+ });
166
+ const fromText = safeParseArgumentObject(result?.content);
167
+ if (fromText) return pruneArgumentsToSchema(fromText, schema);
168
+ } catch {
169
+ // Give up: the executor will use its own defaults.
170
+ }
171
+ return {};
172
+ }
173
+
174
+ function safeParseArgumentObject(text) {
175
+ const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
176
+ if (!cleaned) return null;
177
+ try {
178
+ const value = JSON.parse(cleaned);
179
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function pruneArgumentsToSchema(value, schema) {
186
+ const allowed = schema.properties ?? {};
187
+ const acceptsExtra = schema.additionalProperties !== false;
188
+ const out = {};
189
+ for (const [key, entry] of Object.entries(value)) {
190
+ if (entry === undefined || entry === null) continue;
191
+ if (Object.hasOwn(allowed, key) || acceptsExtra) out[key] = entry;
192
+ }
193
+ return out;
194
+ }
195
+
57
196
  function createSession() {
58
197
  return {
59
198
  workspace: null,
@@ -831,34 +970,60 @@ async function runRuntime(argv, agent) {
831
970
  }
832
971
  const provider = selection.provider;
833
972
  let planResult;
834
- try {
835
- planResult = await callMcpTool(
836
- session.mcp,
837
- provider.serverName,
838
- 'agent_plan',
839
- {
840
- capability: selection.capability,
841
- operation: selection.operation,
842
- objective,
843
- workspace: { revision: String(Date.now()) },
844
- constraints: {
845
- maxConcurrency: resolveCapabilityConcurrency(
846
- provider,
847
- undefined,
848
- process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
849
- ),
850
- requireApprovalForMutations: true,
973
+ const orchestration = provider.description?.orchestration ?? {};
974
+ const canPlan = orchestration.canPlan !== false;
975
+ const singleTaskExecutor = orchestration.canPlan === false
976
+ && orchestration.singleTaskOnly === true;
977
+ let fragment;
978
+ if (canPlan) {
979
+ try {
980
+ planResult = await callMcpTool(
981
+ session.mcp,
982
+ provider.serverName,
983
+ 'agent_plan',
984
+ {
985
+ capability: selection.capability,
986
+ operation: selection.operation,
987
+ objective,
988
+ workspace: { revision: String(Date.now()) },
989
+ constraints: {
990
+ maxConcurrency: resolveCapabilityConcurrency(
991
+ provider,
992
+ undefined,
993
+ process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
994
+ ),
995
+ requireApprovalForMutations: true,
996
+ },
851
997
  },
998
+ );
999
+ } catch (err) {
1000
+ const endpoint = session.mcp?.[provider.serverName]?.url ?? 'unknown endpoint';
1001
+ throw new Error(
1002
+ `Delegation failed during agent_plan: provider=${provider.serverName} endpoint=${endpoint} ${errorDiagnostic(err)}`,
1003
+ { cause: err },
1004
+ );
1005
+ }
1006
+ fragment = parseJsonText(formatMcpToolResult(planResult));
1007
+ } else if (singleTaskExecutor) {
1008
+ const extractedArguments = await resolveExecutorArguments({
1009
+ llm: session.llm,
1010
+ objective,
1011
+ capability: provider.capability,
1012
+ signal: session._abortSignal,
1013
+ });
1014
+ fragment = buildExecutorOnlyFragment({
1015
+ objective,
1016
+ workspace: session.workspace ?? context.workspace ?? 'workspace',
1017
+ selection: {
1018
+ ...selection,
1019
+ arguments: { ...(selection.arguments ?? {}), ...extractedArguments },
852
1020
  },
853
- );
854
- } catch (err) {
855
- const endpoint = session.mcp?.[provider.serverName]?.url ?? 'unknown endpoint';
1021
+ });
1022
+ } else {
856
1023
  throw new Error(
857
- `Delegation failed during agent_plan: provider=${provider.serverName} endpoint=${endpoint} ${errorDiagnostic(err)}`,
858
- { cause: err },
1024
+ `Agent ${provider.agentInstanceId ?? provider.serverName} cannot plan and does not declare singleTaskOnly:true.`,
859
1025
  );
860
1026
  }
861
- const fragment = parseJsonText(formatMcpToolResult(planResult));
862
1027
  if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
863
1028
  throw new Error(fragment?.summary?.initialSynthesis?.[0] ?? `No task was planned for ${selection.capability}/${selection.operation}.`);
864
1029
  }
@@ -1204,10 +1369,6 @@ export async function runCli(argv) {
1204
1369
  if (argv[0] === 'runtime') {
1205
1370
  const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
1206
1371
  if (scaffolded.length > 0) loadManagerEnv();
1207
- const imageRefresh = await refreshRunningContainers({
1208
- onStep: (message) => console.log(`[wiki-manager] ${message}`),
1209
- });
1210
- logImageRefreshErrors(imageRefresh);
1211
1372
  const agent = createAgentGraph();
1212
1373
  await runRuntime(argv.slice(1), agent);
1213
1374
  return;
@@ -1266,7 +1427,7 @@ export async function runCli(argv) {
1266
1427
  }
1267
1428
  const { runOpenTuiShell, runStartupWizard } = await import('../shell/tui.tsx');
1268
1429
  // Fresh directory → copy mcp.endpoints.json/.env from the packaged
1269
- // examples so external agents (cme, mailer, documents) connect out of
1430
+ // examples so external agents (cme and documents) connect out of
1270
1431
  // the box. Done here (and in `runtime`), NOT at import time: --version
1271
1432
  // in a random cwd must not litter files.
1272
1433
  const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
@@ -1,10 +1,101 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import {
4
+ buildExecutorOnlyFragment,
4
5
  forwardRuntimeApproval,
6
+ resolveExecutorArguments,
5
7
  resolvePreparedDelegationApproval,
6
8
  } from './wiki-manager.js';
7
9
 
10
+ const COLLECT_CAPABILITY = {
11
+ description: 'Collect content from an external connector source.',
12
+ inputSchema: {
13
+ type: 'object',
14
+ additionalProperties: true,
15
+ properties: {
16
+ maxMessages: { type: 'integer' },
17
+ query: { type: 'string' },
18
+ },
19
+ },
20
+ };
21
+
22
+ test('executor-only capabilities receive one manager-authored executable task', () => {
23
+ const fragment = buildExecutorOnlyFragment({
24
+ objective: 'donne-moi mes derniers mails',
25
+ workspace: 'juno',
26
+ selection: {
27
+ capability: 'external-source.collect',
28
+ operation: 'collect',
29
+ arguments: { maxMessages: 10 },
30
+ provider: {
31
+ serverName: 'connectors',
32
+ agentInstanceId: 'connectors-1',
33
+ description: {
34
+ contractVersion: '1',
35
+ orchestration: { canPlan: false, singleTaskOnly: true },
36
+ },
37
+ capability: {
38
+ mutationClass: 'external-source',
39
+ defaultRequiresApproval: true,
40
+ },
41
+ },
42
+ },
43
+ });
44
+
45
+ assert.equal(fragment.agentInstanceId, 'connectors-1');
46
+ assert.equal(fragment.tasks.length, 1);
47
+ assert.equal(fragment.tasks[0].requiredCapability, 'external-source.collect');
48
+ assert.equal(fragment.tasks[0].operation, 'collect');
49
+ assert.deepEqual(fragment.tasks[0].arguments, { maxMessages: 10 });
50
+ assert.deepEqual(fragment.tasks[0].locks, ['external-source.collect:juno']);
51
+ assert.equal(fragment.tasks[0].requiresApproval, true);
52
+ assert.equal(fragment.tasks[0].approvalClass, 'external-source');
53
+ assert.match(fragment.tasks[0].idempotencyKey, /^[0-9a-f-]{36}$/);
54
+ });
55
+
56
+ test('generic argument extraction fills the executor task from the objective', async () => {
57
+ const llm = {
58
+ completeWithTools: async () => ({
59
+ tool_calls: [{
60
+ function: { name: 'set_task_arguments', arguments: JSON.stringify({ maxMessages: 10 }) },
61
+ }],
62
+ }),
63
+ };
64
+ const args = await resolveExecutorArguments({
65
+ llm,
66
+ objective: 'récupère les 10 derniers mails',
67
+ capability: COLLECT_CAPABILITY,
68
+ });
69
+ assert.deepEqual(args, { maxMessages: 10 });
70
+ });
71
+
72
+ test('argument extraction falls back to a JSON-text completion', async () => {
73
+ const llm = {
74
+ completeWithTools: async ({ tools }) =>
75
+ tools.length > 0 ? { content: '' } : { content: '{"query":"from:linkedin.com"}' },
76
+ };
77
+ const args = await resolveExecutorArguments({
78
+ llm,
79
+ objective: 'les mails de LinkedIn',
80
+ capability: COLLECT_CAPABILITY,
81
+ });
82
+ assert.deepEqual(args, { query: 'from:linkedin.com' });
83
+ });
84
+
85
+ test('argument extraction stays agnostic and safe when it cannot extract', async () => {
86
+ // No inputSchema → no extraction attempted at all.
87
+ assert.deepEqual(
88
+ await resolveExecutorArguments({ llm: { completeWithTools: async () => ({}) }, objective: 'x', capability: {} }),
89
+ {},
90
+ );
91
+ // Provider/LLM failure degrades to the executor's own defaults, never throws.
92
+ const throwing = { completeWithTools: async () => { throw new Error('gateway rejected tool_choice'); } };
93
+ assert.deepEqual(
94
+ await resolveExecutorArguments({ llm: throwing, objective: 'les 10 derniers mails', capability: COLLECT_CAPABILITY }),
95
+ {},
96
+ );
97
+ });
98
+
8
99
  test('runtime approval bridge preserves the complete run-scoped grant', async () => {
9
100
  let forwarded = null;
10
101
  const request = {
@@ -631,6 +631,7 @@ ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
631
631
  ${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
632
632
  ${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
633
633
  ${helpPair('/mcp tools [mcp]', 'MCP tools', '/mcp call ...', 'Call MCP tool')}
634
+ ${helpPair('/connector list', 'Connector auth status', '/connector auth <n>', 'Authorize connector')}
634
635
  ${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
635
636
  ${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
636
637
  ${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
@@ -1025,6 +1026,63 @@ export async function handleSlashCommand(line, context) {
1025
1026
  }
1026
1027
  return { output: 'Usage: /mcp <status|endpoints|tools|call> [mcp]' };
1027
1028
  }
1029
+ case 'connector': {
1030
+ const subcommand = args[1] ?? 'list';
1031
+ const connectorResult = (output) => rawCommandResult(`/connector ${args.slice(1).join(' ')}`.trim(), output);
1032
+ if (!context.session.workspace) {
1033
+ return connectorResult('No workspace is currently loaded.');
1034
+ }
1035
+ await refreshMcpRuntimeStatus(context.session);
1036
+ const connectorMcp = context.session.mcp?.connectors;
1037
+ if (!connectorMcp || connectorMcp.status !== 'connected') {
1038
+ return connectorResult('The connectors service is unavailable or disabled.');
1039
+ }
1040
+ if (subcommand === 'list') {
1041
+ if (args[2]) return connectorResult('The connector list request has invalid extra arguments.');
1042
+ try {
1043
+ const result = await callMcpTool(
1044
+ context.session.mcp,
1045
+ 'connectors',
1046
+ 'connectors_google_status',
1047
+ { workspace: context.session.workspace },
1048
+ );
1049
+ const payload = parseJsonText(formatMcpToolResult(result));
1050
+ const status = payload?.status === 'configured' ? 'authorized' : 'not authorized';
1051
+ return connectorResult(`google (Gmail read-only): ${status}`);
1052
+ } catch (err) {
1053
+ return connectorResult(`google (Gmail read-only): unavailable (${err instanceof Error ? err.message : String(err)})`);
1054
+ }
1055
+ }
1056
+ if (subcommand === 'auth') {
1057
+ const connector = String(args[2] ?? '').toLowerCase();
1058
+ if (!['google', 'gmail'].includes(connector)) {
1059
+ return connectorResult('The requested connector is unsupported. The available connector is google (Gmail read-only).');
1060
+ }
1061
+ try {
1062
+ const result = await callMcpTool(
1063
+ context.session.mcp,
1064
+ 'connectors',
1065
+ 'connectors_google_oauth_start',
1066
+ { workspace: context.session.workspace },
1067
+ );
1068
+ const payload = parseJsonText(formatMcpToolResult(result));
1069
+ const authorizationUrl = payload?.authorizationUrl;
1070
+ if (payload?.ok !== true || typeof authorizationUrl !== 'string') {
1071
+ return connectorResult(`Google authorization could not start (${payload?.error ?? 'missing authorization URL'}).`);
1072
+ }
1073
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
1074
+ try {
1075
+ execFileSync(opener, [authorizationUrl], { stdio: 'ignore' });
1076
+ return connectorResult('Google authorization opened successfully in the user browser.');
1077
+ } catch {
1078
+ return connectorResult(`Google authorization requires the user to open this URL: ${authorizationUrl}`);
1079
+ }
1080
+ } catch (err) {
1081
+ return connectorResult(`Google authorization could not start (${err instanceof Error ? err.message : String(err)}).`);
1082
+ }
1083
+ }
1084
+ return connectorResult('The requested connector action is unsupported. Available actions: list and auth google.');
1085
+ }
1028
1086
  case 'cancel': {
1029
1087
  // Alias of /run cancel — people type /cancel when they want out.
1030
1088
  const runtime = context.runtime ?? {};
@@ -93,6 +93,24 @@ test('/new without a name shows usage', async () => {
93
93
  assert.match(result.output ?? '', /Usage/i);
94
94
  });
95
95
 
96
+ test('/connector completions expose list and Google authorization', () => {
97
+ const session = { commands: ['connector'] };
98
+ assert.deepEqual(completionContext('/connector ', session)?.matches, ['auth', 'list']);
99
+ assert.deepEqual(completionContext('/connector auth ', session)?.matches, ['google']);
100
+ });
101
+
102
+ test('/connector hands deterministic facts to Donna for localized natural-language output', async () => {
103
+ const result = await handleSlashCommand('/connector list', {
104
+ packageJson: { version: 'test' },
105
+ session: {},
106
+ });
107
+
108
+ assert.equal(result.rawOutput, true);
109
+ assert.match(result.output ?? '', /No workspace is currently loaded/);
110
+ assert.match(result.agentTrigger ?? '', /profil workspace/);
111
+ assert.match(result.agentTrigger ?? '', /Ne relance pas la commande/);
112
+ });
113
+
96
114
  test('/skills run sends the private skill body to Donna without rendering it as command output', async () => {
97
115
  const root = await mkdtemp(join(tmpdir(), 'wiki-manager-skill-run-'));
98
116
  const skillDir = join(root, '.wiki', 'skills');
@@ -13,7 +13,7 @@ function basename(value) {
13
13
  }
14
14
 
15
15
  function terminalStatus(status) {
16
- return ['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error'].includes(String(status ?? '').toLowerCase());
16
+ return ['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'succeeded', 'error'].includes(String(status ?? '').toLowerCase());
17
17
  }
18
18
 
19
19
  export function activityKey(activity) {
@@ -29,6 +29,11 @@ test('normalizeActivity: plan null when no plan field', () => {
29
29
  assert.equal(a.plan, null);
30
30
  });
31
31
 
32
+ test('normalizeActivity: succeeded is terminal', () => {
33
+ const a = normalizeActivity({ id: 'job-connectors', status: 'succeeded' });
34
+ assert.equal(a.terminal, true);
35
+ });
36
+
32
37
  test('isCancelledStatus recognizes both cancellation spellings', () => {
33
38
  assert.equal(isCancelledStatus('cancelled'), true);
34
39
  assert.equal(isCancelledStatus('canceled'), true);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.20",
3
- "commit": "8833a7e"
2
+ "version": "0.14.23",
3
+ "commit": "f0519ef"
4
4
  }