@dotdrelle/wiki-manager 0.15.42 → 0.15.48

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 (46) hide show
  1. package/README.md +139 -27
  2. package/mcp.endpoints.example.json +1 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +290 -29
  5. package/src/agent/graph.test.js +551 -1
  6. package/src/agent/skillRecursion.test.js +98 -0
  7. package/src/cli/wiki-manager.js +209 -7
  8. package/src/cli/wiki-manager.test.js +89 -0
  9. package/src/commands/slash.js +28 -10
  10. package/src/contracts/schemas.js +1 -1
  11. package/src/core/agentEvents.js +50 -0
  12. package/src/core/agentEvents.test.js +52 -0
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/env.js +20 -1
  15. package/src/core/env.test.js +34 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/profile.js +19 -0
  18. package/src/core/runtimeLog.js +15 -0
  19. package/src/core/runtimeLog.test.js +15 -1
  20. package/src/core/skillChainView.js +84 -0
  21. package/src/core/skillChainView.test.js +50 -0
  22. package/src/core/skillCompiler.js +135 -0
  23. package/src/core/skillCompiler.test.js +91 -0
  24. package/src/core/skillInvocation.js +79 -0
  25. package/src/core/skillInvocation.test.js +73 -0
  26. package/src/core/skills.js +81 -19
  27. package/src/core/wikiWorkspace.test.js +34 -0
  28. package/src/core/workspaceProfile.test.js +55 -0
  29. package/src/runtime/client.js +45 -4
  30. package/src/runtime/controlCancellation.js +33 -0
  31. package/src/runtime/controlCancellation.test.js +49 -0
  32. package/src/runtime/controlDrain.js +50 -0
  33. package/src/runtime/controlDrain.test.js +38 -0
  34. package/src/runtime/server.js +341 -20
  35. package/src/runtime/server.test.js +344 -2
  36. package/src/runtime/skillChain.e2e.test.js +394 -0
  37. package/src/runtime/skillRun.js +104 -0
  38. package/src/runtime/skillRun.test.js +84 -0
  39. package/src/runtime/store.js +69 -0
  40. package/src/runtime/store.test.js +11 -0
  41. package/src/runtime/workspaceIsolation.test.js +178 -0
  42. package/src/shell/RightPane.tsx +3 -2
  43. package/src/shell/repl.js +51 -6
  44. package/src/shell/repl.test.js +41 -0
  45. package/src/shell/useSession.ts +43 -9
  46. package/wiki-workspace +137 -1
@@ -2,6 +2,7 @@ import { normalizeActivity } from './activity.js';
2
2
  import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
3
  import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
4
4
  import { formatRuntimeLogPayload } from './runtimeLog.js';
5
+ import { projectSkillChains } from './skillChainView.js';
5
6
  import { projectWorkflow } from './workflow.js';
6
7
  import { validateContractInDev } from '../contracts/schemas.js';
7
8
  import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
@@ -41,6 +42,7 @@ const SESSION_PROJECTION_EVENTS = new Set([
41
42
  'control_enqueued',
42
43
  'control_started',
43
44
  'control_cancelled',
45
+ 'control_skipped',
44
46
  'agent.registered',
45
47
  'agent.health_changed',
46
48
  'run_done',
@@ -130,6 +132,9 @@ function withSessionRunIdentity(event, session) {
130
132
  // assistant messages — used to leave with workspace=null and never reached
131
133
  // the UI, which then displayed a stale plan from the previous action.
132
134
  const workspace = event.workspace ?? identity?.workspace ?? session?.workspace ?? null;
135
+ if (event.payload?.independent === true) {
136
+ return { ...event, runId: null, turnId: event.turnId ?? null, taskId: event.taskId ?? null, workspace };
137
+ }
133
138
  if (!identity) {
134
139
  return workspace === (event.workspace ?? null) ? event : { ...event, workspace };
135
140
  }
@@ -208,6 +213,8 @@ function publicProjection(state) {
208
213
  patch: patch.patch ? { ...patch.patch, operations: (patch.patch.operations ?? []).map((operation) => ({ ...operation })) } : null,
209
214
  })),
210
215
  controlQueue: state.controlQueue.map((item) => ({ ...item })),
216
+ // LOT G: the chain is a projection, never stored state.
217
+ skillChains: projectSkillChains(state.controlQueue),
211
218
  agents: Object.values(state.agents)
212
219
  .map((agent) => ({ ...agent, description: cloneJson(agent.description) }))
213
220
  .sort((a, b) => a.agentInstanceId.localeCompare(b.agentInstanceId)),
@@ -224,6 +231,10 @@ export function applyAgentProjectionToSession(session, projection) {
224
231
  session.headlessPlan = projection.plan ? projection.plan.map((step) => ({ ...step })) : null;
225
232
  session.activities = Object.fromEntries((projection.activities ?? []).map((activity) => [activity.key, { ...activity }]));
226
233
  session.controlQueue = (projection.controlQueue ?? []).map((item) => ({ ...item }));
234
+ session.skillChains = (projection.skillChains ?? []).map((chain) => ({
235
+ ...chain,
236
+ steps: chain.steps.map((step) => ({ ...step })),
237
+ }));
227
238
  session.agents = (projection.agents ?? []).map((agent) => ({ ...agent, description: cloneJson(agent.description) }));
228
239
  session.planRevision = projection.planRevision ?? 0;
229
240
  session.planPatches = (projection.planPatches ?? []).map((patch) => ({ ...patch }));
@@ -545,6 +556,7 @@ function applyEvent(state, event) {
545
556
  // cancelled so the display reflects reality immediately.
546
557
  cancelPendingPlanSteps(state.plan);
547
558
  cancelActiveActivities(state.activities, event.ts);
559
+ cancelPendingApprovals(state.approvals, event.runId ?? event.payload?.runId ?? null, event.ts);
548
560
  finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'cancelled', event.ts);
549
561
  return;
550
562
  case 'run_error':
@@ -556,6 +568,7 @@ function applyEvent(state, event) {
556
568
  // /kill honestly reported 0 because nothing was actually running.
557
569
  cancelPendingPlanSteps(state.plan);
558
570
  cancelActiveActivities(state.activities, event.ts);
571
+ cancelPendingApprovals(state.approvals, event.runId ?? event.payload?.runId ?? null, event.ts);
559
572
  finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'failed', event.ts);
560
573
  return;
561
574
  case 'control_enqueued':
@@ -567,6 +580,25 @@ function applyEvent(state, event) {
567
580
  createdAt: event.payload?.createdAt ?? event.ts,
568
581
  updatedAt: event.ts,
569
582
  ...(event.payload?.capabilityPlan !== undefined ? { capabilityPlan: event.payload.capabilityPlan } : {}),
583
+ ...(event.payload?.chainId ? { chainId: event.payload.chainId } : {}),
584
+ ...(event.payload?.skillName ? { skillName: event.payload.skillName } : {}),
585
+ ...(event.payload?.skillExecution ? { skillExecution: event.payload.skillExecution } : {}),
586
+ /*
587
+ La pile des compétences ouvertes au-dessus de cet élément.
588
+
589
+ Elle DOIT survivre à la projection : c'est le seul état qui relie un
590
+ run imbriqué à ses ancêtres. Le run n'est pas exécuté en ligne — il est
591
+ mis en file et démarre après que son parent s'est nettoyé — donc rien
592
+ d'autre que l'élément lui-même ne peut la lui transmettre. La perdre
593
+ ici, c'est rouvrir A→B→A en silence.
594
+ */
595
+ ...(Array.isArray(event.payload?.skillStack) && event.payload.skillStack.length
596
+ ? { skillStack: [...event.payload.skillStack] }
597
+ : {}),
598
+ ...(event.payload?.selectionKind ? { selectionKind: event.payload.selectionKind } : {}),
599
+ ...(Number.isInteger(event.payload?.chainSequence) ? { chainSequence: event.payload.chainSequence } : {}),
600
+ optional: event.payload?.optional === true,
601
+ continueOnFailure: event.payload?.continueOnFailure === true,
570
602
  });
571
603
  return;
572
604
  case 'control_started':
@@ -586,6 +618,15 @@ function applyEvent(state, event) {
586
618
  updatedAt: event.ts,
587
619
  });
588
620
  return;
621
+ case 'control_skipped':
622
+ upsertControlItem(state.controlQueue, {
623
+ id: event.payload?.id ?? null,
624
+ status: 'skipped',
625
+ skipReason: event.payload?.reason ?? null,
626
+ finishedAt: event.payload?.finishedAt ?? event.ts,
627
+ updatedAt: event.ts,
628
+ });
629
+ return;
589
630
  case 'agent.registered':
590
631
  upsertAgent(state, event.payload?.agent, event.ts);
591
632
  return;
@@ -759,6 +800,15 @@ function cancelPendingPlanSteps(plan) {
759
800
  }
760
801
  }
761
802
 
803
+ function cancelPendingApprovals(approvals, runId, ts) {
804
+ for (const approval of approvals ?? []) {
805
+ if (approval.status !== 'pending_approval') continue;
806
+ if (runId != null && approval.runId != null && String(approval.runId) !== String(runId)) continue;
807
+ approval.status = 'cancelled';
808
+ approval.cancelledAt = ts;
809
+ }
810
+ }
811
+
762
812
  function cancelActiveActivities(activities, ts) {
763
813
  for (const activity of Object.values(activities ?? {})) {
764
814
  if (activity && activity.terminal !== true) {
@@ -39,6 +39,20 @@ test('a streamed reply keeps the sequence of the delta that created it', () => {
39
39
  assert.deepEqual(conversationEventSequences(events), [1, 2]);
40
40
  });
41
41
 
42
+ test('an independent queued skill invocation never inherits the active run identity', () => {
43
+ const session = {
44
+ workspace: 'docs',
45
+ _currentRunIdentity: { runId: 'unrelated-run', turnId: 'unrelated-turn', workspace: 'docs' },
46
+ };
47
+ const event = dispatchAgentEvent(session, createAgentEvent('user_message', {
48
+ origin: 'user',
49
+ payload: { content: '/wiki-build overview', independent: true },
50
+ }));
51
+ assert.equal(event.runId, null);
52
+ assert.equal(event.turnId, null);
53
+ assert.equal(event.workspace, 'docs');
54
+ });
55
+
42
56
  test('reduceAgentEvents: run_started clears stale plan', () => {
43
57
  const projection = reduceAgentEvents([
44
58
  createAgentEvent('activity_upserted', {
@@ -263,6 +277,29 @@ test('reduceAgentEvents: approvals move from pending to approved', () => {
263
277
  assert.deepEqual(projection.approvals[0].plan, ['Build']);
264
278
  });
265
279
 
280
+ test('reduceAgentEvents: cancelling a run clears its pending approval projection', () => {
281
+ const projection = reduceAgentEvents([
282
+ createAgentEvent('approval.requested', {
283
+ origin: 'runtime',
284
+ runId: 'run-cancelled',
285
+ payload: { approvalId: 'approval-cancelled', scope: 'run', runId: 'run-cancelled' },
286
+ }),
287
+ createAgentEvent('approval.requested', {
288
+ origin: 'runtime',
289
+ runId: 'run-other',
290
+ payload: { approvalId: 'approval-other', scope: 'run', runId: 'run-other' },
291
+ }),
292
+ createAgentEvent('run_cancelled', {
293
+ origin: 'runtime',
294
+ runId: 'run-cancelled',
295
+ payload: { runId: 'run-cancelled' },
296
+ }),
297
+ ]);
298
+
299
+ assert.equal(projection.approvals.find((item) => item.runId === 'run-cancelled')?.status, 'cancelled');
300
+ assert.equal(projection.approvals.find((item) => item.runId === 'run-other')?.status, 'pending_approval');
301
+ });
302
+
266
303
  test('reduceAgentEvents: bounded approval grant covers matching pending requests only', () => {
267
304
  const projection = reduceAgentEvents([
268
305
  createAgentEvent('approval.requested', {
@@ -396,6 +433,21 @@ test('reduceAgentEvents: control_enqueued preserves a structured capabilityPlan
396
433
  assert.equal(item.status, 'running');
397
434
  });
398
435
 
436
+ test('reduceAgentEvents: chain metadata survives replay and control_skipped is terminal', () => {
437
+ const projection = reduceAgentEvents([
438
+ createAgentEvent('control_enqueued', { payload: {
439
+ id: 'chain-step-2', input: 'ingest', chainId: 'chain-1', chainSequence: 1,
440
+ skillName: 'wiki-sync', optional: false, continueOnFailure: false,
441
+ } }),
442
+ createAgentEvent('control_skipped', { payload: { id: 'chain-step-2', reason: 'chain_cancelled' } }),
443
+ ]);
444
+ assert.equal(projection.controlQueue[0].chainId, 'chain-1');
445
+ assert.equal(projection.controlQueue[0].chainSequence, 1);
446
+ assert.equal(projection.controlQueue[0].skillName, 'wiki-sync');
447
+ assert.equal(projection.controlQueue[0].status, 'skipped');
448
+ assert.equal(projection.controlQueue[0].skipReason, 'chain_cancelled');
449
+ });
450
+
399
451
  test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
400
452
  const projection = reduceAgentEvents([
401
453
  createAgentEvent('activity_upserted', {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.42",
3
- "commit": "d0180aa"
2
+ "version": "0.15.48",
3
+ "commit": "7290b94"
4
4
  }
package/src/core/env.js CHANGED
@@ -2,6 +2,15 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFil
2
2
  import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
+ const LEGACY_DEFAULT_WIKI_CHAT_TOOLS = [
6
+ 'help_list', 'help_read', 'help_search', 'wiki_workspace_status',
7
+ 'wiki_list_pages', 'wiki_read_page', 'wiki_read_pages', 'wiki_search_context',
8
+ 'wiki_collect_context', 'wiki_read_ingested_source',
9
+ ];
10
+ const TEMPLATE_AUTHORING_CHAT_TOOLS = [
11
+ 'wiki_outline', 'template_read', 'template_write', 'build_context_write',
12
+ ];
13
+
5
14
  export function userManagerDir() {
6
15
  return process.cwd();
7
16
  }
@@ -126,15 +135,25 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
126
135
  && exampleServers && typeof exampleServers === 'object' && !Array.isArray(exampleServers)
127
136
  ? Object.keys(exampleServers).filter((key) => !(key in currentServers) && !disabledServers.has(key))
128
137
  : [];
138
+ // Upgrade only the recognizable packaged wiki allow-list. A custom
139
+ // allow-list remains operator-owned and untouched.
140
+ const wikiAllow = current.chatAccess?.servers?.['llm-wiki']?.allow;
141
+ const migrateWikiChatTools = Array.isArray(wikiAllow)
142
+ && LEGACY_DEFAULT_WIKI_CHAT_TOOLS.every((tool) => wikiAllow.includes(tool));
143
+ const missingWikiChatTools = migrateWikiChatTools
144
+ ? TEMPLATE_AUTHORING_CHAT_TOOLS.filter((tool) => !wikiAllow.includes(tool))
145
+ : [];
129
146
  if (missing.length > 0) {
130
147
  for (const key of missing) current[key] = example[key];
131
148
  }
132
149
  for (const key of missingServers) currentServers[key] = exampleServers[key];
133
- if (missing.length > 0 || missingServers.length > 0) {
150
+ wikiAllow?.push(...missingWikiChatTools);
151
+ if (missing.length > 0 || missingServers.length > 0 || missingWikiChatTools.length > 0) {
134
152
  writeFileSync(endpointsFile, `${JSON.stringify(current, null, 2)}\n`);
135
153
  const changes = [
136
154
  missing.length > 0 ? `keys: ${missing.join(', ')}` : '',
137
155
  missingServers.length > 0 ? `servers: ${missingServers.join(', ')}` : '',
156
+ missingWikiChatTools.length > 0 ? `chat tools: ${missingWikiChatTools.join(', ')}` : '',
138
157
  ].filter(Boolean).join('; ');
139
158
  created.push(`mcp.endpoints.json ${changes}`);
140
159
  }
@@ -85,6 +85,7 @@ test('scaffold merges missing top-level keys into an existing endpoints file', (
85
85
  // Server keys must match the connected MCP endpoint keys (the tool-call
86
86
  // prefix): the wiki server is "llm-wiki", not "wiki".
87
87
  assert.ok(merged.chatAccess?.servers?.['llm-wiki']);
88
+ assert.ok(merged.chatAccess.servers['llm-wiki'].allow.includes('template_write'));
88
89
  });
89
90
  });
90
91
 
@@ -102,6 +103,39 @@ test('scaffold never overwrites an existing chatAccess, including explicit null'
102
103
  });
103
104
  });
104
105
 
106
+ test('scaffold upgrades the packaged wiki chat allow-list with template authoring tools', () => {
107
+ withTempManagerDir((dir) => {
108
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
109
+ const example = JSON.parse(readFileSync('mcp.endpoints.example.json', 'utf8'));
110
+ example.chatAccess.servers['llm-wiki'].allow = example.chatAccess.servers['llm-wiki'].allow
111
+ .filter((tool) => !['wiki_outline', 'template_read', 'template_write', 'build_context_write'].includes(tool));
112
+ writeFileSync(endpointsFile, JSON.stringify(example, null, 2));
113
+
114
+ const changes = ensureManagerScaffold();
115
+ const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
116
+
117
+ assert.ok(changes.some((item) => item.includes('template_write')));
118
+ assert.ok(after.chatAccess.servers['llm-wiki'].allow.includes('wiki_outline'));
119
+ assert.ok(after.chatAccess.servers['llm-wiki'].allow.includes('template_read'));
120
+ assert.ok(after.chatAccess.servers['llm-wiki'].allow.includes('template_write'));
121
+ assert.ok(after.chatAccess.servers['llm-wiki'].allow.includes('build_context_write'));
122
+ });
123
+ });
124
+
125
+ test('scaffold leaves a custom wiki chat allow-list untouched', () => {
126
+ withTempManagerDir((dir) => {
127
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
128
+ writeFileSync(endpointsFile, JSON.stringify({
129
+ mcpServers: {},
130
+ chatAccess: { servers: { 'llm-wiki': { allow: ['wiki_read_page'] } } },
131
+ }, null, 2));
132
+
133
+ ensureManagerScaffold();
134
+ const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
135
+ assert.deepEqual(after.chatAccess.servers['llm-wiki'].allow, ['wiki_read_page']);
136
+ });
137
+ });
138
+
105
139
  test('scaffold does not restore a packaged MCP explicitly removed in the UI', () => {
106
140
  withTempManagerDir((dir) => {
107
141
  const endpointsFile = join(dir, 'mcp.endpoints.json');
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.42';
4
+ const WIKI_MANAGER_VERSION = '0.15.48';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -1,5 +1,8 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { readOptionalText } from './skills.js';
4
+
5
+ export const MAX_PROFILE_CHARS = 4000;
3
6
 
4
7
  const DEFAULT_PROFILE = `# Workspace Profile
5
8
 
@@ -22,6 +25,22 @@ function profilePathForWorkspace(workspacePath) {
22
25
  return join(workspacePath, '.wiki', 'profile.md');
23
26
  }
24
27
 
28
+ // Durable per-workspace user preferences, injected into the system prompt of
29
+ // BOTH shell modes. Agent mode used to own this loader; chat mode had nothing,
30
+ // so the same workspace answered with a different tone depending on the mode,
31
+ // and a skill running in chat could not know who it was talking to. `serve`
32
+ // already injects the profile the same way (llm-wiki chatRoutes), so this keeps
33
+ // the three surfaces aligned. Injection is deliberately preferred over exposing
34
+ // `profile_read` through `chatAccess`: no allow-list entry to migrate on
35
+ // existing installs, and no tool round-trip for a file we can always read.
36
+ // Returns null when there is no workspace or no readable profile — never throws,
37
+ // since a missing profile must not degrade a reply.
38
+ export function loadWorkspaceProfile(workspacePath) {
39
+ if (!workspacePath) return null;
40
+ const content = readOptionalText(profilePathForWorkspace(workspacePath));
41
+ return content ? content.slice(0, MAX_PROFILE_CHARS) : null;
42
+ }
43
+
25
44
  function formatPreference(preference) {
26
45
  const clean = String(preference ?? '').trim();
27
46
  if (!clean) return '';
@@ -93,6 +93,21 @@ export function filterRuntimeLogs(logs = [], filter = '') {
93
93
  return logs.filter((line) => runtimeLogMatchesFilter(line, filter));
94
94
  }
95
95
 
96
+ export function compactRuntimeLogForDisplay(line) {
97
+ const text = String(line ?? '');
98
+ if (!/\bWARN\s+retrieval:vector-fallback\b/i.test(text)) return text;
99
+ const header = text.split(/\r?\n/, 1)[0].trimEnd();
100
+ const reason = logFieldValue(text, 'reason');
101
+ const message = logFieldValue(text, 'message');
102
+ const details = [reason && `reason=${reason}`, message && `message=${message}`].filter(Boolean).join(' ');
103
+ return details ? `${header} ${details}` : header;
104
+ }
105
+
106
+ function logFieldValue(text, name) {
107
+ const match = String(text).match(new RegExp(`(?:^|\\s)${name}=("(?:\\\\.|[^"\\\\])*"|[^\\s]+)`, 'i'));
108
+ return match?.[1] ?? null;
109
+ }
110
+
96
111
  function timeLabel(ts) {
97
112
  const date = ts ? new Date(ts) : new Date();
98
113
  if (Number.isNaN(date.getTime())) return null;
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
5
- import { formatRuntimeLogPayload } from './runtimeLog.js';
5
+ import { compactRuntimeLogForDisplay, formatRuntimeLogPayload } from './runtimeLog.js';
6
6
  import { emitRuntimeLog } from '../runtime/supervisor.js';
7
7
 
8
8
  const CYCLE_EVENTS = [
@@ -84,3 +84,17 @@ test('emitRuntimeLog accepts structured payloads and preserves legacy strings',
84
84
  // events so the Logs/Trace panel stays chronologically readable.
85
85
  assert.match(session.agentProjection.logs[1], /^\d{2}:\d{2}:\d{2} legacy line$/);
86
86
  });
87
+
88
+ test('vector fallback warnings keep only their reason and message for display', () => {
89
+ const displayed = compactRuntimeLogForDisplay(`09:25:34 trace: WARN retrieval:vector-fallback
90
+ reason=vector-error indexPath=/workspace/.wiki/vector-index queryPreview="a long query" fallback=lexical
91
+ consecutiveErrors=1 disabled=false message="Vector index is missing."`);
92
+
93
+ assert.equal(displayed, '09:25:34 trace: WARN retrieval:vector-fallback reason=vector-error message="Vector index is missing."');
94
+ assert.doesNotMatch(displayed, /indexPath|queryPreview|fallback=|consecutiveErrors|disabled=/);
95
+ });
96
+
97
+ test('runtime display compaction leaves other log entries unchanged', () => {
98
+ const line = '09:25:35 trace: ERROR retrieval failed message="broken"';
99
+ assert.equal(compactRuntimeLogForDisplay(line), line);
100
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ * Plan V4.1 LOT G — the execution chain of a skill, as a pure projection over
4
+ * the control queue. No new state and no new event: `chainId`, `chainSequence`,
5
+ * `skillName`, `status` and `skipReason` are already carried by control items,
6
+ * so this module only decides how they read. Both UIs consume the same output,
7
+ * which is why the shaping lives here and not in either renderer.
8
+ */
9
+
10
+ const SYMBOLS = {
11
+ done: '✓',
12
+ running: '●',
13
+ queued: '○',
14
+ cancelled: '×',
15
+ failed: '×',
16
+ skipped: '–',
17
+ };
18
+
19
+ const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
20
+
21
+ // Objectives are whole paragraphs; a chain view needs a line. Keep the first
22
+ // sentence, drop the parameter block the compiler appends, and never cut a word
23
+ // in half.
24
+ export function chainStepLabel(text, { maxLength = 52 } = {}) {
25
+ const withoutParameters = String(text ?? '').split(/\n\s*User parameters:/)[0];
26
+ const firstSentence = withoutParameters.trim().split(/(?<=[.!?])\s/)[0]?.trim() ?? '';
27
+ const label = firstSentence.replace(/\.$/, '');
28
+ if (label.length <= maxLength) return label;
29
+ const clipped = label.slice(0, maxLength);
30
+ const lastSpace = clipped.lastIndexOf(' ');
31
+ return `${(lastSpace > maxLength * 0.6 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`;
32
+ }
33
+
34
+ export function projectSkillChains(controlQueue = []) {
35
+ const items = (Array.isArray(controlQueue) ? controlQueue : []).filter((item) => item?.chainId);
36
+ const byChain = new Map();
37
+ for (const item of items) {
38
+ if (!byChain.has(item.chainId)) byChain.set(item.chainId, []);
39
+ byChain.get(item.chainId).push(item);
40
+ }
41
+ return [...byChain.entries()].map(([chainId, chainItems]) => {
42
+ const steps = chainItems
43
+ .slice()
44
+ .sort((a, b) => Number(a.chainSequence ?? 0) - Number(b.chainSequence ?? 0))
45
+ .map((item) => ({
46
+ id: item.id,
47
+ sequence: Number(item.chainSequence ?? 0),
48
+ label: chainStepLabel(item.input),
49
+ status: String(item.status ?? 'queued'),
50
+ symbol: SYMBOLS[String(item.status ?? 'queued')] ?? '○',
51
+ optional: item.optional === true,
52
+ ...(item.skipReason ? { skipReason: item.skipReason } : {}),
53
+ ...(item.runId ? { runId: item.runId } : {}),
54
+ }));
55
+ return {
56
+ chainId,
57
+ skillName: chainItems.find((item) => item.skillName)?.skillName ?? null,
58
+ selectionKind: chainItems.find((item) => item.selectionKind)?.selectionKind ?? null,
59
+ steps,
60
+ status: chainStatus(steps),
61
+ };
62
+ });
63
+ }
64
+
65
+ function chainStatus(steps) {
66
+ if (steps.some((step) => step.status === 'running')) return 'running';
67
+ if (!steps.every((step) => TERMINAL.has(step.status))) return 'queued';
68
+ if (steps.some((step) => step.status === 'failed')) return 'failed';
69
+ if (steps.some((step) => step.status === 'cancelled')) return 'cancelled';
70
+ if (steps.some((step) => step.status === 'skipped')) return 'incomplete';
71
+ return 'done';
72
+ }
73
+
74
+ // The text form used by the Shell; serve renders the same projection as DOM.
75
+ export function renderSkillChain(chain) {
76
+ if (!chain?.steps?.length) return '';
77
+ const selection = chain.selectionKind ? ` · ${chain.selectionKind}` : '';
78
+ const lines = [`${chain.skillName ?? 'skill'}${selection}`, ''];
79
+ for (const step of chain.steps) {
80
+ lines.push(`${step.symbol} ${step.label}`);
81
+ lines.push(` ${step.status}${step.skipReason ? ` · ${step.skipReason}` : ''}`);
82
+ }
83
+ return lines.join('\n');
84
+ }
@@ -0,0 +1,50 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chainStepLabel, projectSkillChains, renderSkillChain } from './skillChainView.js';
4
+
5
+ const WIKI_SYNC = [
6
+ {
7
+ id: 'c0', chainId: 'chain-1', chainSequence: 0, skillName: 'wiki-sync', selectionKind: 'description_match', status: 'done',
8
+ input: 'Export the requested Confluence source, or all configured sources when none is specified. Check configuration first.\n\nUser parameters:\nsource: docs',
9
+ },
10
+ {
11
+ id: 'c1', chainId: 'chain-1', chainSequence: 1, skillName: 'wiki-sync', status: 'running', runId: 'run-b',
12
+ input: 'Ingest the newly exported Markdown into the wiki, with the normal mutation approval.',
13
+ },
14
+ ];
15
+
16
+ test('a chain reads as ordered steps with one short label each', () => {
17
+ const [chain] = projectSkillChains(WIKI_SYNC);
18
+ assert.equal(chain.skillName, 'wiki-sync');
19
+ assert.equal(chain.selectionKind, 'description_match');
20
+ assert.equal(chain.status, 'running');
21
+ assert.deepEqual(chain.steps.map((step) => step.symbol), ['✓', '●']);
22
+ assert.equal(chain.steps[0].label, 'Export the requested Confluence source, or all…');
23
+ assert.equal(chain.steps[1].runId, 'run-b');
24
+ });
25
+
26
+ test('the label drops the appended parameter block and never cuts a word', () => {
27
+ assert.equal(chainStepLabel('Do the thing.\n\nUser parameters:\nsource: docs'), 'Do the thing');
28
+ const label = chainStepLabel('An objective long enough to need clipping somewhere sensible indeed.');
29
+ assert.ok(label.endsWith('…'));
30
+ assert.ok(!label.includes(' '));
31
+ assert.ok(label.length <= 53);
32
+ });
33
+
34
+ test('after a cancel the chain shows the cancelled step and the skipped remainder', () => {
35
+ const [chain] = projectSkillChains([
36
+ { id: 'c0', chainId: 'k', chainSequence: 0, skillName: 'wiki-sync', status: 'done', input: 'Export source.' },
37
+ { id: 'c1', chainId: 'k', chainSequence: 1, status: 'cancelled', input: 'Ingest files.' },
38
+ { id: 'c2', chainId: 'k', chainSequence: 2, status: 'skipped', skipReason: 'chain_cancelled', input: 'Publish results.' },
39
+ ]);
40
+ assert.equal(chain.status, 'cancelled');
41
+ assert.equal(
42
+ renderSkillChain(chain),
43
+ ['wiki-sync', '', '✓ Export source', ' done', '× Ingest files', ' cancelled', '– Publish results', ' skipped · chain_cancelled'].join('\n'),
44
+ );
45
+ });
46
+
47
+ test('standalone control items are not chains', () => {
48
+ assert.deepEqual(projectSkillChains([{ id: 'x', status: 'queued', input: 'do something' }]), []);
49
+ assert.deepEqual(projectSkillChains(), []);
50
+ });
@@ -0,0 +1,135 @@
1
+ const OPTIONAL_RE = /^(?:si disponible|si possible|optionnellement|if available|if possible|optionally)\b[\s,:-]*/i;
2
+ const STRONG_CONNECTOR_RE = /\n\s*(?=(?:puis|ensuite|après cela|après .{0,80}?terminé|then|next|after .{0,80}?complete|si disponible|si possible|optionnellement|if available|if possible|optionally)\b)/gi;
3
+ const FORBIDDEN_FIELDS = /\b(?:agent|capability|capabilityPlan|MCP|tool(?: name)?)\s*:/i;
4
+ const MAX_OBJECTIVES = 12;
5
+
6
+ export async function compileSkillObjectives(skill, args = {}, { llmFallback = null } = {}) {
7
+ const body = String(skill?.body ?? '').trim();
8
+ if (!body) throw compileError('Skill body is empty.');
9
+ // Splitting must happen on the body alone. Appending the parameters first
10
+ // makes them part of the last objective only — `/wiki-sync ESPACE` would hand
11
+ // `source: ESPACE` to the ingest step and leave the export step, the one that
12
+ // actually needs it, exporting everything. The compiler cannot know which
13
+ // step consumes which parameter, so every objective carries them.
14
+ const deterministic = deterministicObjectives(body);
15
+ if (!deterministic.ambiguous) {
16
+ return withNaturalArguments(validateCompiledObjectives(deterministic.objectives), args);
17
+ }
18
+ if (typeof llmFallback === 'function') {
19
+ try {
20
+ // Parameters do not influence workflow boundaries. Give the fallback the
21
+ // authored body only, then append parameters exactly once to every
22
+ // validated objective below.
23
+ const compiled = normalizeFallback(await llmFallback({ skill, body, args, maxObjectives: MAX_OBJECTIVES }));
24
+ return withNaturalArguments(validateCompiledObjectives(compiled), args);
25
+ } catch { /* preserve the safe mono-intention fallback */ }
26
+ }
27
+ return withNaturalArguments(validateCompiledObjectives([objectiveFromText(body)]), args);
28
+ }
29
+
30
+ export function createSkillCompilerFallback(llm, { signal, timeoutMs = 8_000 } = {}) {
31
+ if (typeof llm?.completeWithTools !== 'function') return null;
32
+ return async ({ body, maxObjectives }) => {
33
+ const system = [
34
+ 'Split workflow prose into delegable business intentions without naming agents, capabilities or tools.',
35
+ `Return 1..${maxObjectives} ordered objectives. Preserve a complex single business capability as one objective.`,
36
+ ].join('\n');
37
+ const tool = {
38
+ type: 'function',
39
+ function: {
40
+ name: 'compiled_skill_objectives',
41
+ description: 'Return validated workflow objectives.',
42
+ parameters: {
43
+ type: 'object', required: ['objectives'], additionalProperties: false,
44
+ properties: { objectives: { type: 'array', items: { type: 'object', required: ['text'], properties: { text: { type: 'string' }, optional: { type: 'boolean' }, continueOnFailure: { type: 'boolean' } } } } },
45
+ },
46
+ },
47
+ };
48
+ const timeoutSignal = AbortSignal.timeout(Math.max(1, Number(timeoutMs) || 8_000));
49
+ const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
50
+ try {
51
+ const preferred = await llm.completeWithTools({ system, tools: [tool], toolChoice: { type: 'function', function: { name: 'compiled_skill_objectives' } }, messages: [{ role: 'user', content: body }], signal: requestSignal });
52
+ const call = preferred?.tool_calls?.find((item) => item?.function?.name === 'compiled_skill_objectives');
53
+ if (call?.function?.arguments) return JSON.parse(call.function.arguments);
54
+ if (preferred?.content) return preferred.content;
55
+ } catch { /* retry through JSON text below */ }
56
+ const plain = await llm.completeWithTools({
57
+ system: `${system}\nReturn JSON only as {"objectives":[{"text":"...","optional":false,"continueOnFailure":false}]}.`,
58
+ tools: [], messages: [{ role: 'user', content: body }], signal: requestSignal,
59
+ });
60
+ return plain?.content;
61
+ };
62
+ }
63
+
64
+ export function deterministicObjectives(body) {
65
+ const text = String(body ?? '').trim();
66
+ const numbered = splitExplicitList(text, /^\s*\d+[.)]\s+/gm);
67
+ if (numbered) return { objectives: numbered.map(objectiveFromText), ambiguous: false };
68
+ const bullets = splitExplicitList(text, /^\s*[-*+]\s+/gm);
69
+ if (bullets) return { objectives: bullets.map(objectiveFromText), ambiguous: false };
70
+ const connected = text.split(STRONG_CONNECTOR_RE).map((part) => part.trim()).filter(Boolean);
71
+ if (connected.length > 1) return { objectives: connected.map(objectiveFromText), ambiguous: false };
72
+ return { objectives: [objectiveFromText(text)], ambiguous: looksAmbiguous(text) };
73
+ }
74
+
75
+ export function validateCompiledObjectives(value) {
76
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_OBJECTIVES) throw compileError(`A skill must compile to 1..${MAX_OBJECTIVES} objectives.`);
77
+ return value.map((raw, index) => {
78
+ const item = typeof raw === 'string' ? objectiveFromText(raw) : { ...raw };
79
+ item.text = String(item.text ?? '').trim();
80
+ if (!item.text) throw compileError(`Objective ${index + 1} is empty.`);
81
+ if (FORBIDDEN_FIELDS.test(item.text) || Object.keys(item).some((key) => /^(?:agent|capability|capabilityPlan|mcp|tool)$/i.test(key))) throw compileError(`Objective ${index + 1} contains technical routing details.`);
82
+ return { text: item.text, optional: item.optional === true, continueOnFailure: item.optional === true || item.continueOnFailure === true };
83
+ });
84
+ }
85
+
86
+ function naturalArgumentBlock(args) {
87
+ const suffix = Object.entries(args ?? {})
88
+ .filter(([, value]) => String(value ?? '').trim())
89
+ .map(([name, value]) => `${name}: ${value}`)
90
+ .join('\n');
91
+ return suffix ? `\n\nUser parameters:\n${suffix}` : '';
92
+ }
93
+
94
+ // Applied after validation: a parameter named `agent` or `tool` would otherwise
95
+ // trip the routing-details guard, which must judge the authored intention, not
96
+ // what the caller typed on the command line.
97
+ function withNaturalArguments(objectives, args) {
98
+ const block = naturalArgumentBlock(args);
99
+ if (!block) return objectives;
100
+ return objectives.map((objective) => ({ ...objective, text: `${objective.text}${block}` }));
101
+ }
102
+
103
+ function splitExplicitList(text, markerRe) {
104
+ const matches = [...text.matchAll(markerRe)];
105
+ if (matches.length < 2) return null;
106
+ return matches.map((match, index) => text.slice(match.index + match[0].length, matches[index + 1]?.index ?? text.length).trim()).filter(Boolean);
107
+ }
108
+
109
+ function objectiveFromText(raw) {
110
+ const text = String(raw ?? '').trim();
111
+ const optional = OPTIONAL_RE.test(text);
112
+ return {
113
+ text: capitalize(text.replace(OPTIONAL_RE, '').replace(/^(?:puis|ensuite|après cela|then|next)\b[\s,:-]*/i, '').trim()),
114
+ optional,
115
+ continueOnFailure: optional,
116
+ };
117
+ }
118
+
119
+ function looksAmbiguous(text) {
120
+ return (text.match(/(?:^|[.!?]\s+)[A-ZÀ-Ý][^.!?]{0,80}\b(?:export|ingest|build|send|create|delete|sync|publish|diagnos|analyse|constru|envoi|cré|supprim)/gi)?.length ?? 0) > 2;
121
+ }
122
+
123
+ function normalizeFallback(value) {
124
+ if (Array.isArray(value)) return value;
125
+ if (Array.isArray(value?.objectives)) return value.objectives;
126
+ if (typeof value === 'string') {
127
+ const parsed = JSON.parse(value.replace(/^```json\s*|\s*```$/gi, ''));
128
+ return Array.isArray(parsed) ? parsed : parsed.objectives;
129
+ }
130
+ throw compileError('Invalid compiler fallback response.');
131
+ }
132
+
133
+ function compileError(message) { const error = new Error(message); error.code = 'skill_compile_failed'; return error; }
134
+
135
+ function capitalize(text) { return text ? `${text[0].toLocaleUpperCase()}${text.slice(1)}` : text; }