@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
|
@@ -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
|
}
|
|
@@ -135,3 +135,37 @@ test('wiki-workspace reaches its usage text with no arguments', async () => {
|
|
|
135
135
|
assert.match(result.stdout, /^Usage:/);
|
|
136
136
|
assert.equal(result.status, 2);
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
// `reset` deletes everything workspace_reset_keep rejects, and its very next
|
|
140
|
+
// statement is `run_wiki … init`, which opens with need_workspace_env. If
|
|
141
|
+
// `.env` ever falls out of the keep list, reset erases the workspace and then
|
|
142
|
+
// dies before re-scaffolding it — and recovering with `config` mints new ports
|
|
143
|
+
// and MCP tokens, invalidating every reference to the old ones. Exercise the
|
|
144
|
+
// real function rather than asserting on the source text.
|
|
145
|
+
test('workspace_reset_keep protects the workspace registration and method', async () => {
|
|
146
|
+
const { spawnSync } = await import('node:child_process');
|
|
147
|
+
const scriptPath = new URL('../../wiki-workspace', import.meta.url).pathname;
|
|
148
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
149
|
+
const start = script.indexOf('workspace_reset_keep() {');
|
|
150
|
+
assert.notEqual(start, -1, 'workspace_reset_keep must exist');
|
|
151
|
+
const body = script.slice(start, script.indexOf('\n}', start) + 2);
|
|
152
|
+
|
|
153
|
+
const ask = (name) =>
|
|
154
|
+
spawnSync('bash', ['-c', `${body}\nworkspace_reset_keep "$1"`, '_', name]).status === 0;
|
|
155
|
+
|
|
156
|
+
for (const kept of ['.env', '.env.local', '.wikirc.yaml', '.wikirc.yaml.prod', 'templates', 'build-context', '.git']) {
|
|
157
|
+
assert.equal(ask(kept), true, `${kept} must survive a reset`);
|
|
158
|
+
}
|
|
159
|
+
for (const removed of ['wiki', 'deliverables', 'raw', '.wiki', 'CLAUDE.md', '.gitignore', '.environment']) {
|
|
160
|
+
assert.equal(ask(removed), false, `${removed} must be reset`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('reset re-scaffolds through run_wiki, which requires the workspace env', async () => {
|
|
165
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
166
|
+
|
|
167
|
+
// The coupling the keep list depends on: if either half moves, revisit it.
|
|
168
|
+
assert.match(script, /run_wiki\(\) \{\n local workspace="\$1"\n shift\n need_workspace_env "\$workspace"/);
|
|
169
|
+
assert.match(script, /run_wiki "\$workspace" init/);
|
|
170
|
+
assert.match(script, /workspace_env_file\(\) \{\n local workspace="\$1"\n printf '%s\/%s\/\.env\\n'/);
|
|
171
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { loadWorkspaceProfile } from './profile.js';
|
|
7
|
+
import { buildDirectChatSystemPrompt } from '../shell/repl.js';
|
|
8
|
+
|
|
9
|
+
function workspaceWithProfile(content) {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'workspace-profile-'));
|
|
11
|
+
if (content !== null) {
|
|
12
|
+
mkdirSync(join(dir, '.wiki'), { recursive: true });
|
|
13
|
+
writeFileSync(join(dir, '.wiki', 'profile.md'), content, 'utf8');
|
|
14
|
+
}
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Une absence de profil doit dégrader la réponse, jamais la casser : le loader
|
|
19
|
+
// est appelé à chaque tour, y compris avant tout /use.
|
|
20
|
+
test('a missing workspace, file or empty profile yields null instead of throwing', () => {
|
|
21
|
+
assert.equal(loadWorkspaceProfile(null), null);
|
|
22
|
+
assert.equal(loadWorkspaceProfile(undefined), null);
|
|
23
|
+
assert.equal(loadWorkspaceProfile(workspaceWithProfile(null)), null);
|
|
24
|
+
assert.equal(loadWorkspaceProfile(workspaceWithProfile(' \n\n')), null);
|
|
25
|
+
assert.equal(loadWorkspaceProfile(join(tmpdir(), 'does-not-exist-ever')), null);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('an existing profile is returned trimmed and capped', () => {
|
|
29
|
+
const dir = workspaceWithProfile('\n# Workspace Profile\n\n## Notifications\n\n- Email: ops@example.com\n\n');
|
|
30
|
+
const profile = loadWorkspaceProfile(dir);
|
|
31
|
+
assert.match(profile, /^# Workspace Profile/);
|
|
32
|
+
assert.match(profile, /ops@example\.com$/);
|
|
33
|
+
|
|
34
|
+
const huge = workspaceWithProfile('x'.repeat(10_000));
|
|
35
|
+
assert.equal(loadWorkspaceProfile(huge).length, 4000);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Le profil n'était injecté qu'en mode agent : le même workspace répondait avec
|
|
39
|
+
// un ton différent selon le mode, et une skill lancée depuis /chat ne pouvait
|
|
40
|
+
// pas savoir à qui elle parlait (destinataire de notification, tutoiement…).
|
|
41
|
+
// L'injection est volontairement préférée à une entrée profile_read dans
|
|
42
|
+
// chatAccess, que le merge additif du scaffold n'aurait jamais propagée aux
|
|
43
|
+
// installs existantes.
|
|
44
|
+
test('chat mode injects the workspace profile into its system prompt', () => {
|
|
45
|
+
const workspacePath = workspaceWithProfile('# Workspace Profile\n\n## Notifications\n\n- Email: ops@example.com\n');
|
|
46
|
+
const prompt = buildDirectChatSystemPrompt({ workspace: 'demo', workspacePath, language: 'fr-FR' }, []);
|
|
47
|
+
assert.match(prompt, /Workspace profile \(\.wiki\/profile\.md\)/);
|
|
48
|
+
assert.match(prompt, /ops@example\.com/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('chat mode omits the profile block entirely when there is no profile', () => {
|
|
52
|
+
const prompt = buildDirectChatSystemPrompt({ workspace: 'demo', workspacePath: null, language: 'fr-FR' }, []);
|
|
53
|
+
assert.doesNotMatch(prompt, /Workspace profile/);
|
|
54
|
+
assert.match(prompt, /Reply language: fr-FR\./);
|
|
55
|
+
});
|
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
|
+
});
|