@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.
- package/README.md +139 -27
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +290 -29
- package/src/agent/graph.test.js +551 -1
- package/src/agent/skillRecursion.test.js +98 -0
- package/src/cli/wiki-manager.js +209 -7
- package/src/cli/wiki-manager.test.js +89 -0
- package/src/commands/slash.js +28 -10
- package/src/contracts/schemas.js +1 -1
- package/src/core/agentEvents.js +50 -0
- package/src/core/agentEvents.test.js +52 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/env.js +20 -1
- package/src/core/env.test.js +34 -0
- package/src/core/mcp.js +1 -1
- package/src/core/profile.js +19 -0
- package/src/core/runtimeLog.js +15 -0
- package/src/core/runtimeLog.test.js +15 -1
- package/src/core/skillChainView.js +84 -0
- package/src/core/skillChainView.test.js +50 -0
- package/src/core/skillCompiler.js +135 -0
- package/src/core/skillCompiler.test.js +91 -0
- package/src/core/skillInvocation.js +79 -0
- package/src/core/skillInvocation.test.js +73 -0
- package/src/core/skills.js +81 -19
- package/src/core/wikiWorkspace.test.js +34 -0
- package/src/core/workspaceProfile.test.js +55 -0
- package/src/runtime/client.js +45 -4
- package/src/runtime/controlCancellation.js +33 -0
- package/src/runtime/controlCancellation.test.js +49 -0
- package/src/runtime/controlDrain.js +50 -0
- package/src/runtime/controlDrain.test.js +38 -0
- package/src/runtime/server.js +341 -20
- package/src/runtime/server.test.js +344 -2
- package/src/runtime/skillChain.e2e.test.js +394 -0
- package/src/runtime/skillRun.js +104 -0
- package/src/runtime/skillRun.test.js +84 -0
- package/src/runtime/store.js +69 -0
- package/src/runtime/store.test.js +11 -0
- package/src/runtime/workspaceIsolation.test.js +178 -0
- package/src/shell/RightPane.tsx +3 -2
- package/src/shell/repl.js +51 -6
- package/src/shell/repl.test.js +41 -0
- package/src/shell/useSession.ts +43 -9
- package/wiki-workspace +137 -1
package/src/core/agentEvents.js
CHANGED
|
@@ -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', {
|
package/src/core/buildInfo.json
CHANGED
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
|
-
|
|
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
|
}
|
package/src/core/env.test.js
CHANGED
|
@@ -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.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.48';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/profile.js
CHANGED
|
@@ -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 '';
|
package/src/core/runtimeLog.js
CHANGED
|
@@ -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; }
|