@dotdrelle/wiki-manager 0.15.43 → 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 +92 -24
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +288 -22
- 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/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/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 +326 -15
- package/src/runtime/server.test.js +341 -0
- 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/shell/RightPane.tsx +3 -2
- package/src/shell/repl.js +40 -5
- package/src/shell/repl.test.js +41 -0
- package/src/shell/useSession.ts +43 -9
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { compileSkillObjectives, createSkillCompilerFallback, deterministicObjectives, validateCompiledObjectives } from './skillCompiler.js';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { parseFrontmatter } from './skills.js';
|
|
7
|
+
|
|
8
|
+
test('numbered and bullet lists are strong objective boundaries', () => {
|
|
9
|
+
assert.equal(deterministicObjectives('1. Export source\n2. Ingest files').objectives.length, 2);
|
|
10
|
+
assert.equal(deterministicObjectives('- Export source\n- Ingest files').objectives.length, 2);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('paragraphs preserve one delegable intention while Puis splits', async () => {
|
|
14
|
+
assert.equal((await compileSkillObjectives({ body: 'Run the complete pipeline.\n\nInclude indexing.\n\nReport it.' })).length, 1);
|
|
15
|
+
assert.equal((await compileSkillObjectives({ body: 'Exporter la source.\n\nPuis ingérer les fichiers.' })).length, 2);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('optional objectives continue on failure', async () => {
|
|
19
|
+
const result = await compileSkillObjectives({ body: 'Export source.\n\nOptionnellement, envoyer une notification.' });
|
|
20
|
+
assert.equal(result[1].optional, true);
|
|
21
|
+
assert.equal(result[1].continueOnFailure, true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('compiler appends natural parameters without teaching placeholders', async () => {
|
|
25
|
+
const [objective] = await compileSkillObjectives({ body: 'Build the deliverable.' }, { template: 'architecture' });
|
|
26
|
+
assert.match(objective.text, /User parameters:\ntemplate: architecture/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('validation rejects technical routing details', () => {
|
|
30
|
+
assert.throws(() => validateCompiledObjectives([{ text: 'agent: cme' }]), { code: 'skill_compile_failed' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('scaffold skills preserve existing capabilities and split only wiki-sync', async () => {
|
|
34
|
+
const expected = { pipeline: 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
|
|
35
|
+
for (const [name, count] of Object.entries(expected)) {
|
|
36
|
+
const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
|
|
37
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
38
|
+
assert.equal((await compileSkillObjectives({ ...meta, body })).length, count, name);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('LLM fallback degrades from missing tool calls to validated JSON text', async () => {
|
|
43
|
+
let calls = 0;
|
|
44
|
+
const fallback = createSkillCompilerFallback({ completeWithTools: async ({ tools }) => {
|
|
45
|
+
calls += 1;
|
|
46
|
+
return tools.length ? { content: null, tool_calls: null } : { content: '{"objectives":[{"text":"Do the coherent workflow."}]}' };
|
|
47
|
+
} });
|
|
48
|
+
assert.equal(await fallback({ body: 'ambiguous', maxObjectives: 12 }), '{"objectives":[{"text":"Do the coherent workflow."}]}');
|
|
49
|
+
assert.equal(calls, 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('every objective of a chain carries the user parameters, not just the last', async () => {
|
|
53
|
+
// Appending the parameters before splitting attached `source` to the ingest
|
|
54
|
+
// step and left the export step — the one that consumes it — without it.
|
|
55
|
+
const skill = {
|
|
56
|
+
name: 'wiki-sync',
|
|
57
|
+
params: ['source'],
|
|
58
|
+
body: 'Export the requested source.\n\nThen ingest what was exported.',
|
|
59
|
+
};
|
|
60
|
+
const objectives = await compileSkillObjectives(skill, { source: 'ESPACE-CONF' });
|
|
61
|
+
assert.equal(objectives.length, 2);
|
|
62
|
+
for (const objective of objectives) {
|
|
63
|
+
assert.match(objective.text, /User parameters:\nsource: ESPACE-CONF/);
|
|
64
|
+
}
|
|
65
|
+
assert.match(objectives[0].text, /^Export the requested source\./);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a parameter named after a routing field does not fail validation', async () => {
|
|
69
|
+
// The routing guard judges the authored intention, not what the caller typed.
|
|
70
|
+
const objectives = await compileSkillObjectives(
|
|
71
|
+
{ name: 'x', params: ['agent'], body: 'Do the thing.' },
|
|
72
|
+
{ agent: 'production' },
|
|
73
|
+
);
|
|
74
|
+
assert.equal(objectives.length, 1);
|
|
75
|
+
assert.match(objectives[0].text, /agent: production/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('LLM fallback sees authored prose and parameters are appended exactly once', async () => {
|
|
79
|
+
let fallbackBody = null;
|
|
80
|
+
const objectives = await compileSkillObjectives(
|
|
81
|
+
{ body: 'Please export the source. Please build the result. Please send the report.' },
|
|
82
|
+
{ source: 'SPACE' },
|
|
83
|
+
{ llmFallback: async ({ body }) => {
|
|
84
|
+
fallbackBody = body;
|
|
85
|
+
return [{ text: body }];
|
|
86
|
+
} },
|
|
87
|
+
);
|
|
88
|
+
assert.doesNotMatch(fallbackBody, /User parameters:/);
|
|
89
|
+
assert.equal(objectives[0].text.match(/User parameters:/g)?.length, 1);
|
|
90
|
+
assert.equal(objectives[0].text.match(/source: SPACE/g)?.length, 1);
|
|
91
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { findSkill } from './skills.js';
|
|
2
|
+
|
|
3
|
+
const INVOCATION_RE = /^\/([A-Za-z0-9_-]+)(?:\s+([\s\S]*))?$/;
|
|
4
|
+
export const RESERVED_SLASH_COMMANDS = new Set(['status', 'stop', 'run', 'queue', 'skills', 'help', 'exit', 'quit', 'chat', 'agent']);
|
|
5
|
+
|
|
6
|
+
export function explicitSkillReference(input, skillName, language = null) {
|
|
7
|
+
const name = escapeRegExp(String(skillName ?? '').trim());
|
|
8
|
+
if (!name) return false;
|
|
9
|
+
const primary = String(language ?? '').toLowerCase().split(/[-_]/)[0];
|
|
10
|
+
if (!['en', 'fr'].includes(primary)) return false;
|
|
11
|
+
const text = String(input ?? '').split(/[.!?\n]/).map((part) => part.trim()).filter(Boolean);
|
|
12
|
+
const keyword = '(?:skill|workflow)';
|
|
13
|
+
const patterns = [
|
|
14
|
+
new RegExp(`\\b${keyword}\\s+${name}\\b`, 'i'),
|
|
15
|
+
new RegExp(`\\b${name}\\s+${keyword}\\b`, 'i'),
|
|
16
|
+
new RegExp(`/skills\\s+run\\s+${name}\\b`, 'i'),
|
|
17
|
+
new RegExp(`/${name}\\s+(?:comme|as)\\s+${keyword}\\b`, 'i'),
|
|
18
|
+
];
|
|
19
|
+
return text.some((sentence) => patterns.some((pattern) => pattern.test(sentence)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function matchSkillInvocation(session, input, { allowReserved = false } = {}) {
|
|
23
|
+
const match = INVOCATION_RE.exec(String(input ?? '').trim());
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
if (!allowReserved && RESERVED_SLASH_COMMANDS.has(match[1].toLowerCase())) return null;
|
|
26
|
+
const skill = findSkill(session, match[1]);
|
|
27
|
+
return skill ? { skill, rawArgs: String(match[2] ?? '').trim(), input: String(input ?? '').trim() } : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseSkillArguments(skill, rawArgs = '') {
|
|
31
|
+
const params = Array.isArray(skill?.params) ? skill.params : [];
|
|
32
|
+
if (params.length === 0) return {};
|
|
33
|
+
if (params.length === 1) return { [params[0]]: unquote(String(rawArgs).trim()) };
|
|
34
|
+
const tokens = tokenizeArguments(rawArgs);
|
|
35
|
+
return Object.fromEntries(params.map((param, index) => [param, index === params.length - 1 ? tokens.slice(index).join(' ') : (tokens[index] ?? '')]));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function applyLegacySkillPlaceholders(body, args) {
|
|
39
|
+
let text = String(body ?? '');
|
|
40
|
+
const used = [];
|
|
41
|
+
for (const [name, value] of Object.entries(args ?? {})) {
|
|
42
|
+
const marker = `{${name}}`;
|
|
43
|
+
if (!text.includes(marker)) continue;
|
|
44
|
+
text = text.replaceAll(marker, String(value ?? ''));
|
|
45
|
+
used.push(name);
|
|
46
|
+
}
|
|
47
|
+
return { body: text, deprecatedPlaceholders: used };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tokenizeArguments(raw) {
|
|
51
|
+
const tokens = [];
|
|
52
|
+
let current = '';
|
|
53
|
+
let quote = null;
|
|
54
|
+
let escaped = false;
|
|
55
|
+
for (const char of String(raw ?? '').trim()) {
|
|
56
|
+
if (escaped) { current += char; escaped = false; }
|
|
57
|
+
else if (char === '\\') escaped = true;
|
|
58
|
+
else if (quote) { if (char === quote) quote = null; else current += char; }
|
|
59
|
+
else if (char === '"' || char === "'") quote = char;
|
|
60
|
+
else if (/\s/.test(char)) { if (current) { tokens.push(current); current = ''; } }
|
|
61
|
+
else current += char;
|
|
62
|
+
}
|
|
63
|
+
if (escaped) current += '\\';
|
|
64
|
+
if (quote) {
|
|
65
|
+
const error = new Error('Unterminated quoted skill argument. Close the quote and retry.');
|
|
66
|
+
error.code = 'skill_arguments_invalid';
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
if (current) tokens.push(current);
|
|
70
|
+
return tokens;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function unquote(value) {
|
|
74
|
+
return value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ? value.slice(1, -1) : value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function escapeRegExp(value) {
|
|
78
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
79
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { applyLegacySkillPlaceholders, explicitSkillReference, matchSkillInvocation, parseSkillArguments } from './skillInvocation.js';
|
|
7
|
+
import { formatSkillsForAgent, inspectSkills } from './skills.js';
|
|
8
|
+
|
|
9
|
+
test('matchSkillInvocation resolves only a real workspace skill', () => {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), 'skill-invocation-'));
|
|
11
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
12
|
+
writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver.');
|
|
13
|
+
const match = matchSkillInvocation({ workspacePath: root }, '/deliver "architecture" "improve security"');
|
|
14
|
+
assert.equal(match.skill.name, 'deliver');
|
|
15
|
+
assert.equal(match.rawArgs, '"architecture" "improve security"');
|
|
16
|
+
assert.equal(matchSkillInvocation({ workspacePath: root }, '/status'), null);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('parseSkillArguments preserves one free-form argument and parses quoted multi params', () => {
|
|
20
|
+
assert.deepEqual(parseSkillArguments({ params: ['files'] }, 'document A.md document B.md'), { files: 'document A.md document B.md' });
|
|
21
|
+
assert.deepEqual(parseSkillArguments({ params: ['template', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { template: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('legacy placeholders remain supported and are reported', () => {
|
|
25
|
+
assert.deepEqual(applyLegacySkillPlaceholders('Sync {source}.', { source: 'CME' }), { body: 'Sync CME.', deprecatedPlaceholders: ['source'] });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('reserved skill references require an explicit skill or workflow designation', () => {
|
|
29
|
+
assert.equal(explicitSkillReference('lance le skill status', 'status', 'fr-FR'), true);
|
|
30
|
+
assert.equal(explicitSkillReference('/status comme skill', 'status', 'fr-FR'), true);
|
|
31
|
+
assert.equal(explicitSkillReference('/skills run status', 'status', 'en-US'), true);
|
|
32
|
+
assert.equal(explicitSkillReference('What is the status of the current run?', 'status', 'en-US'), false);
|
|
33
|
+
assert.equal(explicitSkillReference('lance /status', 'status', 'fr-FR'), false);
|
|
34
|
+
assert.equal(explicitSkillReference('lance le skill status', 'status', 'de-DE'), false);
|
|
35
|
+
assert.equal(explicitSkillReference('run the status skill', 'status'), false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('inspectSkills rejects invalid parameters and case-insensitive name collisions with relative paths', () => {
|
|
39
|
+
const root = mkdtempSync(join(tmpdir(), 'skill-inspection-'));
|
|
40
|
+
const dir = join(root, '.wiki', 'skills');
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
writeFileSync(join(dir, 'one.md'), '---\nname: Build\ndescription: First\n---\nOne.');
|
|
43
|
+
writeFileSync(join(dir, 'two.md'), '---\nname: build\ndescription: Second\n---\nTwo.');
|
|
44
|
+
writeFileSync(join(dir, 'bad.md'), '---\nname: bad\nparams:\n - __proto__\n---\nBad.');
|
|
45
|
+
const result = inspectSkills({ workspacePath: root });
|
|
46
|
+
assert.equal(result.skills.length, 0);
|
|
47
|
+
assert.deepEqual(result.rejected.map((item) => item.reason).sort(), ['duplicate_name', 'duplicate_name', 'invalid_param']);
|
|
48
|
+
assert.equal(result.rejected.every((item) => !item.relativePath.startsWith('/')), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('skills declare whether execution is orchestrated or direct', () => {
|
|
52
|
+
const root = mkdtempSync(join(tmpdir(), 'skill-execution-'));
|
|
53
|
+
const dir = join(root, '.wiki', 'skills');
|
|
54
|
+
mkdirSync(dir, { recursive: true });
|
|
55
|
+
writeFileSync(join(dir, 'direct.md'), '---\nname: direct\ndescription: Direct action\nexecution: direct\n---\nAct directly.');
|
|
56
|
+
writeFileSync(join(dir, 'default.md'), '---\nname: default\ndescription: Delegated action\n---\nDelegate when needed.');
|
|
57
|
+
writeFileSync(join(dir, 'invalid.md'), '---\nname: invalid\ndescription: Invalid policy\nexecution: magical\n---\nNo.');
|
|
58
|
+
|
|
59
|
+
const result = inspectSkills({ workspacePath: root });
|
|
60
|
+
assert.equal(result.skills.find((skill) => skill.name === 'direct')?.execution, 'direct');
|
|
61
|
+
assert.equal(result.skills.find((skill) => skill.name === 'default')?.execution, 'orchestrated');
|
|
62
|
+
assert.equal(result.rejected.find((item) => item.name === 'invalid')?.reason, 'invalid_execution');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('catalog renders declared parameters and marks a missing description explicit-only', () => {
|
|
66
|
+
const root = mkdtempSync(join(tmpdir(), 'skill-catalog-'));
|
|
67
|
+
const dir = join(root, '.wiki', 'skills');
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
writeFileSync(join(dir, 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver.');
|
|
70
|
+
const inspection = inspectSkills({ workspacePath: root });
|
|
71
|
+
assert.deepEqual(inspection.warnings.map((item) => item.reason), ['missing_description']);
|
|
72
|
+
assert.match(formatSkillsForAgent({ workspacePath: root }), /\/deliver \[<template> <polish>\]: workflow skill \[explicit name only\]/);
|
|
73
|
+
});
|
package/src/core/skills.js
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { basename, join } from 'node:path';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
3
|
|
|
4
|
-
const SKILL_NAME_RE = /^[a-zA-Z0-9_-]{
|
|
4
|
+
const SKILL_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
5
|
+
const SKILL_PARAM_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
6
|
+
const DANGEROUS_PARAM_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
|
|
5
7
|
const DEFAULT_UI_SKILL_DIR = '.wiki/skills';
|
|
8
|
+
// The CSI branch must come FIRST. `[` is 0x5B, inside the `[@-_]` range, so the
|
|
9
|
+
// two-character alternative would otherwise consume `ESC [` alone and leave the
|
|
10
|
+
// parameter bytes behind as literal text: "\x1B[31m" would become "31m".
|
|
11
|
+
const ANSI_ESCAPE_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g;
|
|
12
|
+
const CONTROL_CHARACTER_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
13
|
+
const MAX_SKILL_DESCRIPTION_LENGTH = 200;
|
|
6
14
|
|
|
7
|
-
function
|
|
15
|
+
function descriptionMetadata(value) {
|
|
16
|
+
const normalized = String(value || '')
|
|
17
|
+
.replace(ANSI_ESCAPE_RE, '')
|
|
18
|
+
.replace(CONTROL_CHARACTER_RE, '')
|
|
19
|
+
.replace(/\s+/g, ' ')
|
|
20
|
+
.trim();
|
|
21
|
+
return {
|
|
22
|
+
value: normalized.slice(0, MAX_SKILL_DESCRIPTION_LENGTH),
|
|
23
|
+
missing: normalized.length === 0,
|
|
24
|
+
truncated: normalized.length > MAX_SKILL_DESCRIPTION_LENGTH,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapePromptData(value) {
|
|
29
|
+
return String(value)
|
|
30
|
+
.replaceAll('&', '&')
|
|
31
|
+
.replaceAll('<', '<')
|
|
32
|
+
.replaceAll('>', '>');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseFrontmatter(raw) {
|
|
8
36
|
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
9
37
|
if (!match) return { meta: {}, body: raw.trim() };
|
|
10
38
|
const meta = {};
|
|
@@ -30,19 +58,36 @@ function parseFrontmatter(raw) {
|
|
|
30
58
|
return { meta, body: match[2].trim() };
|
|
31
59
|
}
|
|
32
60
|
|
|
33
|
-
function
|
|
61
|
+
function inspectSkillFile(filePath, fallbackName, scope, root) {
|
|
34
62
|
const raw = readFileSync(filePath, 'utf8');
|
|
35
63
|
const { meta, body } = parseFrontmatter(raw);
|
|
36
64
|
const name = String(meta.name || fallbackName).trim();
|
|
37
|
-
|
|
38
|
-
return {
|
|
65
|
+
const relativePath = relative(root, filePath);
|
|
66
|
+
if (!SKILL_NAME_RE.test(name)) return { rejected: { relativePath, name: name || undefined, reason: 'invalid_name' } };
|
|
67
|
+
const params = Array.isArray(meta.params) ? meta.params.map((item) => String(item).trim()) : [];
|
|
68
|
+
if (params.length > 12) return { rejected: { relativePath, name, reason: 'too_many_params' } };
|
|
69
|
+
if (new Set(params).size !== params.length) return { rejected: { relativePath, name, reason: 'duplicate_param' } };
|
|
70
|
+
if (params.some((param) => !SKILL_PARAM_RE.test(param) || DANGEROUS_PARAM_NAMES.has(param))) {
|
|
71
|
+
return { rejected: { relativePath, name, reason: 'invalid_param' } };
|
|
72
|
+
}
|
|
73
|
+
const description = descriptionMetadata(meta.description);
|
|
74
|
+
const execution = String(meta.execution || 'orchestrated').trim().toLowerCase();
|
|
75
|
+
if (!['orchestrated', 'direct'].includes(execution)) {
|
|
76
|
+
return { rejected: { relativePath, name, reason: 'invalid_execution' } };
|
|
77
|
+
}
|
|
78
|
+
const skill = {
|
|
39
79
|
name,
|
|
40
|
-
description:
|
|
41
|
-
params
|
|
80
|
+
description: description.value,
|
|
81
|
+
params,
|
|
42
82
|
body,
|
|
83
|
+
execution,
|
|
43
84
|
scope,
|
|
44
85
|
path: filePath,
|
|
45
86
|
};
|
|
87
|
+
const warnings = [];
|
|
88
|
+
if (description.missing) warnings.push({ relativePath, name, reason: 'missing_description' });
|
|
89
|
+
else if (description.truncated) warnings.push({ relativePath, name, reason: 'description_truncated' });
|
|
90
|
+
return { skill, warnings };
|
|
46
91
|
}
|
|
47
92
|
|
|
48
93
|
export function readOptionalText(filePath) {
|
|
@@ -50,36 +95,53 @@ export function readOptionalText(filePath) {
|
|
|
50
95
|
return readFileSync(filePath, 'utf8').trim();
|
|
51
96
|
}
|
|
52
97
|
|
|
53
|
-
function
|
|
98
|
+
function inspectDirectorySkills(dir, scope, root) {
|
|
54
99
|
if (!existsSync(dir)) return [];
|
|
55
100
|
return readdirSync(dir, { withFileTypes: true })
|
|
56
101
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
57
102
|
.flatMap((entry) => {
|
|
58
103
|
const filePath = join(dir, entry.name);
|
|
59
|
-
|
|
60
|
-
return skill ? [skill] : [];
|
|
104
|
+
return [inspectSkillFile(filePath, basename(entry.name, '.md'), scope, root)];
|
|
61
105
|
});
|
|
62
106
|
}
|
|
63
107
|
|
|
64
|
-
export function
|
|
65
|
-
const
|
|
108
|
+
export function inspectSkills(session = {}) {
|
|
109
|
+
const inspected = [];
|
|
66
110
|
if (session.workspacePath) {
|
|
67
|
-
|
|
111
|
+
const root = join(session.workspacePath, DEFAULT_UI_SKILL_DIR);
|
|
112
|
+
inspected.push(...inspectDirectorySkills(root, 'workspace', root));
|
|
68
113
|
}
|
|
114
|
+
const rejected = inspected.flatMap((item) => item.rejected ? [item.rejected] : []);
|
|
115
|
+
const warnings = inspected.flatMap((item) => item.warnings ?? []);
|
|
116
|
+
const candidates = inspected.flatMap((item) => item.skill ? [item.skill] : []);
|
|
117
|
+
const groups = Map.groupBy(candidates, (skill) => skill.name.toLowerCase());
|
|
118
|
+
const duplicatePaths = new Set();
|
|
119
|
+
for (const group of groups.values()) {
|
|
120
|
+
if (group.length < 2) continue;
|
|
121
|
+
for (const skill of group) {
|
|
122
|
+
const relativePath = relative(join(session.workspacePath, DEFAULT_UI_SKILL_DIR), skill.path);
|
|
123
|
+
duplicatePaths.add(skill.path);
|
|
124
|
+
rejected.push({ relativePath, name: skill.name, reason: 'duplicate_name' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const skills = candidates.filter((skill) => !duplicatePaths.has(skill.path))
|
|
128
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
129
|
+
return { skills, rejected, warnings };
|
|
130
|
+
}
|
|
69
131
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
132
|
+
export function listSkills(session = {}) {
|
|
133
|
+
return inspectSkills(session).skills;
|
|
73
134
|
}
|
|
74
135
|
|
|
75
136
|
export function findSkill(session, name) {
|
|
76
|
-
|
|
137
|
+
const normalized = String(name ?? '').toLowerCase();
|
|
138
|
+
return listSkills(session).find((skill) => skill.name.toLowerCase() === normalized) ?? null;
|
|
77
139
|
}
|
|
78
140
|
|
|
79
141
|
export function formatSkillsForAgent(session) {
|
|
80
142
|
const skills = listSkills(session);
|
|
81
143
|
if (!skills.length) return 'No skills discovered.';
|
|
82
144
|
return skills
|
|
83
|
-
.map((skill) => `/${skill.name}: ${skill.description || 'workflow skill'} (${skill.scope})`)
|
|
145
|
+
.map((skill) => `/${skill.name}${skill.params.length ? ` [${skill.params.map((param) => `<${param}>`).join(' ')}]` : ''}: ${escapePromptData(skill.description || 'workflow skill')}${skill.description ? '' : ' [explicit name only]'} (${skill.scope})`)
|
|
84
146
|
.join('\n');
|
|
85
147
|
}
|
package/src/runtime/client.js
CHANGED
|
@@ -55,6 +55,7 @@ export async function postRuntimeRun(input, {
|
|
|
55
55
|
evaluate = undefined,
|
|
56
56
|
replans = undefined,
|
|
57
57
|
capabilityPlan = undefined,
|
|
58
|
+
skillName = undefined,
|
|
58
59
|
} = {}) {
|
|
59
60
|
const response = await fetch(runtimeEndpoint(url, '/run', workspace), {
|
|
60
61
|
method: 'POST',
|
|
@@ -62,14 +63,53 @@ export async function postRuntimeRun(input, {
|
|
|
62
63
|
...runtimeHeaders(token),
|
|
63
64
|
'Content-Type': 'application/json',
|
|
64
65
|
},
|
|
65
|
-
body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans }, capabilityPlan !== undefined && { capabilityPlan })),
|
|
66
|
+
body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans }, capabilityPlan !== undefined && { capabilityPlan }, skillName !== undefined && { skillName })),
|
|
66
67
|
});
|
|
68
|
+
const payload = await response.json().catch(() => ({}));
|
|
67
69
|
if (!response.ok) {
|
|
68
|
-
const
|
|
70
|
+
const detail = payload.error ?? payload.message ?? payload.detail;
|
|
71
|
+
const err = new Error(detail
|
|
72
|
+
? `Runtime run failed: HTTP ${response.status} — ${detail}`
|
|
73
|
+
: `Runtime run failed: HTTP ${response.status}`);
|
|
69
74
|
err.status = response.status;
|
|
75
|
+
err.code = payload.code;
|
|
70
76
|
throw err;
|
|
71
77
|
}
|
|
72
|
-
return
|
|
78
|
+
return payload;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function postRuntimeSkill(skillName, args = {}, {
|
|
82
|
+
url = runtimeUrlFromEnv(),
|
|
83
|
+
token = runtimeToken(),
|
|
84
|
+
workspace = null,
|
|
85
|
+
idempotencyKey = undefined,
|
|
86
|
+
turnId = undefined,
|
|
87
|
+
selectionKind = undefined,
|
|
88
|
+
/** Compétences déjà en cours au-dessus de l'appelant, pour la détection de cycle. */
|
|
89
|
+
skillStack = undefined,
|
|
90
|
+
} = {}) {
|
|
91
|
+
const response = await fetch(runtimeEndpoint(url, '/run', workspace), {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { ...runtimeHeaders(token), 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
input: `/${skillName}`,
|
|
96
|
+
workspace,
|
|
97
|
+
skillName,
|
|
98
|
+
skillArguments: args,
|
|
99
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
100
|
+
...(turnId ? { turnId } : {}),
|
|
101
|
+
...(selectionKind ? { selectionKind } : {}),
|
|
102
|
+
...(Array.isArray(skillStack) && skillStack.length ? { skillStack } : {}),
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
const payload = await response.json().catch(() => ({}));
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
const error = new Error(payload.error ?? `Runtime skill failed: HTTP ${response.status}`);
|
|
108
|
+
error.status = response.status;
|
|
109
|
+
error.code = payload.code;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return payload;
|
|
73
113
|
}
|
|
74
114
|
|
|
75
115
|
export async function postRuntimeTurn(input, {
|
|
@@ -120,6 +160,7 @@ export async function postRuntimeControl(action, {
|
|
|
120
160
|
workspace = null,
|
|
121
161
|
input = undefined,
|
|
122
162
|
intent = undefined,
|
|
163
|
+
id = undefined,
|
|
123
164
|
} = {}) {
|
|
124
165
|
const response = await fetch(runtimeEndpoint(url, '/control', workspace), {
|
|
125
166
|
method: 'POST',
|
|
@@ -127,7 +168,7 @@ export async function postRuntimeControl(action, {
|
|
|
127
168
|
...runtimeHeaders(token),
|
|
128
169
|
'Content-Type': 'application/json',
|
|
129
170
|
},
|
|
130
|
-
body: JSON.stringify(Object.assign({ action }, input !== undefined && { input }, intent !== undefined && { intent })),
|
|
171
|
+
body: JSON.stringify(Object.assign({ action }, input !== undefined && { input }, intent !== undefined && { intent }, id !== undefined && { id })),
|
|
131
172
|
});
|
|
132
173
|
if (!response.ok) {
|
|
133
174
|
const err = new Error(`Runtime control failed: HTTP ${response.status}`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function cancelControlChain(session, { runId = null, itemId = null, reason = 'chain_cancelled', cancelItem } = {}) {
|
|
2
|
+
const queue = Array.isArray(session?.controlQueue) ? session.controlQueue : [];
|
|
3
|
+
const active = queue.find((item) => (itemId && item.id === itemId) || (runId && item.runId === runId));
|
|
4
|
+
if (!active) return { active: null, skipped: 0 };
|
|
5
|
+
// A standalone control item carries no chainId at all. Without this guard the
|
|
6
|
+
// sibling test below compares undefined to undefined, matches every queued
|
|
7
|
+
// standalone request, and turns `/run cancel` into a full queue wipe.
|
|
8
|
+
if (!active.chainId) return { active, skipped: 0 };
|
|
9
|
+
if (active.optional === true || active.continueOnFailure === true) return { active, skipped: 0 };
|
|
10
|
+
let skipped = 0;
|
|
11
|
+
for (const item of queue) {
|
|
12
|
+
if (item.id === active.id || item.chainId !== active.chainId || item.status !== 'queued') continue;
|
|
13
|
+
cancelItem?.(item, reason);
|
|
14
|
+
skipped += 1;
|
|
15
|
+
}
|
|
16
|
+
return { active, skipped };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function cancelQueuedControlItem(session, itemId, { cancelItem, skipItem } = {}) {
|
|
20
|
+
const queue = Array.isArray(session?.controlQueue) ? session.controlQueue : [];
|
|
21
|
+
const item = queue.find((entry) => String(entry.id) === String(itemId));
|
|
22
|
+
if (!item || item.status !== 'queued') return { cancelled: false, reason: item ? 'not_queued' : 'not_found' };
|
|
23
|
+
cancelItem?.(item, 'item_cancelled');
|
|
24
|
+
let skipped = 0;
|
|
25
|
+
if (item.chainId && item.optional !== true && item.continueOnFailure !== true) {
|
|
26
|
+
for (const sibling of queue) {
|
|
27
|
+
if (sibling.chainId !== item.chainId || sibling.status !== 'queued' || Number(sibling.chainSequence) <= Number(item.chainSequence)) continue;
|
|
28
|
+
skipItem?.(sibling, 'required_predecessor_cancelled');
|
|
29
|
+
skipped += 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { cancelled: true, item, skipped };
|
|
33
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
|
|
4
|
+
|
|
5
|
+
test('run cancellation skips queued siblings of the same chain only', () => {
|
|
6
|
+
const session = { controlQueue: [
|
|
7
|
+
{ id: 'a1', runId: 'run-a', chainId: 'a', status: 'running' },
|
|
8
|
+
{ id: 'a2', chainId: 'a', status: 'queued' },
|
|
9
|
+
{ id: 'b1', chainId: 'b', status: 'queued' },
|
|
10
|
+
] };
|
|
11
|
+
const result = cancelControlChain(session, { runId: 'run-a', cancelItem: (item) => { item.status = 'skipped'; } });
|
|
12
|
+
assert.equal(result.skipped, 1);
|
|
13
|
+
assert.equal(session.controlQueue[2].status, 'queued');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('targeted cancellation propagates only after a required item', () => {
|
|
17
|
+
const session = { controlQueue: [
|
|
18
|
+
{ id: 'a1', chainId: 'a', chainSequence: 0, status: 'queued' },
|
|
19
|
+
{ id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
|
|
20
|
+
] };
|
|
21
|
+
const result = cancelQueuedControlItem(session, 'a1', {
|
|
22
|
+
cancelItem: (item) => { item.status = 'cancelled'; },
|
|
23
|
+
skipItem: (item) => { item.status = 'skipped'; },
|
|
24
|
+
});
|
|
25
|
+
assert.deepEqual({ cancelled: result.cancelled, skipped: result.skipped }, { cancelled: true, skipped: 1 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('run cancellation of a standalone item never touches the rest of the queue', () => {
|
|
29
|
+
const session = { controlQueue: [
|
|
30
|
+
{ id: 'a', runId: 'run-1', status: 'running', optional: false, continueOnFailure: false },
|
|
31
|
+
{ id: 'b', status: 'queued', optional: false, continueOnFailure: false },
|
|
32
|
+
{ id: 'c', status: 'queued', optional: false, continueOnFailure: false },
|
|
33
|
+
] };
|
|
34
|
+
const cancelled = [];
|
|
35
|
+
const result = cancelControlChain(session, { runId: 'run-1', cancelItem: (item) => cancelled.push(item.id) });
|
|
36
|
+
assert.deepEqual(cancelled, []);
|
|
37
|
+
assert.equal(result.skipped, 0);
|
|
38
|
+
assert.deepEqual(session.controlQueue.map((item) => item.status), ['running', 'queued', 'queued']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('run cancellation on an optional active item preserves later chain steps', () => {
|
|
42
|
+
const session = { controlQueue: [
|
|
43
|
+
{ id: 'optional', runId: 'run-optional', chainId: 'a', status: 'running', optional: true },
|
|
44
|
+
{ id: 'next', chainId: 'a', status: 'queued' },
|
|
45
|
+
] };
|
|
46
|
+
const result = cancelControlChain(session, { runId: 'run-optional', cancelItem: (item) => { item.status = 'skipped'; } });
|
|
47
|
+
assert.equal(result.skipped, 0);
|
|
48
|
+
assert.equal(session.controlQueue[1].status, 'queued');
|
|
49
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statuses-vocabulary
|
|
3
|
+
* Control items are queued run requests, not orchestrator tasks. Their
|
|
4
|
+
* terminal vocabulary deliberately includes chain-level `skipped` and is
|
|
5
|
+
* projected by core/agentEvents.js rather than taskStatuses.js.
|
|
6
|
+
*/
|
|
7
|
+
const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
|
|
8
|
+
|
|
9
|
+
export function reconcileControlQueue(context, { startItem, skipItem } = {}) {
|
|
10
|
+
if (!context?.session || context.running || context.controlDrainActive) return false;
|
|
11
|
+
context.controlDrainActive = true;
|
|
12
|
+
try {
|
|
13
|
+
const queue = Array.isArray(context.session.controlQueue) ? context.session.controlQueue : [];
|
|
14
|
+
applySkipPropagation(queue, skipItem);
|
|
15
|
+
// Event dispatch replaces the projection array and its objects. Re-read it
|
|
16
|
+
// after propagation so eligibility never runs against stale queued items.
|
|
17
|
+
const refreshed = Array.isArray(context.session.controlQueue) ? context.session.controlQueue : [];
|
|
18
|
+
const next = refreshed.find((item) => isRunnableControlItem(item, refreshed));
|
|
19
|
+
if (!next) return false;
|
|
20
|
+
startItem?.(next);
|
|
21
|
+
return true;
|
|
22
|
+
} finally {
|
|
23
|
+
context.controlDrainActive = false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isRunnableControlItem(item, queue) {
|
|
28
|
+
if (item?.status !== 'queued') return false;
|
|
29
|
+
if (!item.chainId) return true;
|
|
30
|
+
const predecessors = queue.filter((candidate) => candidate.chainId === item.chainId && Number(candidate.chainSequence) < Number(item.chainSequence));
|
|
31
|
+
if (predecessors.some((candidate) => !TERMINAL.has(candidate.status))) return false;
|
|
32
|
+
return !predecessors.some((candidate) => candidate.optional !== true && candidate.continueOnFailure !== true && ['failed', 'cancelled', 'skipped'].includes(candidate.status));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function applySkipPropagation(queue, skipItem = null) {
|
|
36
|
+
let changed = 0;
|
|
37
|
+
for (const item of queue) {
|
|
38
|
+
if (item.status !== 'queued' || !item.chainId) continue;
|
|
39
|
+
const predecessors = queue.filter((candidate) => candidate.chainId === item.chainId && Number(candidate.chainSequence) < Number(item.chainSequence));
|
|
40
|
+
const blocker = predecessors.find((candidate) => candidate.optional !== true && candidate.continueOnFailure !== true && ['failed', 'cancelled', 'skipped'].includes(candidate.status));
|
|
41
|
+
if (!blocker) continue;
|
|
42
|
+
skipItem?.(item, blocker.status === 'cancelled' ? 'chain_cancelled' : 'required_predecessor_failed');
|
|
43
|
+
// Event dispatch replaces the projected queue instead of mutating this
|
|
44
|
+
// snapshot. Mirror the transition locally so later siblings in this same
|
|
45
|
+
// pass can observe the cascade (failed -> skipped -> skipped).
|
|
46
|
+
item.status = 'skipped';
|
|
47
|
+
changed += 1;
|
|
48
|
+
}
|
|
49
|
+
return changed;
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { applySkipPropagation, reconcileControlQueue } from './controlDrain.js';
|
|
4
|
+
|
|
5
|
+
test('drain starts only the first runnable chain item', async () => {
|
|
6
|
+
const started = [];
|
|
7
|
+
const context = { running: false, session: { controlQueue: [
|
|
8
|
+
{ id: 'a1', chainId: 'a', chainSequence: 0, status: 'queued' },
|
|
9
|
+
{ id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
|
|
10
|
+
] } };
|
|
11
|
+
assert.equal(await reconcileControlQueue(context, { startItem: (item) => { started.push(item.id); context.running = true; } }), true);
|
|
12
|
+
assert.deepEqual(started, ['a1']);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('failed required predecessors skip remaining items but preserve another chain', () => {
|
|
16
|
+
const queue = [
|
|
17
|
+
{ id: 'a1', chainId: 'a', chainSequence: 0, status: 'failed' },
|
|
18
|
+
{ id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
|
|
19
|
+
{ id: 'b1', chainId: 'b', chainSequence: 0, status: 'queued' },
|
|
20
|
+
];
|
|
21
|
+
const skipped = [];
|
|
22
|
+
applySkipPropagation(queue, (item) => { item.status = 'skipped'; skipped.push(item.id); });
|
|
23
|
+
assert.deepEqual(skipped, ['a2']);
|
|
24
|
+
assert.equal(queue[2].status, 'queued');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('one propagation pass skips every required descendant in a long chain', () => {
|
|
28
|
+
const queue = [
|
|
29
|
+
{ id: 'a1', chainId: 'a', chainSequence: 0, status: 'failed' },
|
|
30
|
+
{ id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
|
|
31
|
+
{ id: 'a3', chainId: 'a', chainSequence: 2, status: 'queued' },
|
|
32
|
+
{ id: 'a4', chainId: 'a', chainSequence: 3, status: 'queued' },
|
|
33
|
+
];
|
|
34
|
+
const skipped = [];
|
|
35
|
+
assert.equal(applySkipPropagation(queue, (item) => skipped.push(item.id)), 3);
|
|
36
|
+
assert.deepEqual(skipped, ['a2', 'a3', 'a4']);
|
|
37
|
+
assert.deepEqual(queue.map((item) => item.status), ['failed', 'skipped', 'skipped', 'skipped']);
|
|
38
|
+
});
|