clementine-agent 1.18.105 → 1.18.107
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/dist/agent/skill-store.d.ts +60 -0
- package/dist/agent/skill-store.js +313 -0
- package/dist/cli/dashboard.js +368 -1
- package/dist/types.d.ts +112 -0
- package/package.json +1 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill store — Phase A (read-only) of the Skills-First redesign.
|
|
3
|
+
*
|
|
4
|
+
* Discovers skill .md files from two locations and parses their
|
|
5
|
+
* frontmatter into the Skill type. Phase A surfaces what's already on
|
|
6
|
+
* disk; Phase B adds editing + testing; Phase C wires runtime invocation.
|
|
7
|
+
*
|
|
8
|
+
* Discovery order:
|
|
9
|
+
* 1. ~/.clementine/vault/00-System/skills/<name>.md (global)
|
|
10
|
+
* 2. <work_dir>/.clementine/skills/<name>.md (per-project)
|
|
11
|
+
*
|
|
12
|
+
* Per-project files win on name collision — they override global skills
|
|
13
|
+
* for that project. The dashboard surfaces both pools and tags each
|
|
14
|
+
* skill with its scope so the user can see which one will resolve.
|
|
15
|
+
*
|
|
16
|
+
* Schema detection: a file is `v1` when its frontmatter declares any of
|
|
17
|
+
* inputs / tools.allow / tools.deny / dataSources / stateKeys / success.
|
|
18
|
+
* Otherwise (only legacy fields like title / triggers / toolsUsed) it's
|
|
19
|
+
* `legacy` and the dashboard shows a migration badge.
|
|
20
|
+
*
|
|
21
|
+
* Used-by join: Phase A reads the `skills:` array on CronJobDefinition
|
|
22
|
+
* (the existing field) to populate Skill.usedByTriggers. Phase C will
|
|
23
|
+
* extend this to read the new top-level `skill:` field on the trigger.
|
|
24
|
+
*/
|
|
25
|
+
import type { Skill, SkillScope, CronJobDefinition } from '../types.js';
|
|
26
|
+
interface ParseResult {
|
|
27
|
+
skill: Skill;
|
|
28
|
+
/** Set when the file existed but couldn't be parsed (bad YAML, etc.).
|
|
29
|
+
* We still surface the file with a fallback frontmatter so the user
|
|
30
|
+
* can see which one needs fixing. */
|
|
31
|
+
parseError?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Parse a single skill file. Returns a Skill record even when the
|
|
34
|
+
* frontmatter is malformed — the dashboard renders the parse error
|
|
35
|
+
* in-pane so the user can fix it without leaving the UI. */
|
|
36
|
+
export declare function parseSkillFile(filePath: string, scope: SkillScope): ParseResult;
|
|
37
|
+
export interface ListSkillsOptions {
|
|
38
|
+
/** Optional per-project work_dir to also scan. Per-project skills
|
|
39
|
+
* override global skills with the same filename. */
|
|
40
|
+
projectWorkDir?: string;
|
|
41
|
+
/** Optional cron jobs list — when provided, the loader populates the
|
|
42
|
+
* usedByTriggers field on each skill via the existing skills[] array
|
|
43
|
+
* on CronJobDefinition (Phase A's join). */
|
|
44
|
+
jobs?: CronJobDefinition[];
|
|
45
|
+
}
|
|
46
|
+
/** Top-level discovery API. Returns the merged list of skills across
|
|
47
|
+
* global + per-project pools, with per-project taking precedence on
|
|
48
|
+
* name collision. usedByTriggers is populated when jobs are passed in. */
|
|
49
|
+
export declare function listSkills(opts?: ListSkillsOptions): Skill[];
|
|
50
|
+
/** Get a single skill by name, with the same global/project precedence
|
|
51
|
+
* as listSkills. Returns null if neither pool has the skill. */
|
|
52
|
+
export declare function getSkill(name: string, opts?: ListSkillsOptions): Skill | null;
|
|
53
|
+
/** Test-only: where the loader looked. Useful in unit tests + the
|
|
54
|
+
* dashboard's diagnostics surface. */
|
|
55
|
+
export declare function _skillDirsForDiagnostics(workDir?: string): {
|
|
56
|
+
global: string;
|
|
57
|
+
project: string | null;
|
|
58
|
+
};
|
|
59
|
+
export {};
|
|
60
|
+
//# sourceMappingURL=skill-store.d.ts.map
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill store — Phase A (read-only) of the Skills-First redesign.
|
|
3
|
+
*
|
|
4
|
+
* Discovers skill .md files from two locations and parses their
|
|
5
|
+
* frontmatter into the Skill type. Phase A surfaces what's already on
|
|
6
|
+
* disk; Phase B adds editing + testing; Phase C wires runtime invocation.
|
|
7
|
+
*
|
|
8
|
+
* Discovery order:
|
|
9
|
+
* 1. ~/.clementine/vault/00-System/skills/<name>.md (global)
|
|
10
|
+
* 2. <work_dir>/.clementine/skills/<name>.md (per-project)
|
|
11
|
+
*
|
|
12
|
+
* Per-project files win on name collision — they override global skills
|
|
13
|
+
* for that project. The dashboard surfaces both pools and tags each
|
|
14
|
+
* skill with its scope so the user can see which one will resolve.
|
|
15
|
+
*
|
|
16
|
+
* Schema detection: a file is `v1` when its frontmatter declares any of
|
|
17
|
+
* inputs / tools.allow / tools.deny / dataSources / stateKeys / success.
|
|
18
|
+
* Otherwise (only legacy fields like title / triggers / toolsUsed) it's
|
|
19
|
+
* `legacy` and the dashboard shows a migration badge.
|
|
20
|
+
*
|
|
21
|
+
* Used-by join: Phase A reads the `skills:` array on CronJobDefinition
|
|
22
|
+
* (the existing field) to populate Skill.usedByTriggers. Phase C will
|
|
23
|
+
* extend this to read the new top-level `skill:` field on the trigger.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import matter from 'gray-matter';
|
|
29
|
+
/** Resolve the global skills directory from CLEMENTINE_HOME (or default). */
|
|
30
|
+
function globalSkillsDir() {
|
|
31
|
+
const base = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
|
|
32
|
+
return path.join(base, 'vault', '00-System', 'skills');
|
|
33
|
+
}
|
|
34
|
+
/** Resolve a per-project skills directory. Returns null if work_dir is
|
|
35
|
+
* empty or doesn't have a .clementine/skills/ child. */
|
|
36
|
+
function projectSkillsDir(workDir) {
|
|
37
|
+
if (!workDir)
|
|
38
|
+
return null;
|
|
39
|
+
const dir = path.join(workDir, '.clementine', 'skills');
|
|
40
|
+
return existsSync(dir) ? dir : null;
|
|
41
|
+
}
|
|
42
|
+
/** Strip backup files (.bak), hidden files, and directories. */
|
|
43
|
+
function isSkillFile(name) {
|
|
44
|
+
if (name.startsWith('.'))
|
|
45
|
+
return false;
|
|
46
|
+
if (!name.endsWith('.md'))
|
|
47
|
+
return false;
|
|
48
|
+
if (name.endsWith('.bak'))
|
|
49
|
+
return false;
|
|
50
|
+
if (name.endsWith('.bak.md'))
|
|
51
|
+
return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
/** Skill name is the filename without extension. We don't trust the
|
|
55
|
+
* frontmatter's `name:` field as the canonical identifier because
|
|
56
|
+
* different files could collide on it; the filename is what the loader
|
|
57
|
+
* joins on. The frontmatter `name:` is preserved as a display alias. */
|
|
58
|
+
function nameFromFile(file) {
|
|
59
|
+
return path.basename(file, '.md');
|
|
60
|
+
}
|
|
61
|
+
/** Detect whether a frontmatter object uses the v1 schema or the
|
|
62
|
+
* pre-redesign legacy shape. Phase A renders this as a badge so users
|
|
63
|
+
* can see which skills need migration in Phase B. */
|
|
64
|
+
function detectSchemaVersion(fm) {
|
|
65
|
+
const v1Markers = ['inputs', 'dataSources', 'stateKeys', 'success', 'limits'];
|
|
66
|
+
if (v1Markers.some((k) => k in fm))
|
|
67
|
+
return 'v1';
|
|
68
|
+
const tools = fm.tools;
|
|
69
|
+
if (tools && (Array.isArray(tools.allow) || Array.isArray(tools.deny)))
|
|
70
|
+
return 'v1';
|
|
71
|
+
return 'legacy';
|
|
72
|
+
}
|
|
73
|
+
/** Coerce a parsed YAML object into the SkillFrontmatter shape. We
|
|
74
|
+
* accept both the v1 fields and the legacy fields side-by-side; the
|
|
75
|
+
* caller's schemaVersion check tells the dashboard which is which. */
|
|
76
|
+
function coerceFrontmatter(raw, fileBasename) {
|
|
77
|
+
const fm = {
|
|
78
|
+
// Identifier — ALWAYS the filename (without .md). The frontmatter's
|
|
79
|
+
// `name:` field is intentionally ignored to avoid two skills colliding
|
|
80
|
+
// on it. Users wanting a friendly display string can set `title:`
|
|
81
|
+
// instead, which Phase B's editor surfaces as the heading.
|
|
82
|
+
name: fileBasename,
|
|
83
|
+
};
|
|
84
|
+
if (typeof raw.description === 'string')
|
|
85
|
+
fm.description = raw.description;
|
|
86
|
+
// v1 inputs — JSON Schema map keyed by field name.
|
|
87
|
+
if (raw.inputs && typeof raw.inputs === 'object' && !Array.isArray(raw.inputs)) {
|
|
88
|
+
fm.inputs = raw.inputs;
|
|
89
|
+
}
|
|
90
|
+
// tools.allow / tools.deny
|
|
91
|
+
if (raw.tools && typeof raw.tools === 'object' && !Array.isArray(raw.tools)) {
|
|
92
|
+
const t = raw.tools;
|
|
93
|
+
const policy = {};
|
|
94
|
+
if (Array.isArray(t.allow))
|
|
95
|
+
policy.allow = t.allow.map(String);
|
|
96
|
+
if (Array.isArray(t.deny))
|
|
97
|
+
policy.deny = t.deny.map(String);
|
|
98
|
+
if (policy.allow || policy.deny)
|
|
99
|
+
fm.tools = policy;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(raw.dataSources)) {
|
|
102
|
+
fm.dataSources = raw.dataSources
|
|
103
|
+
.filter((d) => !!d && typeof d === 'object')
|
|
104
|
+
.map((d) => ({
|
|
105
|
+
kind: String(d.kind || 'unknown'),
|
|
106
|
+
purpose: String(d.purpose || ''),
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(raw.stateKeys))
|
|
110
|
+
fm.stateKeys = raw.stateKeys.map(String);
|
|
111
|
+
if (raw.success && typeof raw.success === 'object' && !Array.isArray(raw.success)) {
|
|
112
|
+
const s = raw.success;
|
|
113
|
+
const success = {};
|
|
114
|
+
if (s.schema && typeof s.schema === 'object')
|
|
115
|
+
success.schema = s.schema;
|
|
116
|
+
if (typeof s.criterion === 'string')
|
|
117
|
+
success.criterion = s.criterion;
|
|
118
|
+
if (success.schema || success.criterion)
|
|
119
|
+
fm.success = success;
|
|
120
|
+
}
|
|
121
|
+
if (raw.limits && typeof raw.limits === 'object' && !Array.isArray(raw.limits)) {
|
|
122
|
+
const l = raw.limits;
|
|
123
|
+
const limits = {};
|
|
124
|
+
if (typeof l.maxTurns === 'number')
|
|
125
|
+
limits.maxTurns = l.maxTurns;
|
|
126
|
+
if (typeof l.maxBudgetUsd === 'number')
|
|
127
|
+
limits.maxBudgetUsd = l.maxBudgetUsd;
|
|
128
|
+
if (typeof l.timeoutSeconds === 'number')
|
|
129
|
+
limits.timeoutSeconds = l.timeoutSeconds;
|
|
130
|
+
if (Object.keys(limits).length > 0)
|
|
131
|
+
fm.limits = limits;
|
|
132
|
+
}
|
|
133
|
+
if (typeof raw.version === 'number')
|
|
134
|
+
fm.version = raw.version;
|
|
135
|
+
if (typeof raw.createdAt === 'string')
|
|
136
|
+
fm.createdAt = raw.createdAt;
|
|
137
|
+
if (typeof raw.updatedAt === 'string')
|
|
138
|
+
fm.updatedAt = raw.updatedAt;
|
|
139
|
+
if (typeof raw.lastUsed === 'string')
|
|
140
|
+
fm.lastUsed = raw.lastUsed;
|
|
141
|
+
if (typeof raw.lastTestPass === 'string')
|
|
142
|
+
fm.lastTestPass = raw.lastTestPass;
|
|
143
|
+
// Legacy fields (preserved as-is for the migration UI).
|
|
144
|
+
if (typeof raw.title === 'string')
|
|
145
|
+
fm.title = raw.title;
|
|
146
|
+
if (Array.isArray(raw.triggers))
|
|
147
|
+
fm.triggers = raw.triggers.map(String);
|
|
148
|
+
if (typeof raw.source === 'string')
|
|
149
|
+
fm.source = raw.source;
|
|
150
|
+
if (Array.isArray(raw.toolsUsed))
|
|
151
|
+
fm.toolsUsed = raw.toolsUsed.map(String);
|
|
152
|
+
if (typeof raw.useCount === 'number')
|
|
153
|
+
fm.useCount = raw.useCount;
|
|
154
|
+
return fm;
|
|
155
|
+
}
|
|
156
|
+
/** Parse a single skill file. Returns a Skill record even when the
|
|
157
|
+
* frontmatter is malformed — the dashboard renders the parse error
|
|
158
|
+
* in-pane so the user can fix it without leaving the UI. */
|
|
159
|
+
export function parseSkillFile(filePath, scope) {
|
|
160
|
+
const basename = nameFromFile(filePath);
|
|
161
|
+
let raw;
|
|
162
|
+
try {
|
|
163
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return {
|
|
167
|
+
skill: emptySkill(filePath, basename, scope),
|
|
168
|
+
parseError: 'failed to read: ' + String(err),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = matter(raw);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
return {
|
|
177
|
+
skill: { ...emptySkill(filePath, basename, scope), body: raw },
|
|
178
|
+
parseError: 'YAML parse error: ' + String(err),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const data = parsed.data;
|
|
182
|
+
const fm = coerceFrontmatter(data, basename);
|
|
183
|
+
const schemaVersion = detectSchemaVersion(data);
|
|
184
|
+
return {
|
|
185
|
+
skill: {
|
|
186
|
+
frontmatter: fm,
|
|
187
|
+
body: parsed.content || '',
|
|
188
|
+
filePath,
|
|
189
|
+
scope,
|
|
190
|
+
schemaVersion,
|
|
191
|
+
usedByTriggers: [],
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function emptySkill(filePath, basename, scope) {
|
|
196
|
+
return {
|
|
197
|
+
frontmatter: { name: basename },
|
|
198
|
+
body: '',
|
|
199
|
+
filePath,
|
|
200
|
+
scope,
|
|
201
|
+
schemaVersion: 'legacy',
|
|
202
|
+
usedByTriggers: [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** List skills in a directory, returning Skill records (not just paths)
|
|
206
|
+
* so callers can immediately render them. Tolerates missing dirs and
|
|
207
|
+
* unreadable files — best-effort. */
|
|
208
|
+
function listSkillsInDir(dir, scope) {
|
|
209
|
+
if (!existsSync(dir))
|
|
210
|
+
return [];
|
|
211
|
+
let entries;
|
|
212
|
+
try {
|
|
213
|
+
entries = readdirSync(dir);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (!isSkillFile(entry))
|
|
221
|
+
continue;
|
|
222
|
+
const fullPath = path.join(dir, entry);
|
|
223
|
+
try {
|
|
224
|
+
const stat = statSync(fullPath);
|
|
225
|
+
if (!stat.isFile())
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
out.push(parseSkillFile(fullPath, scope).skill);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
/** Top-level discovery API. Returns the merged list of skills across
|
|
236
|
+
* global + per-project pools, with per-project taking precedence on
|
|
237
|
+
* name collision. usedByTriggers is populated when jobs are passed in. */
|
|
238
|
+
export function listSkills(opts = {}) {
|
|
239
|
+
const globalSkills = listSkillsInDir(globalSkillsDir(), 'global');
|
|
240
|
+
const projectSkills = opts.projectWorkDir
|
|
241
|
+
? (() => {
|
|
242
|
+
const pdir = projectSkillsDir(opts.projectWorkDir);
|
|
243
|
+
return pdir ? listSkillsInDir(pdir, 'project') : [];
|
|
244
|
+
})()
|
|
245
|
+
: [];
|
|
246
|
+
// Build a map keyed by basename so per-project entries override global.
|
|
247
|
+
const merged = new Map();
|
|
248
|
+
for (const s of globalSkills)
|
|
249
|
+
merged.set(s.frontmatter.name, s);
|
|
250
|
+
for (const s of projectSkills)
|
|
251
|
+
merged.set(s.frontmatter.name, s);
|
|
252
|
+
// Used-by join from cron jobs' skills[] array. Same skill referenced by
|
|
253
|
+
// multiple jobs accumulates them in order.
|
|
254
|
+
if (opts.jobs && opts.jobs.length > 0) {
|
|
255
|
+
for (const job of opts.jobs) {
|
|
256
|
+
if (!Array.isArray(job.skills))
|
|
257
|
+
continue;
|
|
258
|
+
for (const skillName of job.skills) {
|
|
259
|
+
const s = merged.get(skillName);
|
|
260
|
+
if (s)
|
|
261
|
+
s.usedByTriggers.push(job.name);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Sorted alphabetically — predictable rendering, no need for the
|
|
266
|
+
// dashboard to re-sort. Per-project always sorts at the same key as
|
|
267
|
+
// the global version it replaced.
|
|
268
|
+
return [...merged.values()].sort((a, b) => a.frontmatter.name.localeCompare(b.frontmatter.name));
|
|
269
|
+
}
|
|
270
|
+
/** Get a single skill by name, with the same global/project precedence
|
|
271
|
+
* as listSkills. Returns null if neither pool has the skill. */
|
|
272
|
+
export function getSkill(name, opts = {}) {
|
|
273
|
+
// Per-project first (precedence).
|
|
274
|
+
if (opts.projectWorkDir) {
|
|
275
|
+
const pdir = projectSkillsDir(opts.projectWorkDir);
|
|
276
|
+
if (pdir) {
|
|
277
|
+
const candidate = path.join(pdir, name + '.md');
|
|
278
|
+
if (existsSync(candidate)) {
|
|
279
|
+
const result = parseSkillFile(candidate, 'project');
|
|
280
|
+
if (opts.jobs)
|
|
281
|
+
result.skill.usedByTriggers = jobsUsing(name, opts.jobs);
|
|
282
|
+
return result.skill;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Global fallback.
|
|
287
|
+
const candidate = path.join(globalSkillsDir(), name + '.md');
|
|
288
|
+
if (existsSync(candidate)) {
|
|
289
|
+
const result = parseSkillFile(candidate, 'global');
|
|
290
|
+
if (opts.jobs)
|
|
291
|
+
result.skill.usedByTriggers = jobsUsing(name, opts.jobs);
|
|
292
|
+
return result.skill;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
/** Internal helper for the used-by join. */
|
|
297
|
+
function jobsUsing(skillName, jobs) {
|
|
298
|
+
const out = [];
|
|
299
|
+
for (const job of jobs) {
|
|
300
|
+
if (Array.isArray(job.skills) && job.skills.includes(skillName))
|
|
301
|
+
out.push(job.name);
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
/** Test-only: where the loader looked. Useful in unit tests + the
|
|
306
|
+
* dashboard's diagnostics surface. */
|
|
307
|
+
export function _skillDirsForDiagnostics(workDir) {
|
|
308
|
+
return {
|
|
309
|
+
global: globalSkillsDir(),
|
|
310
|
+
project: projectSkillsDir(workDir) ?? null,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
//# sourceMappingURL=skill-store.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -4609,6 +4609,44 @@ export async function cmdDashboard(opts) {
|
|
|
4609
4609
|
// POST /api/cron/:job/publish — promote draft to CRON.md
|
|
4610
4610
|
// DELETE /api/cron/:job/draft — discard the draft
|
|
4611
4611
|
// GET /api/cron/drafts — list all drafted task names
|
|
4612
|
+
// ── Skills-First redesign Phase A / 1.18.106: skill catalog (read-only) ─
|
|
4613
|
+
// Two endpoints feed the new Skills page:
|
|
4614
|
+
// GET /api/skills — full list with usedByTriggers join
|
|
4615
|
+
// GET /api/skills/:name — single skill detail (frontmatter + body)
|
|
4616
|
+
// Phase A is read-only; Phase B adds POST/PUT for editing.
|
|
4617
|
+
app.get('/api/skills', async (_req, res) => {
|
|
4618
|
+
try {
|
|
4619
|
+
const { listSkills } = await import('../agent/skill-store.js');
|
|
4620
|
+
const { parseCronJobs } = await import('../gateway/cron-scheduler.js');
|
|
4621
|
+
const jobs = parseCronJobs();
|
|
4622
|
+
const skills = listSkills({ jobs });
|
|
4623
|
+
res.json({ ok: true, count: skills.length, skills });
|
|
4624
|
+
}
|
|
4625
|
+
catch (err) {
|
|
4626
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4627
|
+
}
|
|
4628
|
+
});
|
|
4629
|
+
app.get('/api/skills/:name', async (req, res) => {
|
|
4630
|
+
try {
|
|
4631
|
+
const name = req.params.name;
|
|
4632
|
+
if (!name) {
|
|
4633
|
+
res.status(400).json({ ok: false, error: 'name required' });
|
|
4634
|
+
return;
|
|
4635
|
+
}
|
|
4636
|
+
const { getSkill } = await import('../agent/skill-store.js');
|
|
4637
|
+
const { parseCronJobs } = await import('../gateway/cron-scheduler.js');
|
|
4638
|
+
const jobs = parseCronJobs();
|
|
4639
|
+
const skill = getSkill(name, { jobs });
|
|
4640
|
+
if (!skill) {
|
|
4641
|
+
res.status(404).json({ ok: false, error: `skill "${name}" not found` });
|
|
4642
|
+
return;
|
|
4643
|
+
}
|
|
4644
|
+
res.json({ ok: true, skill });
|
|
4645
|
+
}
|
|
4646
|
+
catch (err) {
|
|
4647
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4648
|
+
}
|
|
4649
|
+
});
|
|
4612
4650
|
app.get('/api/cron/drafts', async (_req, res) => {
|
|
4613
4651
|
try {
|
|
4614
4652
|
const { listDraftNames } = await import('../agent/draft-store.js');
|
|
@@ -17116,6 +17154,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
17116
17154
|
<span class="nav-icon"></span> Tasks
|
|
17117
17155
|
<span class="nav-badge" id="nav-cron-count" style="display:none">0</span>
|
|
17118
17156
|
</div>
|
|
17157
|
+
<!-- Skills-First redesign Phase A / 1.18.106: read-only catalog
|
|
17158
|
+
of skill files (procedures + tools + data + state). Phase B
|
|
17159
|
+
adds editing + testing; Phase C wires runtime invocation. -->
|
|
17160
|
+
<div class="nav-item" data-page="skills" data-icon="brain" title="Reusable skill files — procedures + tool allowlists + data sources">
|
|
17161
|
+
<span class="nav-icon"></span> Skills
|
|
17162
|
+
<span class="nav-badge" id="nav-skills-count" style="display:none">0</span>
|
|
17163
|
+
</div>
|
|
17119
17164
|
<div class="nav-item" data-page="heartbeat" data-icon="bell" title="Heartbeat controls and queued work">
|
|
17120
17165
|
<span class="nav-icon"></span> Heartbeat
|
|
17121
17166
|
</div>
|
|
@@ -20427,6 +20472,34 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
20427
20472
|
|
|
20428
20473
|
<!-- page-goals merged into Team → Goals tab. -->
|
|
20429
20474
|
|
|
20475
|
+
<!-- ═══ Skills Page — Phase A read-only catalog ═══
|
|
20476
|
+
Skills-First redesign Phase A / 1.18.106: a list of every skill
|
|
20477
|
+
file with detail pane. Editing + testing land in Phase B; runtime
|
|
20478
|
+
invocation lands in Phase C. The page is intentionally minimal —
|
|
20479
|
+
we want users to see what's there, not be overwhelmed by 7 tiles. -->
|
|
20480
|
+
<div class="page" id="page-skills">
|
|
20481
|
+
<div class="page-head">
|
|
20482
|
+
<div class="icon icon-slot" data-icon="brain"></div>
|
|
20483
|
+
<div class="title-block">
|
|
20484
|
+
<h1>Skills</h1>
|
|
20485
|
+
<p class="desc">Reusable procedures Clementine can run. Each skill declares its tools, data sources, and state.</p>
|
|
20486
|
+
</div>
|
|
20487
|
+
</div>
|
|
20488
|
+
<div style="display:grid;grid-template-columns:380px 1fr;gap:18px;height:calc(100vh - 180px);min-height:500px">
|
|
20489
|
+
<div id="skills-list-pane" style="overflow-y:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">
|
|
20490
|
+
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px">
|
|
20491
|
+
<input type="search" id="skills-search" placeholder="Search skills…" oninput="onSkillsSearch(this.value)" style="flex:1;padding:6px 10px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)">
|
|
20492
|
+
</div>
|
|
20493
|
+
<div id="skills-list" style="padding:6px"></div>
|
|
20494
|
+
</div>
|
|
20495
|
+
<div id="skills-detail-pane" style="overflow-y:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:0">
|
|
20496
|
+
<div id="skills-detail" style="padding:24px;color:var(--text-muted);text-align:center;font-size:13px">
|
|
20497
|
+
Select a skill on the left to see its procedure, tools, and data sources.
|
|
20498
|
+
</div>
|
|
20499
|
+
</div>
|
|
20500
|
+
</div>
|
|
20501
|
+
</div>
|
|
20502
|
+
|
|
20430
20503
|
<!-- ═══ Team Page — The Office ═══ -->
|
|
20431
20504
|
<div class="page" id="page-team">
|
|
20432
20505
|
<div class="page-head">
|
|
@@ -22106,7 +22179,7 @@ function lucide(name, cls) {
|
|
|
22106
22179
|
return '<svg class="icn ' + (cls || '') + '" viewBox="0 0 24 24" aria-hidden="true">' + path + '</svg>';
|
|
22107
22180
|
}
|
|
22108
22181
|
|
|
22109
|
-
var DESTINATIONS = ['home', 'build', 'heartbeat', 'team', 'brain', 'settings'];
|
|
22182
|
+
var DESTINATIONS = ['home', 'build', 'skills', 'heartbeat', 'team', 'brain', 'settings'];
|
|
22110
22183
|
|
|
22111
22184
|
var ROUTE_REDIRECTS = {
|
|
22112
22185
|
// old hash → new {page, tab}
|
|
@@ -22183,6 +22256,12 @@ function navigateTo(page, opts) {
|
|
|
22183
22256
|
var bp = currentAgentSlug || '';
|
|
22184
22257
|
refreshBuilderAgents(bp);
|
|
22185
22258
|
break;
|
|
22259
|
+
case 'skills':
|
|
22260
|
+
// Skills-First redesign Phase A / 1.18.106: load the catalog when
|
|
22261
|
+
// the user navigates to the Skills page. Read-only; no mutation
|
|
22262
|
+
// surfaces here yet.
|
|
22263
|
+
if (typeof refreshSkillsPage === 'function') refreshSkillsPage();
|
|
22264
|
+
break;
|
|
22186
22265
|
case 'heartbeat':
|
|
22187
22266
|
refreshHeartbeatControl();
|
|
22188
22267
|
break;
|
|
@@ -26730,6 +26809,294 @@ async function deleteDelegation(id) {
|
|
|
26730
26809
|
|
|
26731
26810
|
// ── Heartbeat Queue ──────────────────────
|
|
26732
26811
|
|
|
26812
|
+
// ── Skills-First redesign Phase A / 1.18.106 ──────────────────────────
|
|
26813
|
+
// Read-only Skills page: list + detail. Phase B adds editor + Test
|
|
26814
|
+
// runner; Phase C wires runtime invocation; Phase D collapses Tasks
|
|
26815
|
+
// page complexity once Triggers can point at skills directly.
|
|
26816
|
+
//
|
|
26817
|
+
// State kept module-local so other surfaces (e.g. the Tools & MCP
|
|
26818
|
+
// catalog's future "used by" join) can read it without re-fetching.
|
|
26819
|
+
var _skillsState = {
|
|
26820
|
+
data: [], // raw skills array from /api/skills
|
|
26821
|
+
filtered: [], // text-filter result
|
|
26822
|
+
selectedName: '', // currently shown in detail pane
|
|
26823
|
+
query: '', // search input value
|
|
26824
|
+
};
|
|
26825
|
+
|
|
26826
|
+
async function refreshSkillsPage() {
|
|
26827
|
+
var listEl = document.getElementById('skills-list');
|
|
26828
|
+
var detailEl = document.getElementById('skills-detail');
|
|
26829
|
+
if (!listEl) return;
|
|
26830
|
+
listEl.innerHTML = '<div style="padding:18px;color:var(--text-muted);font-size:12px">Loading skills…</div>';
|
|
26831
|
+
try {
|
|
26832
|
+
var r = await apiFetch('/api/skills');
|
|
26833
|
+
var d = await r.json();
|
|
26834
|
+
if (!r.ok || d.ok === false) {
|
|
26835
|
+
listEl.innerHTML = '<div style="padding:18px;color:var(--red);font-size:12px">Failed to load: ' + esc(d.error || 'unknown') + '</div>';
|
|
26836
|
+
return;
|
|
26837
|
+
}
|
|
26838
|
+
_skillsState.data = d.skills || [];
|
|
26839
|
+
_skillsState.filtered = applySkillsFilter(_skillsState.data, _skillsState.query);
|
|
26840
|
+
renderSkillsList();
|
|
26841
|
+
var badge = document.getElementById('nav-skills-count');
|
|
26842
|
+
if (badge) {
|
|
26843
|
+
badge.textContent = String(_skillsState.data.length);
|
|
26844
|
+
badge.style.display = _skillsState.data.length > 0 ? '' : 'none';
|
|
26845
|
+
}
|
|
26846
|
+
// If a skill was previously selected, reload its detail. Otherwise
|
|
26847
|
+
// auto-select the first to give the user something to look at.
|
|
26848
|
+
if (_skillsState.selectedName) {
|
|
26849
|
+
var still = _skillsState.data.find(function(s) { return s.frontmatter.name === _skillsState.selectedName; });
|
|
26850
|
+
if (still) showSkillDetail(_skillsState.selectedName);
|
|
26851
|
+
} else if (_skillsState.filtered.length > 0) {
|
|
26852
|
+
showSkillDetail(_skillsState.filtered[0].frontmatter.name);
|
|
26853
|
+
}
|
|
26854
|
+
} catch (e) {
|
|
26855
|
+
listEl.innerHTML = '<div style="padding:18px;color:var(--red);font-size:12px">Error: ' + esc(String(e)) + '</div>';
|
|
26856
|
+
}
|
|
26857
|
+
}
|
|
26858
|
+
|
|
26859
|
+
function applySkillsFilter(skills, query) {
|
|
26860
|
+
var q = (query || '').trim().toLowerCase();
|
|
26861
|
+
if (!q) return skills.slice();
|
|
26862
|
+
return skills.filter(function(s) {
|
|
26863
|
+
var fm = s.frontmatter || {};
|
|
26864
|
+
var hay = [fm.name, fm.title, fm.description, (fm.toolsUsed || []).join(' ')].filter(Boolean).join(' ').toLowerCase();
|
|
26865
|
+
return hay.indexOf(q) !== -1;
|
|
26866
|
+
});
|
|
26867
|
+
}
|
|
26868
|
+
|
|
26869
|
+
function onSkillsSearch(value) {
|
|
26870
|
+
_skillsState.query = value;
|
|
26871
|
+
_skillsState.filtered = applySkillsFilter(_skillsState.data, value);
|
|
26872
|
+
renderSkillsList();
|
|
26873
|
+
}
|
|
26874
|
+
|
|
26875
|
+
function renderSkillsList() {
|
|
26876
|
+
var listEl = document.getElementById('skills-list');
|
|
26877
|
+
if (!listEl) return;
|
|
26878
|
+
if (_skillsState.filtered.length === 0) {
|
|
26879
|
+
listEl.innerHTML = '<div style="padding:18px;color:var(--text-muted);font-size:12px;text-align:center;line-height:1.5">'
|
|
26880
|
+
+ (_skillsState.query
|
|
26881
|
+
? 'No skills match <strong>' + esc(_skillsState.query) + '</strong>.'
|
|
26882
|
+
: 'No skills yet. Phase B will add a <strong>+ New skill</strong> button. For now, drop .md files in <code>~/.clementine/vault/00-System/skills/</code>.')
|
|
26883
|
+
+ '</div>';
|
|
26884
|
+
return;
|
|
26885
|
+
}
|
|
26886
|
+
var html = '';
|
|
26887
|
+
for (var i = 0; i < _skillsState.filtered.length; i++) {
|
|
26888
|
+
var s = _skillsState.filtered[i];
|
|
26889
|
+
var fm = s.frontmatter || {};
|
|
26890
|
+
var isSelected = s.frontmatter.name === _skillsState.selectedName;
|
|
26891
|
+
var bg = isSelected ? 'var(--bg-tertiary)' : 'transparent';
|
|
26892
|
+
// Schema badge: v1 = green, legacy = yellow (needs migration in Phase B).
|
|
26893
|
+
var schemaBadge = s.schemaVersion === 'v1'
|
|
26894
|
+
? '<span style="font-size:9px;background:var(--green)20;color:var(--green);padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em">V1</span>'
|
|
26895
|
+
: '<span style="font-size:9px;background:var(--yellow)20;color:var(--yellow);padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em" title="Legacy frontmatter — Phase B will migrate this">LEGACY</span>';
|
|
26896
|
+
var scopeBadge = s.scope === 'project'
|
|
26897
|
+
? '<span style="font-size:9px;background:var(--blue)20;color:var(--blue);padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em" title="Loaded from per-project .clementine/skills/">PROJECT</span>'
|
|
26898
|
+
: '';
|
|
26899
|
+
var usedCount = (s.usedByTriggers || []).length;
|
|
26900
|
+
var displayName = fm.title || fm.name;
|
|
26901
|
+
var desc = (fm.description || '').slice(0, 100);
|
|
26902
|
+
if (fm.description && fm.description.length > 100) desc += '…';
|
|
26903
|
+
html += '<div onclick="showSkillDetail(\\x27' + jsStr(fm.name) + '\\x27)" style="padding:12px 14px;border-bottom:1px solid var(--border);cursor:pointer;background:' + bg + ';transition:background 0.1s" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27' + bg + '\\x27">'
|
|
26904
|
+
+ '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">'
|
|
26905
|
+
+ schemaBadge + scopeBadge
|
|
26906
|
+
+ '<span style="font-weight:500;font-size:13px;color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="' + esc(displayName) + '">' + esc(displayName) + '</span>'
|
|
26907
|
+
+ '</div>'
|
|
26908
|
+
+ (desc ? '<div style="font-size:11px;color:var(--text-muted);line-height:1.4;margin-bottom:4px">' + esc(desc) + '</div>' : '')
|
|
26909
|
+
+ '<div style="font-size:10px;color:var(--text-muted)">'
|
|
26910
|
+
+ (usedCount > 0 ? 'Used by ' + usedCount + ' trigger' + (usedCount === 1 ? '' : 's') : '<span style="color:var(--text-muted);opacity:0.6">Unused</span>')
|
|
26911
|
+
+ '</div>'
|
|
26912
|
+
+ '</div>';
|
|
26913
|
+
}
|
|
26914
|
+
listEl.innerHTML = html;
|
|
26915
|
+
}
|
|
26916
|
+
|
|
26917
|
+
async function showSkillDetail(name) {
|
|
26918
|
+
_skillsState.selectedName = name;
|
|
26919
|
+
renderSkillsList(); // re-render to update the highlight
|
|
26920
|
+
var detailEl = document.getElementById('skills-detail');
|
|
26921
|
+
if (!detailEl) return;
|
|
26922
|
+
detailEl.innerHTML = '<div style="padding:24px;color:var(--text-muted);font-size:12px">Loading…</div>';
|
|
26923
|
+
try {
|
|
26924
|
+
var r = await apiFetch('/api/skills/' + encodeURIComponent(name));
|
|
26925
|
+
var d = await r.json();
|
|
26926
|
+
if (!r.ok || d.ok === false) {
|
|
26927
|
+
detailEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:12px">Failed: ' + esc(d.error || 'unknown') + '</div>';
|
|
26928
|
+
return;
|
|
26929
|
+
}
|
|
26930
|
+
detailEl.innerHTML = renderSkillDetail(d.skill);
|
|
26931
|
+
} catch (e) {
|
|
26932
|
+
detailEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:12px">Error: ' + esc(String(e)) + '</div>';
|
|
26933
|
+
}
|
|
26934
|
+
}
|
|
26935
|
+
|
|
26936
|
+
function renderSkillDetail(s) {
|
|
26937
|
+
var fm = s.frontmatter || {};
|
|
26938
|
+
var displayName = fm.title || fm.name;
|
|
26939
|
+
var html = '<div style="padding:24px 28px">';
|
|
26940
|
+
// Header
|
|
26941
|
+
html += '<div style="margin-bottom:18px">';
|
|
26942
|
+
html += '<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap">';
|
|
26943
|
+
html += '<h2 style="margin:0;font-size:20px;font-weight:600;color:var(--text-primary)">' + esc(displayName) + '</h2>';
|
|
26944
|
+
html += s.schemaVersion === 'v1'
|
|
26945
|
+
? '<span style="font-size:10px;background:var(--green)20;color:var(--green);padding:2px 8px;border-radius:4px;font-weight:600;letter-spacing:0.04em">V1 SCHEMA</span>'
|
|
26946
|
+
: '<span style="font-size:10px;background:var(--yellow)20;color:var(--yellow);padding:2px 8px;border-radius:4px;font-weight:600;letter-spacing:0.04em" title="Phase B will migrate this">LEGACY SCHEMA</span>';
|
|
26947
|
+
if (s.scope === 'project') {
|
|
26948
|
+
html += '<span style="font-size:10px;background:var(--blue)20;color:var(--blue);padding:2px 8px;border-radius:4px;font-weight:600">PROJECT-SCOPED</span>';
|
|
26949
|
+
}
|
|
26950
|
+
if (typeof fm.version === 'number') {
|
|
26951
|
+
html += '<span style="font-size:10px;color:var(--text-muted)">v' + esc(fm.version) + '</span>';
|
|
26952
|
+
}
|
|
26953
|
+
html += '</div>';
|
|
26954
|
+
if (fm.description) {
|
|
26955
|
+
html += '<p style="font-size:13px;color:var(--text-secondary);line-height:1.5;margin:0">' + esc(fm.description) + '</p>';
|
|
26956
|
+
}
|
|
26957
|
+
html += '<div style="margin-top:8px;font-size:11px;color:var(--text-muted)">'
|
|
26958
|
+
+ 'File: <code style="font-size:10px">' + esc(s.filePath) + '</code>'
|
|
26959
|
+
+ '</div>';
|
|
26960
|
+
html += '</div>';
|
|
26961
|
+
|
|
26962
|
+
// Used-by triggers
|
|
26963
|
+
if (Array.isArray(s.usedByTriggers) && s.usedByTriggers.length > 0) {
|
|
26964
|
+
html += '<div style="margin-bottom:18px;padding:12px 14px;background:var(--bg-tertiary);border-radius:6px">';
|
|
26965
|
+
html += '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px;font-weight:500">Used by ' + s.usedByTriggers.length + ' trigger' + (s.usedByTriggers.length === 1 ? '' : 's') + '</div>';
|
|
26966
|
+
html += '<div style="display:flex;flex-wrap:wrap;gap:6px">';
|
|
26967
|
+
for (var i = 0; i < s.usedByTriggers.length; i++) {
|
|
26968
|
+
html += '<span style="font-size:11px;background:var(--bg-secondary);padding:2px 8px;border-radius:4px;border:1px solid var(--border);color:var(--text-secondary)">' + esc(s.usedByTriggers[i]) + '</span>';
|
|
26969
|
+
}
|
|
26970
|
+
html += '</div></div>';
|
|
26971
|
+
}
|
|
26972
|
+
|
|
26973
|
+
// V1 fields (only render when present)
|
|
26974
|
+
if (fm.inputs && Object.keys(fm.inputs).length > 0) {
|
|
26975
|
+
html += renderSkillSection('Inputs', renderSkillInputs(fm.inputs));
|
|
26976
|
+
}
|
|
26977
|
+
if (fm.tools && (fm.tools.allow?.length || fm.tools.deny?.length)) {
|
|
26978
|
+
html += renderSkillSection('Tools', renderSkillTools(fm.tools));
|
|
26979
|
+
}
|
|
26980
|
+
if (Array.isArray(fm.dataSources) && fm.dataSources.length > 0) {
|
|
26981
|
+
html += renderSkillSection('Data sources', renderSkillDataSources(fm.dataSources));
|
|
26982
|
+
}
|
|
26983
|
+
if (Array.isArray(fm.stateKeys) && fm.stateKeys.length > 0) {
|
|
26984
|
+
html += renderSkillSection('State keys', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + fm.stateKeys.map(function(k) { return '<code style="font-size:11px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px">' + esc(k) + '</code>'; }).join('') + '</div>');
|
|
26985
|
+
}
|
|
26986
|
+
if (fm.success && (fm.success.criterion || fm.success.schema)) {
|
|
26987
|
+
var sc = '';
|
|
26988
|
+
if (fm.success.criterion) sc += '<div style="font-size:12px;line-height:1.5;color:var(--text-secondary);margin-bottom:8px"><em>Criterion:</em> ' + esc(fm.success.criterion) + '</div>';
|
|
26989
|
+
if (fm.success.schema) sc += '<details><summary style="cursor:pointer;font-size:11px;color:var(--text-muted)">Schema</summary><pre style="font-size:11px;background:var(--bg-tertiary);padding:10px;border-radius:6px;margin-top:6px;overflow:auto">' + esc(JSON.stringify(fm.success.schema, null, 2)) + '</pre></details>';
|
|
26990
|
+
html += renderSkillSection('Success criterion', sc);
|
|
26991
|
+
}
|
|
26992
|
+
if (fm.limits) {
|
|
26993
|
+
var l = fm.limits;
|
|
26994
|
+
var bits = [];
|
|
26995
|
+
if (l.maxTurns) bits.push('max ' + l.maxTurns + ' turns');
|
|
26996
|
+
if (l.maxBudgetUsd) bits.push('$' + l.maxBudgetUsd + ' budget');
|
|
26997
|
+
if (l.timeoutSeconds) bits.push(l.timeoutSeconds + 's timeout');
|
|
26998
|
+
if (bits.length) html += renderSkillSection('Limits', '<div style="font-size:12px;color:var(--text-secondary)">' + bits.map(esc).join(' · ') + '</div>');
|
|
26999
|
+
}
|
|
27000
|
+
|
|
27001
|
+
// Legacy fields (preserved for the migration UI)
|
|
27002
|
+
if (Array.isArray(fm.triggers) && fm.triggers.length > 0) {
|
|
27003
|
+
html += renderSkillSection('Triggers (legacy NLP phrases)',
|
|
27004
|
+
'<div style="display:flex;flex-wrap:wrap;gap:4px">'
|
|
27005
|
+
+ fm.triggers.slice(0, 30).map(function(t) { return '<span style="font-size:11px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px;color:var(--text-secondary)">' + esc(t) + '</span>'; }).join('')
|
|
27006
|
+
+ (fm.triggers.length > 30 ? '<span style="font-size:11px;color:var(--text-muted)">+' + (fm.triggers.length - 30) + ' more</span>' : '')
|
|
27007
|
+
+ '</div>');
|
|
27008
|
+
}
|
|
27009
|
+
if (Array.isArray(fm.toolsUsed) && fm.toolsUsed.length > 0) {
|
|
27010
|
+
html += renderSkillSection('Tools used (legacy informational)',
|
|
27011
|
+
'<div style="display:flex;flex-wrap:wrap;gap:4px">'
|
|
27012
|
+
+ fm.toolsUsed.map(function(t) { return '<code style="font-size:11px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px">' + esc(t) + '</code>'; }).join('')
|
|
27013
|
+
+ '</div>');
|
|
27014
|
+
}
|
|
27015
|
+
if (fm.useCount || fm.lastUsed) {
|
|
27016
|
+
var bits = [];
|
|
27017
|
+
if (fm.useCount) bits.push('Used ' + fm.useCount + ' times');
|
|
27018
|
+
if (fm.lastUsed) bits.push('Last: ' + new Date(fm.lastUsed).toLocaleString());
|
|
27019
|
+
html += renderSkillSection('Usage', '<div style="font-size:12px;color:var(--text-secondary)">' + bits.map(esc).join(' · ') + '</div>');
|
|
27020
|
+
}
|
|
27021
|
+
|
|
27022
|
+
// Body — markdown rendered as preformatted text. Phase B will add a
|
|
27023
|
+
// proper markdown renderer; Phase A keeps it simple to ship.
|
|
27024
|
+
if (s.body && s.body.trim()) {
|
|
27025
|
+
html += '<div style="margin-top:18px">';
|
|
27026
|
+
html += '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;font-weight:500">Procedure</div>';
|
|
27027
|
+
html += '<pre style="font-size:12px;line-height:1.55;background:var(--bg-tertiary);padding:14px 16px;border-radius:6px;white-space:pre-wrap;word-break:break-word;font-family:inherit;border:1px solid var(--border);max-height:500px;overflow:auto">' + esc(s.body) + '</pre>';
|
|
27028
|
+
html += '</div>';
|
|
27029
|
+
}
|
|
27030
|
+
|
|
27031
|
+
if (s.schemaVersion === 'legacy') {
|
|
27032
|
+
html += '<div style="margin-top:24px;padding:12px 14px;background:var(--yellow)15;border:1px solid var(--yellow);border-radius:6px;font-size:12px;color:var(--text-secondary);line-height:1.5">'
|
|
27033
|
+
+ '<strong style="color:var(--yellow)">Legacy schema.</strong> '
|
|
27034
|
+
+ 'This skill uses the pre-redesign frontmatter shape (title / triggers / toolsUsed / useCount). '
|
|
27035
|
+
+ 'Phase B will surface a one-click migration that converts it to v1 (inputs / tools.allow / dataSources / stateKeys / success).'
|
|
27036
|
+
+ '</div>';
|
|
27037
|
+
}
|
|
27038
|
+
|
|
27039
|
+
html += '</div>';
|
|
27040
|
+
return html;
|
|
27041
|
+
}
|
|
27042
|
+
|
|
27043
|
+
function renderSkillSection(title, body) {
|
|
27044
|
+
return '<div style="margin-bottom:18px">'
|
|
27045
|
+
+ '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;font-weight:500">' + esc(title) + '</div>'
|
|
27046
|
+
+ body
|
|
27047
|
+
+ '</div>';
|
|
27048
|
+
}
|
|
27049
|
+
|
|
27050
|
+
function renderSkillInputs(inputs) {
|
|
27051
|
+
var html = '<table style="width:100%;font-size:12px;border-collapse:collapse">';
|
|
27052
|
+
html += '<thead><tr style="border-bottom:1px solid var(--border);text-align:left">'
|
|
27053
|
+
+ '<th style="padding:6px 8px;font-weight:500;color:var(--text-muted);font-size:10px;text-transform:uppercase">Field</th>'
|
|
27054
|
+
+ '<th style="padding:6px 8px;font-weight:500;color:var(--text-muted);font-size:10px;text-transform:uppercase">Type</th>'
|
|
27055
|
+
+ '<th style="padding:6px 8px;font-weight:500;color:var(--text-muted);font-size:10px;text-transform:uppercase">Default</th>'
|
|
27056
|
+
+ '</tr></thead><tbody>';
|
|
27057
|
+
Object.keys(inputs).forEach(function(key) {
|
|
27058
|
+
var spec = inputs[key] || {};
|
|
27059
|
+
html += '<tr style="border-bottom:1px solid var(--border-light)">';
|
|
27060
|
+
html += '<td style="padding:6px 8px"><code style="font-size:11px">' + esc(key) + '</code></td>';
|
|
27061
|
+
html += '<td style="padding:6px 8px;color:var(--text-secondary)">' + esc(spec.type || 'any') + '</td>';
|
|
27062
|
+
html += '<td style="padding:6px 8px;color:var(--text-muted);font-size:11px">' + (spec.default !== undefined ? '<code>' + esc(JSON.stringify(spec.default)) + '</code>' : '—') + '</td>';
|
|
27063
|
+
html += '</tr>';
|
|
27064
|
+
if (spec.description) {
|
|
27065
|
+
html += '<tr><td colspan="3" style="padding:0 8px 6px 16px;font-size:11px;color:var(--text-muted);font-style:italic">' + esc(spec.description) + '</td></tr>';
|
|
27066
|
+
}
|
|
27067
|
+
});
|
|
27068
|
+
html += '</tbody></table>';
|
|
27069
|
+
return html;
|
|
27070
|
+
}
|
|
27071
|
+
|
|
27072
|
+
function renderSkillTools(tools) {
|
|
27073
|
+
var html = '';
|
|
27074
|
+
if (Array.isArray(tools.allow) && tools.allow.length > 0) {
|
|
27075
|
+
html += '<div style="margin-bottom:6px;font-size:11px;color:var(--text-muted)">Allow:</div>';
|
|
27076
|
+
html += '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">'
|
|
27077
|
+
+ tools.allow.map(function(t) { return '<code style="font-size:11px;background:var(--green)15;color:var(--green);padding:2px 6px;border-radius:3px">' + esc(t) + '</code>'; }).join('')
|
|
27078
|
+
+ '</div>';
|
|
27079
|
+
}
|
|
27080
|
+
if (Array.isArray(tools.deny) && tools.deny.length > 0) {
|
|
27081
|
+
html += '<div style="margin-bottom:6px;font-size:11px;color:var(--text-muted)">Deny:</div>';
|
|
27082
|
+
html += '<div style="display:flex;flex-wrap:wrap;gap:4px">'
|
|
27083
|
+
+ tools.deny.map(function(t) { return '<code style="font-size:11px;background:var(--red)15;color:var(--red);padding:2px 6px;border-radius:3px">' + esc(t) + '</code>'; }).join('')
|
|
27084
|
+
+ '</div>';
|
|
27085
|
+
}
|
|
27086
|
+
return html;
|
|
27087
|
+
}
|
|
27088
|
+
|
|
27089
|
+
function renderSkillDataSources(sources) {
|
|
27090
|
+
return '<ul style="list-style:none;padding:0;margin:0;font-size:12px">'
|
|
27091
|
+
+ sources.map(function(d) {
|
|
27092
|
+
return '<li style="padding:4px 0;border-bottom:1px solid var(--border-light)">'
|
|
27093
|
+
+ '<code style="font-size:11px;background:var(--bg-tertiary);padding:1px 6px;border-radius:3px;margin-right:8px">' + esc(d.kind) + '</code>'
|
|
27094
|
+
+ '<span style="color:var(--text-secondary)">' + esc(d.purpose) + '</span>'
|
|
27095
|
+
+ '</li>';
|
|
27096
|
+
}).join('')
|
|
27097
|
+
+ '</ul>';
|
|
27098
|
+
}
|
|
27099
|
+
|
|
26733
27100
|
async function refreshHeartbeatControl() {
|
|
26734
27101
|
var container = document.getElementById('heartbeat-control-content');
|
|
26735
27102
|
if (!container) return;
|
package/dist/types.d.ts
CHANGED
|
@@ -309,6 +309,118 @@ export interface AgentHeartbeatState {
|
|
|
309
309
|
*/
|
|
310
310
|
lastTickKind?: 'acted' | 'quiet' | 'silent' | 'override';
|
|
311
311
|
}
|
|
312
|
+
/** Source of a skill file — informational, used by the dashboard. */
|
|
313
|
+
export type SkillScope = 'global' | 'project';
|
|
314
|
+
/** Whether the file's frontmatter matches the v1 spec or is the
|
|
315
|
+
* pre-redesign shape. Drives the "needs migration" badge in the UI. */
|
|
316
|
+
export type SkillSchemaVersion = 'v1' | 'legacy';
|
|
317
|
+
/** A typed skill input — backed by JSON Schema. The dashboard form
|
|
318
|
+
* generator can derive UI directly from a JSON Schema; ajv validates. */
|
|
319
|
+
export interface SkillInputSchema {
|
|
320
|
+
type?: 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'object';
|
|
321
|
+
description?: string;
|
|
322
|
+
default?: unknown;
|
|
323
|
+
enum?: unknown[];
|
|
324
|
+
minimum?: number;
|
|
325
|
+
maximum?: number;
|
|
326
|
+
minLength?: number;
|
|
327
|
+
maxLength?: number;
|
|
328
|
+
pattern?: string;
|
|
329
|
+
items?: SkillInputSchema;
|
|
330
|
+
properties?: Record<string, SkillInputSchema>;
|
|
331
|
+
required?: string[];
|
|
332
|
+
}
|
|
333
|
+
/** Declarative entry describing where a skill reads data from. Surfaced
|
|
334
|
+
* in the dashboard's per-skill detail pane and in the Tools & MCP "used
|
|
335
|
+
* by" join. Free-form by design — different skills declare different
|
|
336
|
+
* data shapes. */
|
|
337
|
+
export interface SkillDataSource {
|
|
338
|
+
/** A loose identifier — e.g. 'outlook', 'memory', 'vault', 'cli', 'mcp:ElevenLabs'. */
|
|
339
|
+
kind: string;
|
|
340
|
+
/** One-line human description — what the skill reads from this source. */
|
|
341
|
+
purpose: string;
|
|
342
|
+
}
|
|
343
|
+
/** Tool allowlist + denylist on a skill. Deny wins on conflict. The
|
|
344
|
+
* runtime (Phase C) will refuse to invoke a tool that isn't on allow,
|
|
345
|
+
* even if the trigger tries to override. */
|
|
346
|
+
export interface SkillToolPolicy {
|
|
347
|
+
allow?: string[];
|
|
348
|
+
deny?: string[];
|
|
349
|
+
}
|
|
350
|
+
/** Success criterion. Either schema (ajv-validated against the run's
|
|
351
|
+
* structured_output) or criterion (free-text Haiku evaluator). Both =
|
|
352
|
+
* both must pass. Mirrors the existing CronJobDefinition shape. */
|
|
353
|
+
export interface SkillSuccess {
|
|
354
|
+
schema?: SkillInputSchema;
|
|
355
|
+
criterion?: string;
|
|
356
|
+
}
|
|
357
|
+
/** Per-skill caps. A trigger can tighten these but never loosen. */
|
|
358
|
+
export interface SkillLimits {
|
|
359
|
+
maxTurns?: number;
|
|
360
|
+
maxBudgetUsd?: number;
|
|
361
|
+
timeoutSeconds?: number;
|
|
362
|
+
}
|
|
363
|
+
/** Parsed frontmatter + computed metadata. Phase A surfaces this whole
|
|
364
|
+
* shape in the Skills page detail pane. */
|
|
365
|
+
export interface SkillFrontmatter {
|
|
366
|
+
/** Skill identifier — derived from filename if absent in frontmatter. */
|
|
367
|
+
name: string;
|
|
368
|
+
/** One-line description (matches today's `description:` field). */
|
|
369
|
+
description?: string;
|
|
370
|
+
/** Typed parameters the skill accepts at invocation time. */
|
|
371
|
+
inputs?: Record<string, SkillInputSchema>;
|
|
372
|
+
/** Tool allowlist + denylist enforced by the runtime. */
|
|
373
|
+
tools?: SkillToolPolicy;
|
|
374
|
+
/** Where the skill reads data from — purely declarative. */
|
|
375
|
+
dataSources?: SkillDataSource[];
|
|
376
|
+
/** State.* keys this skill owns (others can't touch them). */
|
|
377
|
+
stateKeys?: string[];
|
|
378
|
+
/** Success criterion — schema and/or free-text evaluator. */
|
|
379
|
+
success?: SkillSuccess;
|
|
380
|
+
/** Caps the trigger can tighten but never loosen. */
|
|
381
|
+
limits?: SkillLimits;
|
|
382
|
+
/** Bumped by the user on Publish (Phase B). Phase A surfaces but
|
|
383
|
+
* doesn't increment. */
|
|
384
|
+
version?: number;
|
|
385
|
+
/** Timestamps captured by the legacy + v1 schemas alike. */
|
|
386
|
+
createdAt?: string;
|
|
387
|
+
updatedAt?: string;
|
|
388
|
+
lastUsed?: string;
|
|
389
|
+
/** Last time the user clicked "Test this skill" in the dashboard
|
|
390
|
+
* (Phase B). Phase A reads but doesn't write. */
|
|
391
|
+
lastTestPass?: string;
|
|
392
|
+
/** Legacy: title (use as fallback for display when description missing). */
|
|
393
|
+
title?: string;
|
|
394
|
+
/** Legacy: NLP-style trigger phrases. Pre-redesign Clementine matched
|
|
395
|
+
* these against incoming chat messages. */
|
|
396
|
+
triggers?: string[];
|
|
397
|
+
/** Legacy: 'manual' / 'auto' / 'imported' — provenance label. */
|
|
398
|
+
source?: string;
|
|
399
|
+
/** Legacy: tools observed during runs. Informational, not constraint. */
|
|
400
|
+
toolsUsed?: string[];
|
|
401
|
+
/** Legacy: incrementing counter of how many runs invoked the skill. */
|
|
402
|
+
useCount?: number;
|
|
403
|
+
}
|
|
404
|
+
/** Resolved skill record — frontmatter + body + computed extras the
|
|
405
|
+
* dashboard surfaces (file path, scope, schemaVersion, used-by list). */
|
|
406
|
+
export interface Skill {
|
|
407
|
+
/** Frontmatter, parsed (or synthesized for files without one). */
|
|
408
|
+
frontmatter: SkillFrontmatter;
|
|
409
|
+
/** Markdown body of the skill — the actual procedure. */
|
|
410
|
+
body: string;
|
|
411
|
+
/** Absolute path to the source .md file. */
|
|
412
|
+
filePath: string;
|
|
413
|
+
/** Whether this skill was loaded from the global pool or a per-project
|
|
414
|
+
* override. Per-project wins on name collision. */
|
|
415
|
+
scope: SkillScope;
|
|
416
|
+
/** Whether the frontmatter matches the v1 spec or is the pre-redesign
|
|
417
|
+
* shape. The Skills page renders a "Schema: legacy" badge accordingly. */
|
|
418
|
+
schemaVersion: SkillSchemaVersion;
|
|
419
|
+
/** Used-by join: cron job names that reference this skill. Phase A
|
|
420
|
+
* builds this from the legacy `skills:` array on CronJobDefinition;
|
|
421
|
+
* Phase C extends it to read the new top-level `skill:` field. */
|
|
422
|
+
usedByTriggers: string[];
|
|
423
|
+
}
|
|
312
424
|
export interface CronJobDefinition {
|
|
313
425
|
name: string;
|
|
314
426
|
schedule: string;
|