dsh-continual-evolve 0.15.4 → 0.15.5
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 +13 -9
- package/README.zh.md +13 -9
- package/client/client.js +181 -16
- package/lib/card-routes.d.ts +2 -0
- package/lib/card-routes.js +66 -1
- package/lib/index.d.ts +4 -2
- package/lib/index.js +7 -2
- package/lib/memory-snapshot.d.ts +52 -0
- package/lib/memory-snapshot.js +100 -4
- package/lib/skill-rules.d.ts +73 -0
- package/lib/skill-rules.js +158 -0
- package/lib/skill-tools.d.ts +9 -0
- package/lib/skill-tools.js +198 -0
- package/lib/store.d.ts +73 -5
- package/lib/store.js +504 -6
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
package/lib/memory-snapshot.d.ts
CHANGED
|
@@ -63,4 +63,56 @@ export declare function listCardWorkspaces(store: MemoryStore): {
|
|
|
63
63
|
root: string;
|
|
64
64
|
label: string;
|
|
65
65
|
}[];
|
|
66
|
+
/** One skill row the card renders (scope resolved: project shadows global). */
|
|
67
|
+
export interface SkillFileInfo {
|
|
68
|
+
id: string;
|
|
69
|
+
title: string;
|
|
70
|
+
description: string;
|
|
71
|
+
/** `project` or `global` — which partition the row was read from. */
|
|
72
|
+
scope: string;
|
|
73
|
+
/** Row status; quarantined rows render in the patrol section, not here. */
|
|
74
|
+
status: string;
|
|
75
|
+
/** Update time in epoch milliseconds (the card's "updated" line). */
|
|
76
|
+
updatedAt: number;
|
|
77
|
+
}
|
|
78
|
+
/** The full read-only skill snapshot the card renders for one workspace root. */
|
|
79
|
+
export interface SkillSnapshot {
|
|
80
|
+
root: string;
|
|
81
|
+
exists: boolean;
|
|
82
|
+
/** Set when the store query failed; null otherwise. */
|
|
83
|
+
readError: string | null;
|
|
84
|
+
/** Active row count (the number the card's stats line shows). */
|
|
85
|
+
fileCount: number;
|
|
86
|
+
/** Active rows, project first (the list the card renders). */
|
|
87
|
+
files: SkillFileInfo[];
|
|
88
|
+
/** Rows quarantined by patrol — displayed as 巡检异常, never in the list. */
|
|
89
|
+
quarantined: {
|
|
90
|
+
id: string;
|
|
91
|
+
scope: string;
|
|
92
|
+
reason: string;
|
|
93
|
+
}[];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The read-only skill snapshot for one workspace partition. Mirrors the
|
|
97
|
+
* memory snapshot's degrade contract: unknown partitions read `exists:
|
|
98
|
+
* false`, failing queries report `readError` instead of throwing.
|
|
99
|
+
*/
|
|
100
|
+
export declare function skillSnapshot(store: MemoryStore, root: string): SkillSnapshot;
|
|
101
|
+
/** The content endpoint's outcomes for one skill body. */
|
|
102
|
+
export type SkillEntryContent = {
|
|
103
|
+
ok: true;
|
|
104
|
+
id: string;
|
|
105
|
+
scope: string;
|
|
106
|
+
content: string;
|
|
107
|
+
updatedAtMs: number;
|
|
108
|
+
} | {
|
|
109
|
+
ok: false;
|
|
110
|
+
reason: "absent" | "too-large";
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Read one skill's body for the card's content preview — project shadowing
|
|
114
|
+
* global, keyed by id, never by file name. Bodies are schema-capped at 64KB,
|
|
115
|
+
* so `too-large` is dead code kept for the day the preview limit shrinks.
|
|
116
|
+
*/
|
|
117
|
+
export declare function skillEntryContent(store: MemoryStore, root: string, id: string): SkillEntryContent;
|
|
66
118
|
//# sourceMappingURL=memory-snapshot.d.ts.map
|
package/lib/memory-snapshot.js
CHANGED
|
@@ -127,13 +127,109 @@ export function memoryEntryContent(store, root, id) {
|
|
|
127
127
|
/** The workspace rows the card lists: active partitions whose directory survives. */
|
|
128
128
|
export function listCardWorkspaces(store) {
|
|
129
129
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const rows = [];
|
|
132
|
+
// One fence for every card route: partitions holding memories or
|
|
133
|
+
// skills. A skills-only workspace lists (its memory snapshot reads
|
|
134
|
+
// empty); global skills ride along with whichever workspace is open.
|
|
135
|
+
for (const workspace of [...store.listWorkspaces(), ...store.listSkillWorkspaces()]) {
|
|
136
|
+
if (workspace.workspaceId === "" || seen.has(workspace.workspaceId)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
seen.add(workspace.workspaceId);
|
|
140
|
+
if (!existsSync(workspace.workspaceId)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
rows.push({ root: workspace.workspaceId, label: basename(workspace.workspaceId) });
|
|
144
|
+
}
|
|
145
|
+
return rows;
|
|
134
146
|
}
|
|
135
147
|
catch {
|
|
136
148
|
return [];
|
|
137
149
|
}
|
|
138
150
|
}
|
|
151
|
+
/** Project-first merge of one partition's skills with the global scope. */
|
|
152
|
+
function scopedSkills(store, root) {
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
const merged = [];
|
|
155
|
+
for (const record of store.listAllSkills(root)) {
|
|
156
|
+
if (seen.has(record.id)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
seen.add(record.id);
|
|
160
|
+
merged.push({ record, scope: "project" });
|
|
161
|
+
}
|
|
162
|
+
for (const record of store.listAllSkills("")) {
|
|
163
|
+
if (seen.has(record.id)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
seen.add(record.id);
|
|
167
|
+
merged.push({ record, scope: "global" });
|
|
168
|
+
}
|
|
169
|
+
return merged;
|
|
170
|
+
}
|
|
171
|
+
function skillInfoOf(entry) {
|
|
172
|
+
return {
|
|
173
|
+
id: entry.record.id,
|
|
174
|
+
title: entry.record.title,
|
|
175
|
+
description: entry.record.description,
|
|
176
|
+
scope: entry.scope,
|
|
177
|
+
status: entry.record.status,
|
|
178
|
+
updatedAt: Date.parse(entry.record.updatedAt) || 0,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The read-only skill snapshot for one workspace partition. Mirrors the
|
|
183
|
+
* memory snapshot's degrade contract: unknown partitions read `exists:
|
|
184
|
+
* false`, failing queries report `readError` instead of throwing.
|
|
185
|
+
*/
|
|
186
|
+
export function skillSnapshot(store, root) {
|
|
187
|
+
const absent = { root, exists: false, readError: null, fileCount: 0, files: [], quarantined: [] };
|
|
188
|
+
let entries;
|
|
189
|
+
try {
|
|
190
|
+
entries = scopedSkills(store, root);
|
|
191
|
+
if (entries.length === 0) {
|
|
192
|
+
return absent;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
return { ...absent, exists: true, readError: error instanceof Error ? error.message : String(error) };
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
root,
|
|
200
|
+
exists: true,
|
|
201
|
+
readError: null,
|
|
202
|
+
fileCount: entries.filter((entry) => entry.record.status === "active").length,
|
|
203
|
+
files: entries.filter((entry) => entry.record.status === "active").map(skillInfoOf),
|
|
204
|
+
quarantined: entries
|
|
205
|
+
.filter((entry) => entry.record.status === "quarantined")
|
|
206
|
+
.map((entry) => ({ id: entry.record.id, scope: entry.scope, reason: quarantineReasonOf(store, entry.record) })),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Read one skill's body for the card's content preview — project shadowing
|
|
211
|
+
* global, keyed by id, never by file name. Bodies are schema-capped at 64KB,
|
|
212
|
+
* so `too-large` is dead code kept for the day the preview limit shrinks.
|
|
213
|
+
*/
|
|
214
|
+
export function skillEntryContent(store, root, id) {
|
|
215
|
+
if (id.length === 0) {
|
|
216
|
+
return { ok: false, reason: "absent" };
|
|
217
|
+
}
|
|
218
|
+
let entries;
|
|
219
|
+
try {
|
|
220
|
+
entries = scopedSkills(store, root);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return { ok: false, reason: "absent" };
|
|
224
|
+
}
|
|
225
|
+
const hit = entries.find((entry) => entry.record.id === id);
|
|
226
|
+
if (hit === undefined) {
|
|
227
|
+
return { ok: false, reason: "absent" };
|
|
228
|
+
}
|
|
229
|
+
/* v8 ignore next 3 */
|
|
230
|
+
if (hit.record.body.length > MEMORY_FILE_PREVIEW_LIMIT) {
|
|
231
|
+
return { ok: false, reason: "too-large" };
|
|
232
|
+
}
|
|
233
|
+
return { ok: true, id: hit.record.id, scope: hit.scope, content: hit.record.body, updatedAtMs: Date.parse(hit.record.updatedAt) || 0 };
|
|
234
|
+
}
|
|
139
235
|
//# sourceMappingURL=memory-snapshot.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { isValidMemoryId, slugifyId } from "./memory-rules.js";
|
|
2
|
+
export { isValidMemoryId as isValidSkillId, slugifyId as slugifySkillId };
|
|
3
|
+
/** Skill scopes: project rows live in their workspace partition, global rows in `""`. */
|
|
4
|
+
export declare const SKILL_SCOPES: readonly ["project", "global"];
|
|
5
|
+
export type SkillScope = (typeof SKILL_SCOPES)[number];
|
|
6
|
+
/** The global partition id (shared with the memory store's global state rows). */
|
|
7
|
+
export declare const SKILL_GLOBAL_WORKSPACE = "";
|
|
8
|
+
/** Max rendered frontmatter description length (loaders truncate anyway). */
|
|
9
|
+
export declare const MAX_SKILL_DESCRIPTION_LENGTH = 240;
|
|
10
|
+
/**
|
|
11
|
+
* Marker carried by every materialized file, right after the frontmatter.
|
|
12
|
+
* Patrol only touches files carrying it — hand-written skills in the same
|
|
13
|
+
* directories are never rewritten or removed.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SKILL_GENERATED_MARKER = "<!-- generated by dsh-continual-evolve \u2014 the central store is the source of truth; edit via skill_write, manual edits are reverted by patrol -->";
|
|
16
|
+
/** One proposed skill mutation — the closed shape the write paths speak. */
|
|
17
|
+
export interface SkillProposal {
|
|
18
|
+
action: "create" | "update" | "delete" | "move-to-global";
|
|
19
|
+
/** Required for update/delete/move-to-global; optional for create (derived from title). */
|
|
20
|
+
id?: string | undefined;
|
|
21
|
+
/** Which partition update/delete targets. Create always lands in the project. */
|
|
22
|
+
scope?: string | undefined;
|
|
23
|
+
title?: string | undefined;
|
|
24
|
+
description?: string | undefined;
|
|
25
|
+
body?: string | undefined;
|
|
26
|
+
/** Trajectory provenance ("820-831"), kept for parity with memory proposals. */
|
|
27
|
+
sourceSeqs?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
/** The merged field values a create/update would write (delete checks none). */
|
|
30
|
+
export interface SkillFields {
|
|
31
|
+
title: string;
|
|
32
|
+
description: string;
|
|
33
|
+
body: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Gates that depend only on the merged field values (not on store state).
|
|
37
|
+
* Runs the secret screen over the full visible text so a secret smuggled
|
|
38
|
+
* into any field blocks the write.
|
|
39
|
+
*/
|
|
40
|
+
export declare function skillFieldGateReasons(fields: SkillFields): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Render a skill as a discoverable SKILL.md document. The frontmatter
|
|
43
|
+
* description carries a routing hint — title plus the first meaningful body
|
|
44
|
+
* line — instead of the bare title: the skill catalog matches on
|
|
45
|
+
* description, and a title-only description gives loaders nothing to route
|
|
46
|
+
* on.
|
|
47
|
+
*/
|
|
48
|
+
export declare function renderSkillMarkdown(skill: {
|
|
49
|
+
id: string;
|
|
50
|
+
title: string;
|
|
51
|
+
description: string;
|
|
52
|
+
body: string;
|
|
53
|
+
}): string;
|
|
54
|
+
/**
|
|
55
|
+
* Mechanical problems that would make the platform's skill loader ignore the
|
|
56
|
+
* rendered file. The file is still written, but the caller surfaces these:
|
|
57
|
+
* a silently ignored skill is worse than a noisy one.
|
|
58
|
+
*/
|
|
59
|
+
export declare function validateRenderedSkill(skill: {
|
|
60
|
+
id: string;
|
|
61
|
+
description: string;
|
|
62
|
+
}): string[];
|
|
63
|
+
/** Where project skills materialize: the workspace's own discovered skill directory. */
|
|
64
|
+
export declare function projectSkillsDir(workspaceId: string): string;
|
|
65
|
+
/** Where global skills materialize: the host-wide discovered skill directory. */
|
|
66
|
+
export declare function globalSkillsDir(): string;
|
|
67
|
+
/**
|
|
68
|
+
* Resolve and defend the materialized directory for a skill id. Ids are
|
|
69
|
+
* kebab slugs by gate, but the containment check stays: a caller bug must
|
|
70
|
+
* never turn a skill write into a write elsewhere.
|
|
71
|
+
*/
|
|
72
|
+
export declare function skillDir(root: string, id: string): string;
|
|
73
|
+
//# sourceMappingURL=skill-rules.d.ts.map
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mechanical write gates every skill passes before the store accepts it
|
|
3
|
+
* — the same "模型提议,代码保证" boundary as memories, minus the
|
|
4
|
+
* taxonomy: skills carry no type enum and no feedback body contract. Both
|
|
5
|
+
* entry points (the explicit `skill_write` tool and the manual move to the
|
|
6
|
+
* global scope) funnel through these checks; nothing reaches the database or
|
|
7
|
+
* the materialized files without them.
|
|
8
|
+
*
|
|
9
|
+
* File rendering follows the previously validated shape (pre-teardown
|
|
10
|
+
* `skill-render.ts`, itself checked against the platform loader): YAML-ish
|
|
11
|
+
* frontmatter (`name` + `description` carrying a routing hint) over the body.
|
|
12
|
+
* The official skill-creator template is no longer shipped locally, so the
|
|
13
|
+
* live host preset skills (same frontmatter contract) are the format anchor.
|
|
14
|
+
*/
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join, resolve, sep } from "node:path";
|
|
17
|
+
import { isValidMemoryId, MEMORY_BODY_LIMIT, MEMORY_DESCRIPTION_LIMIT, MEMORY_TITLE_LIMIT, secretLeakReason, slugifyId, } from "./memory-rules.js";
|
|
18
|
+
export { isValidMemoryId as isValidSkillId, slugifyId as slugifySkillId };
|
|
19
|
+
/** Skill scopes: project rows live in their workspace partition, global rows in `""`. */
|
|
20
|
+
export const SKILL_SCOPES = ["project", "global"];
|
|
21
|
+
/** The global partition id (shared with the memory store's global state rows). */
|
|
22
|
+
export const SKILL_GLOBAL_WORKSPACE = "";
|
|
23
|
+
/** Max rendered frontmatter description length (loaders truncate anyway). */
|
|
24
|
+
export const MAX_SKILL_DESCRIPTION_LENGTH = 240;
|
|
25
|
+
/**
|
|
26
|
+
* Marker carried by every materialized file, right after the frontmatter.
|
|
27
|
+
* Patrol only touches files carrying it — hand-written skills in the same
|
|
28
|
+
* directories are never rewritten or removed.
|
|
29
|
+
*/
|
|
30
|
+
export const SKILL_GENERATED_MARKER = "<!-- generated by dsh-continual-evolve — the central store is the source of truth; edit via skill_write, manual edits are reverted by patrol -->";
|
|
31
|
+
/**
|
|
32
|
+
* Gates that depend only on the merged field values (not on store state).
|
|
33
|
+
* Runs the secret screen over the full visible text so a secret smuggled
|
|
34
|
+
* into any field blocks the write.
|
|
35
|
+
*/
|
|
36
|
+
export function skillFieldGateReasons(fields) {
|
|
37
|
+
const reasons = [];
|
|
38
|
+
if (fields.title.trim().length === 0) {
|
|
39
|
+
reasons.push("title must not be empty");
|
|
40
|
+
}
|
|
41
|
+
else if (fields.title.length > MEMORY_TITLE_LIMIT) {
|
|
42
|
+
reasons.push(`title exceeds ${MEMORY_TITLE_LIMIT} characters`);
|
|
43
|
+
}
|
|
44
|
+
if (fields.description.trim().length === 0) {
|
|
45
|
+
reasons.push("description must not be empty — it is the skill trigger");
|
|
46
|
+
}
|
|
47
|
+
else if (fields.description.length > MEMORY_DESCRIPTION_LIMIT) {
|
|
48
|
+
reasons.push(`description exceeds ${MEMORY_DESCRIPTION_LIMIT} characters`);
|
|
49
|
+
}
|
|
50
|
+
if (fields.body.trim().length === 0) {
|
|
51
|
+
reasons.push("body must not be empty");
|
|
52
|
+
}
|
|
53
|
+
else if (fields.body.length > MEMORY_BODY_LIMIT) {
|
|
54
|
+
reasons.push(`body exceeds the ${MEMORY_BODY_LIMIT}-character cap`);
|
|
55
|
+
}
|
|
56
|
+
const leak = secretLeakReason(`${fields.title}\n${fields.description}\n${fields.body}`);
|
|
57
|
+
if (leak !== undefined) {
|
|
58
|
+
reasons.push(leak);
|
|
59
|
+
}
|
|
60
|
+
return reasons;
|
|
61
|
+
}
|
|
62
|
+
/** Collapse prose to one line for the frontmatter description. */
|
|
63
|
+
function oneLine(text) {
|
|
64
|
+
return text.replace(/\s+/g, " ").trim();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* First content line usable as a routing hint: non-empty, not a Markdown
|
|
68
|
+
* heading, not a list marker, not frontmatter. Undefined when the body is
|
|
69
|
+
* effectively empty.
|
|
70
|
+
*/
|
|
71
|
+
function routingHint(body) {
|
|
72
|
+
for (const rawLine of body.split("\n")) {
|
|
73
|
+
const line = rawLine.trim();
|
|
74
|
+
if (line.length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
if (line.startsWith("#") || line.startsWith("---") || line.startsWith("-") || line.startsWith("*"))
|
|
77
|
+
continue;
|
|
78
|
+
return oneLine(line);
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Render a skill as a discoverable SKILL.md document. The frontmatter
|
|
84
|
+
* description carries a routing hint — title plus the first meaningful body
|
|
85
|
+
* line — instead of the bare title: the skill catalog matches on
|
|
86
|
+
* description, and a title-only description gives loaders nothing to route
|
|
87
|
+
* on.
|
|
88
|
+
*/
|
|
89
|
+
export function renderSkillMarkdown(skill) {
|
|
90
|
+
const hint = routingHint(skill.body);
|
|
91
|
+
const stated = oneLine(skill.description);
|
|
92
|
+
const base = oneLine(skill.title);
|
|
93
|
+
// Prefer the author's own description; fall back to title + hint exactly
|
|
94
|
+
// like the pre-teardown renderer so re-materialized rows stay byte-stable.
|
|
95
|
+
let description;
|
|
96
|
+
if (stated.length > 0) {
|
|
97
|
+
description = stated;
|
|
98
|
+
}
|
|
99
|
+
else if (base.length === 0) {
|
|
100
|
+
description = (hint ?? "").slice(0, MAX_SKILL_DESCRIPTION_LENGTH);
|
|
101
|
+
}
|
|
102
|
+
else if (hint !== undefined && !base.toLowerCase().includes(hint.toLowerCase())) {
|
|
103
|
+
description = `${base} — use when: ${hint}`.slice(0, MAX_SKILL_DESCRIPTION_LENGTH);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
description = base.slice(0, MAX_SKILL_DESCRIPTION_LENGTH);
|
|
107
|
+
}
|
|
108
|
+
return ["---", `name: ${skill.id}`, `description: ${description}`, "---", "", SKILL_GENERATED_MARKER, "", skill.body.trim(), ""].join("\n");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Mechanical problems that would make the platform's skill loader ignore the
|
|
112
|
+
* rendered file. The file is still written, but the caller surfaces these:
|
|
113
|
+
* a silently ignored skill is worse than a noisy one.
|
|
114
|
+
*/
|
|
115
|
+
export function validateRenderedSkill(skill) {
|
|
116
|
+
const problems = [];
|
|
117
|
+
if (!isValidMemoryId(skill.id)) {
|
|
118
|
+
problems.push(`name "${skill.id}" is not kebab-case — the platform ignores the file`);
|
|
119
|
+
}
|
|
120
|
+
const description = oneLine(skill.description);
|
|
121
|
+
if (description.length === 0) {
|
|
122
|
+
problems.push("empty description — the catalog has nothing to route on");
|
|
123
|
+
}
|
|
124
|
+
else if (description.length > MAX_SKILL_DESCRIPTION_LENGTH) {
|
|
125
|
+
problems.push(`description exceeds ${MAX_SKILL_DESCRIPTION_LENGTH} characters — loaders truncate it`);
|
|
126
|
+
}
|
|
127
|
+
if (/[\r\n]/.test(skill.description)) {
|
|
128
|
+
problems.push("description spans lines — frontmatter must stay one line");
|
|
129
|
+
}
|
|
130
|
+
return problems;
|
|
131
|
+
}
|
|
132
|
+
/** The DSH home the global skills directory lives under (same rule as the store path). */
|
|
133
|
+
function dshHome() {
|
|
134
|
+
const home = process.env.DSH_HOME?.trim();
|
|
135
|
+
return home !== undefined && home.length > 0 ? home : join(homedir(), ".dsh");
|
|
136
|
+
}
|
|
137
|
+
/** Where project skills materialize: the workspace's own discovered skill directory. */
|
|
138
|
+
export function projectSkillsDir(workspaceId) {
|
|
139
|
+
return join(workspaceId, ".agents", "skills");
|
|
140
|
+
}
|
|
141
|
+
/** Where global skills materialize: the host-wide discovered skill directory. */
|
|
142
|
+
export function globalSkillsDir() {
|
|
143
|
+
return join(dshHome(), "skills");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve and defend the materialized directory for a skill id. Ids are
|
|
147
|
+
* kebab slugs by gate, but the containment check stays: a caller bug must
|
|
148
|
+
* never turn a skill write into a write elsewhere.
|
|
149
|
+
*/
|
|
150
|
+
export function skillDir(root, id) {
|
|
151
|
+
const base = resolve(root);
|
|
152
|
+
const dir = resolve(join(base, id));
|
|
153
|
+
if (dir !== base && !dir.startsWith(`${base}/`) && !dir.startsWith(`${base}${sep}`)) {
|
|
154
|
+
throw new Error(`skill path escapes skills root: ${dir}`);
|
|
155
|
+
}
|
|
156
|
+
return dir;
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=skill-rules.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MemoryStore } from "./store.js";
|
|
2
|
+
import type { ToolsService, ToolDefinition } from "./memory-tools.js";
|
|
3
|
+
export type { ToolsService, ToolDefinition };
|
|
4
|
+
/**
|
|
5
|
+
* Register the skill tools and return a disposer that removes them (the
|
|
6
|
+
* host may return one; call only the functions among whatever comes back).
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerSkillTools(tools: ToolsService, store: MemoryStore): () => void;
|
|
9
|
+
//# sourceMappingURL=skill-tools.d.ts.map
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing skill tools: `skill_write` (the explicit write path
|
|
3
|
+
* through the store's gates, plus the manual move to the global scope) and
|
|
4
|
+
* `skill_read` (bodies and search across the project partition and the
|
|
5
|
+
* global scope, project shadowing global on name collisions).
|
|
6
|
+
*
|
|
7
|
+
* Skills are created in the project only — global rows arise solely from an
|
|
8
|
+
* explicit `move-to-global`. There is no promotion mechanism: no counters,
|
|
9
|
+
* no confidence, no automation. Definitions are hand-built to the shape
|
|
10
|
+
* `ctx.tools.register` consumes (standard JSON Schema with a root-level
|
|
11
|
+
* `required` array, docs/FAQ.md #2). Host API shapes are declared locally;
|
|
12
|
+
* the plugin imports nothing from DSH at runtime.
|
|
13
|
+
*/
|
|
14
|
+
import { cwdOf } from "./memory-section.js";
|
|
15
|
+
function textTool(name, description, parameters, execute) {
|
|
16
|
+
return {
|
|
17
|
+
name,
|
|
18
|
+
description,
|
|
19
|
+
parameters,
|
|
20
|
+
output: {
|
|
21
|
+
schema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: { text: { type: "string", description: "结果说明" } },
|
|
24
|
+
required: ["text"],
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
},
|
|
27
|
+
render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
|
|
28
|
+
},
|
|
29
|
+
execute: async (args, exec) => ({ text: await execute(args, exec) }),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** The calling agent's session id (provenance for the ledger). */
|
|
33
|
+
function sessionIdOf(exec) {
|
|
34
|
+
const id = exec?.agent?.id;
|
|
35
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
36
|
+
}
|
|
37
|
+
const SKILL_WRITE_PARAMETERS = {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
action: {
|
|
41
|
+
type: "string",
|
|
42
|
+
enum: ["create", "update", "delete", "move-to-global"],
|
|
43
|
+
description: "create 在本项目新建技能;update 更新已有技能(只给要改的字段);delete 删除技能;move-to-global 把本项目技能手动搬到全局(项目侧归档,没有自动晋升)。",
|
|
44
|
+
},
|
|
45
|
+
scope: {
|
|
46
|
+
type: "string",
|
|
47
|
+
enum: ["project", "global"],
|
|
48
|
+
description: "update/delete 的目标分区(默认 project;全局行只可显式指定 global 更新或删除)。",
|
|
49
|
+
},
|
|
50
|
+
id: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "短横线小写标识。update/delete/move-to-global 必填;create 可省略(从 title 推导,中文标题会得到哈希后缀)。",
|
|
53
|
+
},
|
|
54
|
+
title: { type: "string", description: "短标题(create 必填)。" },
|
|
55
|
+
description: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "一句话触发钩子——决定未来会话会不会想起这个技能(create 必填,会进 SKILL.md 的 description)。",
|
|
58
|
+
},
|
|
59
|
+
body: { type: "string", description: "技能正文(create 必填;update 时传新正文整体替换)。" },
|
|
60
|
+
},
|
|
61
|
+
required: ["action"],
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
};
|
|
64
|
+
const SKILL_READ_PARAMETERS = {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
id: { type: "string", description: "按 id 直取一条技能的完整内容(同名时项目遮蔽全局)。" },
|
|
68
|
+
query: { type: "string", description: "关键词检索(不传 id 时生效;不传 query 则列出本项目与全局技能)。" },
|
|
69
|
+
limit: { type: "number", description: "检索/列出的最大条数,默认 3,上限 10。" },
|
|
70
|
+
},
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Register the skill tools and return a disposer that removes them (the
|
|
75
|
+
* host may return one; call only the functions among whatever comes back).
|
|
76
|
+
*/
|
|
77
|
+
export function registerSkillTools(tools, store) {
|
|
78
|
+
const disposers = [];
|
|
79
|
+
disposers.push(tools.register(textTool("skill_write", "写入可复用的技能(create 新建 / update 更新 / delete 删除 / move-to-global 手动搬到全局)。用户明确要求沉淀技能时使用;技能只在本项目创建,全局只接受手动搬家,由插件代码校验后落库并物化为 SKILL.md。", SKILL_WRITE_PARAMETERS, async (args, exec) => {
|
|
80
|
+
const cwd = cwdOf(exec.agent);
|
|
81
|
+
if (cwd === undefined) {
|
|
82
|
+
return "错误:当前会话没有可用的工作区路径,无法写入技能。";
|
|
83
|
+
}
|
|
84
|
+
const action = args.action;
|
|
85
|
+
const { outcomes, warnings } = store.applySkillProposals({ workspaceId: cwd, trigger: "explicit", sessionId: sessionIdOf(exec) }, [
|
|
86
|
+
{
|
|
87
|
+
action,
|
|
88
|
+
scope: typeof args.scope === "string" ? args.scope : undefined,
|
|
89
|
+
id: typeof args.id === "string" ? args.id : undefined,
|
|
90
|
+
title: typeof args.title === "string" ? args.title : undefined,
|
|
91
|
+
description: typeof args.description === "string" ? args.description : undefined,
|
|
92
|
+
body: typeof args.body === "string" ? args.body : undefined,
|
|
93
|
+
},
|
|
94
|
+
]);
|
|
95
|
+
const outcome = outcomes[0];
|
|
96
|
+
if (outcome === undefined || !outcome.ok) {
|
|
97
|
+
return `写入被门禁拒绝,技能未落盘:${outcome?.reason ?? "未知原因"}`;
|
|
98
|
+
}
|
|
99
|
+
const warningText = warnings.length > 0 ? `(文件投影告警:${warnings.map((w) => w.warning).join(";")})` : "";
|
|
100
|
+
if (outcome.action === "delete") {
|
|
101
|
+
return `已删除技能 \`${outcome.id}\`。旧内容保留在技能账本中,可由用户恢复。${warningText}`;
|
|
102
|
+
}
|
|
103
|
+
if (outcome.action === "move-to-global") {
|
|
104
|
+
return `已把技能 \`${outcome.id}\` 手动搬到全局(本项目侧已归档)。重启后所有工作区可用。${warningText}`;
|
|
105
|
+
}
|
|
106
|
+
return `已${outcome.action === "create" ? "写入" : "更新"}技能 \`${outcome.id}\`(本项目,重启后本工作区可用)。${warningText}`;
|
|
107
|
+
})));
|
|
108
|
+
disposers.push(tools.register(textTool("skill_read", "读取技能:按 id 取一条的完整内容,或按关键词检索本项目与全局的技能(同名时项目遮蔽全局,只返回项目那条)。", SKILL_READ_PARAMETERS, async (args, exec) => {
|
|
109
|
+
const cwd = cwdOf(exec.agent);
|
|
110
|
+
if (cwd === undefined) {
|
|
111
|
+
return "错误:当前会话没有可用的工作区路径,无法读取技能。";
|
|
112
|
+
}
|
|
113
|
+
const limit = clampLimit(args.limit);
|
|
114
|
+
if (typeof args.id === "string" && args.id.length > 0) {
|
|
115
|
+
const found = readWithScope(store, cwd, args.id);
|
|
116
|
+
return found === undefined ? `没有找到 id 为 "${args.id}" 的技能。` : formatSkill(found.record, found.scope);
|
|
117
|
+
}
|
|
118
|
+
const query = typeof args.query === "string" ? args.query : "";
|
|
119
|
+
const merged = query.length > 0 ? searchWithScope(store, cwd, query, limit) : listWithScope(store, cwd, limit);
|
|
120
|
+
if (merged.length === 0) {
|
|
121
|
+
return query.length > 0 ? `没有匹配 "${query}" 的技能。` : "本项目和全局都还没有技能。";
|
|
122
|
+
}
|
|
123
|
+
return merged.map((entry) => formatSkill(entry.record, entry.scope)).join("\n\n---\n\n");
|
|
124
|
+
})));
|
|
125
|
+
return () => {
|
|
126
|
+
for (const disposer of disposers) {
|
|
127
|
+
if (typeof disposer === "function") {
|
|
128
|
+
disposer();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Project-first read: a project row shadows the global row of the same id. */
|
|
134
|
+
function readWithScope(store, workspaceId, id) {
|
|
135
|
+
const project = store.getSkill(workspaceId, id);
|
|
136
|
+
if (project !== undefined && project.status !== "archived") {
|
|
137
|
+
return { record: project, scope: "project" };
|
|
138
|
+
}
|
|
139
|
+
const global = store.getSkill("", id);
|
|
140
|
+
if (global !== undefined && global.status !== "archived") {
|
|
141
|
+
return { record: global, scope: "global" };
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
/** Scope-merged search: project wins on id collisions, global fills the rest. */
|
|
146
|
+
function searchWithScope(store, workspaceId, query, limit) {
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const merged = [];
|
|
149
|
+
for (const record of store.searchSkills(workspaceId, query, limit)) {
|
|
150
|
+
if (seen.has(record.id)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
seen.add(record.id);
|
|
154
|
+
merged.push({ record, scope: "project" });
|
|
155
|
+
}
|
|
156
|
+
if (merged.length < limit) {
|
|
157
|
+
for (const record of store.searchSkills("", query, limit)) {
|
|
158
|
+
if (seen.has(record.id)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
seen.add(record.id);
|
|
162
|
+
merged.push({ record, scope: "global" });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return merged.slice(0, limit);
|
|
166
|
+
}
|
|
167
|
+
/** Scope-merged listing: project rows first, then global rows. */
|
|
168
|
+
function listWithScope(store, workspaceId, limit) {
|
|
169
|
+
const merged = store
|
|
170
|
+
.listSkills(workspaceId)
|
|
171
|
+
.slice(0, limit)
|
|
172
|
+
.map((record) => ({ record, scope: "project" }));
|
|
173
|
+
if (merged.length < limit) {
|
|
174
|
+
const seen = new Set(merged.map((entry) => entry.record.id));
|
|
175
|
+
for (const record of store.listSkills("")) {
|
|
176
|
+
if (merged.length >= limit) {
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
if (seen.has(record.id)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
seen.add(record.id);
|
|
183
|
+
merged.push({ record, scope: "global" });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return merged;
|
|
187
|
+
}
|
|
188
|
+
function clampLimit(value) {
|
|
189
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
190
|
+
return 3;
|
|
191
|
+
}
|
|
192
|
+
return Math.min(10, Math.max(1, Math.floor(value)));
|
|
193
|
+
}
|
|
194
|
+
/** Full skill text: header line with scope, then the body. */
|
|
195
|
+
function formatSkill(record, scope) {
|
|
196
|
+
return [`# ${record.title}`, `id: ${record.id} | 范围: ${scope === "global" ? "全局" : "本项目"} | 状态: ${record.status} | 更新: ${record.updatedAt}`, `钩子: ${record.description}`, "", record.body].join("\n");
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=skill-tools.js.map
|