@celestea/core 2.7.1
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/LICENSE +21 -0
- package/README.md +95 -0
- package/contracts/data-files/checkpoint.schema.json +111 -0
- package/contracts/data-files/cli-main-jsonl-precompact.schema.json +27 -0
- package/contracts/data-files/cli-main-jsonl.schema.json +22 -0
- package/contracts/data-files/fallbacks.schema.json +71 -0
- package/contracts/data-files/index.json +124 -0
- package/contracts/data-files/pricing.schema.json +65 -0
- package/contracts/data-files/prompts.schema.json +130 -0
- package/contracts/data-files/providers.schema.json +177 -0
- package/contracts/data-files/registry-tsv.schema.json +74 -0
- package/contracts/data-files/session.schema.json +51 -0
- package/contracts/data-files/usage-ledger.schema.json +112 -0
- package/contracts/data-files/workspaces.schema.json +63 -0
- package/contracts/endpoints.json +4390 -0
- package/contracts/probe-evidence.json +219 -0
- package/contracts/route-table.snapshot.json +377 -0
- package/contracts/scope-hash-vectors.json +273 -0
- package/contracts/session-event.schema.json +441 -0
- package/contracts/sse-events.json +202 -0
- package/contracts/tools.json +730 -0
- package/dist/agent.d.ts +65 -0
- package/dist/agent.js +36 -0
- package/dist/celestea-home.d.ts +63 -0
- package/dist/celestea-home.js +96 -0
- package/dist/celestea-sources.d.ts +53 -0
- package/dist/celestea-sources.js +61 -0
- package/dist/context.d.ts +33 -0
- package/dist/context.js +55 -0
- package/dist/contracts/index.d.ts +234 -0
- package/dist/contracts/index.js +159 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +22 -0
- package/dist/event-bus.d.ts +60 -0
- package/dist/event-bus.js +100 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +66 -0
- package/dist/injection.d.ts +61 -0
- package/dist/injection.js +27 -0
- package/dist/json.d.ts +34 -0
- package/dist/json.js +127 -0
- package/dist/llm.d.ts +34 -0
- package/dist/llm.js +41 -0
- package/dist/memory.d.ts +72 -0
- package/dist/memory.js +123 -0
- package/dist/message.d.ts +189 -0
- package/dist/message.js +252 -0
- package/dist/plugin.d.ts +38 -0
- package/dist/plugin.js +49 -0
- package/dist/projection.d.ts +67 -0
- package/dist/projection.js +168 -0
- package/dist/question.d.ts +154 -0
- package/dist/question.js +82 -0
- package/dist/redact.d.ts +40 -0
- package/dist/redact.js +185 -0
- package/dist/repo.d.ts +14 -0
- package/dist/repo.js +87 -0
- package/dist/sandbox.d.ts +182 -0
- package/dist/sandbox.js +78 -0
- package/dist/session-event.d.ts +57 -0
- package/dist/session-event.js +425 -0
- package/dist/session-log.d.ts +71 -0
- package/dist/session-log.js +66 -0
- package/dist/skill-catalog.d.ts +29 -0
- package/dist/skill-catalog.js +52 -0
- package/dist/skills.d.ts +116 -0
- package/dist/skills.js +273 -0
- package/dist/sse-bus.d.ts +40 -0
- package/dist/sse-bus.js +105 -0
- package/dist/stream.d.ts +115 -0
- package/dist/stream.js +52 -0
- package/dist/tool-surface.d.ts +45 -0
- package/dist/tool-surface.js +98 -0
- package/dist/tool.d.ts +77 -0
- package/dist/tool.js +15 -0
- package/dist/turn-id.d.ts +37 -0
- package/dist/turn-id.js +76 -0
- package/dist/types.d.ts +396 -0
- package/dist/types.js +58 -0
- package/package.json +27 -0
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W882 — skill discovery over the two-layer source model (celestea-sources.ts).
|
|
3
|
+
*
|
|
4
|
+
* A "skill" is a directory skills/<name>/SKILL.md. The file starts with YAML
|
|
5
|
+
* frontmatter; the rest is the BODY. Discovery is a pure walk over the layers
|
|
6
|
+
* [project, global] (project WINS on a name collision). Parsing is a pure
|
|
7
|
+
* function; the filesystem lives behind the tiny SkillIo seam so the parser and
|
|
8
|
+
* the precedence rules are unit-testable without touching disk.
|
|
9
|
+
*
|
|
10
|
+
* Frontmatter contract (mirrors anthropics/skills):
|
|
11
|
+
* - whitelist: name / description / license / allowed-tools / compatibility /
|
|
12
|
+
* metadata. ANY unknown key REJECTS the skill with a readable reason.
|
|
13
|
+
* - name must equal the directory name, match ^[a-z0-9-]+$ and be <= 64 chars.
|
|
14
|
+
* - description is REQUIRED, <= 1024 chars, and must not contain < or >.
|
|
15
|
+
* Write it as a TRIGGER sentence ("Use this skill whenever ...").
|
|
16
|
+
* - compatibility <= 500 chars.
|
|
17
|
+
*
|
|
18
|
+
* Progressive disclosure: loadSkillBody() returns the SKILL.md BODY ONLY. A
|
|
19
|
+
* skill's child resources (references/, scripts/) are NEVER auto-loaded; the
|
|
20
|
+
* model must explicitly read them. W882 adds NO tool and does NOT inject any
|
|
21
|
+
* skill text into a prompt — this module is discovery + lookup only.
|
|
22
|
+
*/
|
|
23
|
+
import { type SourceLayer, type SourceName } from "./celestea-sources.js";
|
|
24
|
+
/** Allowed name shape. */
|
|
25
|
+
export declare const SKILL_NAME_PATTERN: RegExp;
|
|
26
|
+
/** name <= 64 chars. */
|
|
27
|
+
export declare const SKILL_NAME_MAX = 64;
|
|
28
|
+
/** description <= 1024 chars. */
|
|
29
|
+
export declare const SKILL_DESCRIPTION_MAX = 1024;
|
|
30
|
+
/** compatibility <= 500 chars. */
|
|
31
|
+
export declare const SKILL_COMPATIBILITY_MAX = 500;
|
|
32
|
+
/** The ONLY frontmatter keys a skill may declare. */
|
|
33
|
+
export declare const SKILL_FRONTMATTER_KEYS: readonly ["name", "description", "license", "allowed-tools", "compatibility", "metadata"];
|
|
34
|
+
export type SkillMetadata = Record<string, string>;
|
|
35
|
+
export interface ParsedSkillFrontmatter {
|
|
36
|
+
name: string | null;
|
|
37
|
+
description: string | null;
|
|
38
|
+
license: string | null;
|
|
39
|
+
allowedTools: string[];
|
|
40
|
+
compatibility: string | null;
|
|
41
|
+
metadata: SkillMetadata;
|
|
42
|
+
}
|
|
43
|
+
export interface SkillDefinition extends ParsedSkillFrontmatter {
|
|
44
|
+
name: string;
|
|
45
|
+
description: string;
|
|
46
|
+
source: SourceName;
|
|
47
|
+
dir: string;
|
|
48
|
+
file: string;
|
|
49
|
+
}
|
|
50
|
+
export interface SkillLocation {
|
|
51
|
+
name: string;
|
|
52
|
+
dir: string;
|
|
53
|
+
file: string;
|
|
54
|
+
source: SourceName;
|
|
55
|
+
}
|
|
56
|
+
export interface SkillRejection extends SkillLocation {
|
|
57
|
+
reason: string;
|
|
58
|
+
}
|
|
59
|
+
export interface SkillListing {
|
|
60
|
+
skills: SkillDefinition[];
|
|
61
|
+
rejected: SkillRejection[];
|
|
62
|
+
}
|
|
63
|
+
export type FrontmatterParse = {
|
|
64
|
+
ok: true;
|
|
65
|
+
data: ParsedSkillFrontmatter;
|
|
66
|
+
body: string;
|
|
67
|
+
} | {
|
|
68
|
+
ok: false;
|
|
69
|
+
error: string;
|
|
70
|
+
};
|
|
71
|
+
export type SkillValidation = {
|
|
72
|
+
ok: true;
|
|
73
|
+
skill: SkillDefinition;
|
|
74
|
+
} | {
|
|
75
|
+
ok: false;
|
|
76
|
+
reason: SkillRejection;
|
|
77
|
+
};
|
|
78
|
+
export type SkillLoad = {
|
|
79
|
+
ok: true;
|
|
80
|
+
skill: SkillDefinition;
|
|
81
|
+
body: string;
|
|
82
|
+
} | {
|
|
83
|
+
ok: false;
|
|
84
|
+
error: string;
|
|
85
|
+
};
|
|
86
|
+
/** The thin filesystem seam: the only impure surface of this module. */
|
|
87
|
+
export interface SkillIo {
|
|
88
|
+
listDirectories(dir: string): string[];
|
|
89
|
+
readText(file: string): string | null;
|
|
90
|
+
}
|
|
91
|
+
/** Real filesystem; every error (missing root, missing file) reads as "absent". */
|
|
92
|
+
export declare const nodeSkillIo: SkillIo;
|
|
93
|
+
/**
|
|
94
|
+
* Parse the leading YAML frontmatter of a SKILL.md. PURE. Returns the parsed
|
|
95
|
+
* whitelist fields plus the body (everything after the closing delimiter). An
|
|
96
|
+
* unknown key is an ERROR, never silently ignored.
|
|
97
|
+
*/
|
|
98
|
+
export declare function parseSkillFrontmatter(text: string): FrontmatterParse;
|
|
99
|
+
/**
|
|
100
|
+
* Validate the parsed frontmatter against the anthropics/skills contract.
|
|
101
|
+
* location.name is the DIRECTORY name; the frontmatter name must equal it.
|
|
102
|
+
*/
|
|
103
|
+
export declare function validateSkill(data: ParsedSkillFrontmatter, location: SkillLocation): SkillValidation;
|
|
104
|
+
/**
|
|
105
|
+
* Discover every skill in the layers, HIGHEST PRIORITY FIRST. The first layer to
|
|
106
|
+
* define a name wins; a lower layer is not even parsed for that name. A rejected
|
|
107
|
+
* project skill therefore shadows (and does not fall back to) a global one, so a
|
|
108
|
+
* broken override is visible instead of silent. Absent roots are not an error.
|
|
109
|
+
*/
|
|
110
|
+
export declare function listSkills(layers: readonly SourceLayer[], io?: SkillIo): SkillListing;
|
|
111
|
+
/**
|
|
112
|
+
* The BODY of one named skill, from the first layer that defines it. Returns the
|
|
113
|
+
* SKILL.md body only — child resources are never auto-loaded (progressive
|
|
114
|
+
* disclosure). The name must already be a valid skill name (no path traversal).
|
|
115
|
+
*/
|
|
116
|
+
export declare function loadSkillBody(layers: readonly SourceLayer[], name: string, io?: SkillIo): SkillLoad;
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W882 — skill discovery over the two-layer source model (celestea-sources.ts).
|
|
3
|
+
*
|
|
4
|
+
* A "skill" is a directory skills/<name>/SKILL.md. The file starts with YAML
|
|
5
|
+
* frontmatter; the rest is the BODY. Discovery is a pure walk over the layers
|
|
6
|
+
* [project, global] (project WINS on a name collision). Parsing is a pure
|
|
7
|
+
* function; the filesystem lives behind the tiny SkillIo seam so the parser and
|
|
8
|
+
* the precedence rules are unit-testable without touching disk.
|
|
9
|
+
*
|
|
10
|
+
* Frontmatter contract (mirrors anthropics/skills):
|
|
11
|
+
* - whitelist: name / description / license / allowed-tools / compatibility /
|
|
12
|
+
* metadata. ANY unknown key REJECTS the skill with a readable reason.
|
|
13
|
+
* - name must equal the directory name, match ^[a-z0-9-]+$ and be <= 64 chars.
|
|
14
|
+
* - description is REQUIRED, <= 1024 chars, and must not contain < or >.
|
|
15
|
+
* Write it as a TRIGGER sentence ("Use this skill whenever ...").
|
|
16
|
+
* - compatibility <= 500 chars.
|
|
17
|
+
*
|
|
18
|
+
* Progressive disclosure: loadSkillBody() returns the SKILL.md BODY ONLY. A
|
|
19
|
+
* skill's child resources (references/, scripts/) are NEVER auto-loaded; the
|
|
20
|
+
* model must explicitly read them. W882 adds NO tool and does NOT inject any
|
|
21
|
+
* skill text into a prompt — this module is discovery + lookup only.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { SKILL_FILE_NAME, SKILLS_SUBDIR } from "./celestea-sources.js";
|
|
26
|
+
/** Allowed name shape. */
|
|
27
|
+
export const SKILL_NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
28
|
+
/** name <= 64 chars. */
|
|
29
|
+
export const SKILL_NAME_MAX = 64;
|
|
30
|
+
/** description <= 1024 chars. */
|
|
31
|
+
export const SKILL_DESCRIPTION_MAX = 1024;
|
|
32
|
+
/** compatibility <= 500 chars. */
|
|
33
|
+
export const SKILL_COMPATIBILITY_MAX = 500;
|
|
34
|
+
/** The ONLY frontmatter keys a skill may declare. */
|
|
35
|
+
export const SKILL_FRONTMATTER_KEYS = ["name", "description", "license", "allowed-tools", "compatibility", "metadata"];
|
|
36
|
+
/** Real filesystem; every error (missing root, missing file) reads as "absent". */
|
|
37
|
+
export const nodeSkillIo = {
|
|
38
|
+
listDirectories(dir) {
|
|
39
|
+
try {
|
|
40
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
41
|
+
.filter((e) => e.isDirectory())
|
|
42
|
+
.map((e) => e.name);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
readText(file) {
|
|
49
|
+
try {
|
|
50
|
+
return readFileSync(file, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const ALLOWED_KEYS = new Set(SKILL_FRONTMATTER_KEYS);
|
|
58
|
+
const FRONTMATTER_DELIMITER = "---";
|
|
59
|
+
function newFrontmatter() {
|
|
60
|
+
return { scalars: {}, allowedTools: [], metadata: {}, unknown: [] };
|
|
61
|
+
}
|
|
62
|
+
function unquote(value) {
|
|
63
|
+
const t = value.trim();
|
|
64
|
+
const first = t.charAt(0);
|
|
65
|
+
const last = t.charAt(t.length - 1);
|
|
66
|
+
if (t.length >= 2 && ((first === '"' && last === '"') || (first === "'" && last === "'"))) {
|
|
67
|
+
return t.slice(1, -1);
|
|
68
|
+
}
|
|
69
|
+
return t;
|
|
70
|
+
}
|
|
71
|
+
function splitListValue(value) {
|
|
72
|
+
const t = value.trim();
|
|
73
|
+
const inner = t.startsWith("[") && t.endsWith("]") ? t.slice(1, -1) : t;
|
|
74
|
+
return inner.split(/[,\s]+/).map(unquote).filter((s) => s !== "");
|
|
75
|
+
}
|
|
76
|
+
function applyTopLevel(fm, key, value) {
|
|
77
|
+
if (key === "metadata")
|
|
78
|
+
return null;
|
|
79
|
+
if (key === "allowed-tools") {
|
|
80
|
+
if (value !== "")
|
|
81
|
+
fm.allowedTools = splitListValue(value);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (value !== "")
|
|
85
|
+
fm.scalars[key] = unquote(value);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function applyIndented(fm, currentKey, trimmed) {
|
|
89
|
+
if (currentKey === null)
|
|
90
|
+
return null;
|
|
91
|
+
if (trimmed.startsWith("- ")) {
|
|
92
|
+
if (currentKey === "allowed-tools")
|
|
93
|
+
fm.allowedTools.push(unquote(trimmed.slice(2)));
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (currentKey === "metadata") {
|
|
97
|
+
const colon = trimmed.indexOf(":");
|
|
98
|
+
if (colon <= 0)
|
|
99
|
+
return "invalid metadata line '" + trimmed + "'";
|
|
100
|
+
fm.metadata[trimmed.slice(0, colon).trim()] = unquote(trimmed.slice(colon + 1));
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return "unsupported frontmatter block under '" + currentKey + "'";
|
|
104
|
+
}
|
|
105
|
+
function parseBlock(block) {
|
|
106
|
+
const fm = newFrontmatter();
|
|
107
|
+
let currentKey = null;
|
|
108
|
+
for (const rawLine of block.split("\n")) {
|
|
109
|
+
const trimmed = rawLine.trim();
|
|
110
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
111
|
+
continue;
|
|
112
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
113
|
+
if (indent > 0) {
|
|
114
|
+
const err = applyIndented(fm, currentKey, trimmed);
|
|
115
|
+
if (err !== null)
|
|
116
|
+
return { error: err };
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const colon = trimmed.indexOf(":");
|
|
120
|
+
if (colon <= 0)
|
|
121
|
+
return { error: "invalid frontmatter line '" + trimmed + "'" };
|
|
122
|
+
const key = trimmed.slice(0, colon).trim();
|
|
123
|
+
if (!ALLOWED_KEYS.has(key)) {
|
|
124
|
+
fm.unknown.push(key);
|
|
125
|
+
currentKey = null;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
currentKey = key;
|
|
129
|
+
const err = applyTopLevel(fm, key, trimmed.slice(colon + 1).trim());
|
|
130
|
+
if (err !== null)
|
|
131
|
+
return { error: err };
|
|
132
|
+
}
|
|
133
|
+
return { fm };
|
|
134
|
+
}
|
|
135
|
+
function toParsed(fm) {
|
|
136
|
+
return {
|
|
137
|
+
name: fm.scalars["name"] ?? null,
|
|
138
|
+
description: fm.scalars["description"] ?? null,
|
|
139
|
+
license: fm.scalars["license"] ?? null,
|
|
140
|
+
allowedTools: fm.allowedTools,
|
|
141
|
+
compatibility: fm.scalars["compatibility"] ?? null,
|
|
142
|
+
metadata: fm.metadata,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Parse the leading YAML frontmatter of a SKILL.md. PURE. Returns the parsed
|
|
147
|
+
* whitelist fields plus the body (everything after the closing delimiter). An
|
|
148
|
+
* unknown key is an ERROR, never silently ignored.
|
|
149
|
+
*/
|
|
150
|
+
export function parseSkillFrontmatter(text) {
|
|
151
|
+
const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
152
|
+
const lines = normalized.split("\n");
|
|
153
|
+
if ((lines[0] ?? "").trim() !== FRONTMATTER_DELIMITER) {
|
|
154
|
+
return { ok: false, error: "missing YAML frontmatter: file must start with '---'" };
|
|
155
|
+
}
|
|
156
|
+
let close = -1;
|
|
157
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
158
|
+
if ((lines[i] ?? "").trim() === FRONTMATTER_DELIMITER) {
|
|
159
|
+
close = i;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (close === -1)
|
|
164
|
+
return { ok: false, error: "missing closing '---' frontmatter delimiter" };
|
|
165
|
+
const block = parseBlock(lines.slice(1, close).join("\n"));
|
|
166
|
+
if ("error" in block)
|
|
167
|
+
return { ok: false, error: block.error };
|
|
168
|
+
const { fm } = block;
|
|
169
|
+
if (fm.unknown.length > 0) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
error: "unknown frontmatter key(s): " + fm.unknown.join(", ") + " (allowed: " + SKILL_FRONTMATTER_KEYS.join(", ") + ")",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const body = lines.slice(close + 1).join("\n").replace(/^\n/, "");
|
|
176
|
+
return { ok: true, data: toParsed(fm), body };
|
|
177
|
+
}
|
|
178
|
+
function reject(location, reason) {
|
|
179
|
+
return { ok: false, reason: { ...location, reason } };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Validate the parsed frontmatter against the anthropics/skills contract.
|
|
183
|
+
* location.name is the DIRECTORY name; the frontmatter name must equal it.
|
|
184
|
+
*/
|
|
185
|
+
export function validateSkill(data, location) {
|
|
186
|
+
const name = data.name;
|
|
187
|
+
if (name === null || name === "")
|
|
188
|
+
return reject(location, "missing required frontmatter key 'name'");
|
|
189
|
+
if (name !== location.name) {
|
|
190
|
+
return reject(location, "frontmatter name '" + name + "' does not match directory name '" + location.name + "'");
|
|
191
|
+
}
|
|
192
|
+
if (!SKILL_NAME_PATTERN.test(name)) {
|
|
193
|
+
return reject(location, "name '" + name + "' must match " + String(SKILL_NAME_PATTERN) + " (lowercase letters, digits, hyphens)");
|
|
194
|
+
}
|
|
195
|
+
if (name.length > SKILL_NAME_MAX)
|
|
196
|
+
return reject(location, "name is " + name.length + " chars; max " + SKILL_NAME_MAX);
|
|
197
|
+
const description = data.description;
|
|
198
|
+
if (description === null || description === "")
|
|
199
|
+
return reject(location, "missing required frontmatter key 'description'");
|
|
200
|
+
if (description.length > SKILL_DESCRIPTION_MAX) {
|
|
201
|
+
return reject(location, "description is " + description.length + " chars; max " + SKILL_DESCRIPTION_MAX);
|
|
202
|
+
}
|
|
203
|
+
if (/[<>]/.test(description))
|
|
204
|
+
return reject(location, "description must not contain angle brackets '<' or '>'");
|
|
205
|
+
const compatibility = data.compatibility;
|
|
206
|
+
if (compatibility !== null && compatibility.length > SKILL_COMPATIBILITY_MAX) {
|
|
207
|
+
return reject(location, "compatibility is " + compatibility.length + " chars; max " + SKILL_COMPATIBILITY_MAX);
|
|
208
|
+
}
|
|
209
|
+
return { ok: true, skill: { ...data, name, description, source: location.source, dir: location.dir, file: location.file } };
|
|
210
|
+
}
|
|
211
|
+
function candidateOf(layer, name) {
|
|
212
|
+
const dir = join(layer.root, SKILLS_SUBDIR, name);
|
|
213
|
+
return { name, dir, file: join(dir, SKILL_FILE_NAME), source: layer.source };
|
|
214
|
+
}
|
|
215
|
+
function acceptCandidate(location, text) {
|
|
216
|
+
const parsed = parseSkillFrontmatter(text);
|
|
217
|
+
if (!parsed.ok)
|
|
218
|
+
return { reason: parsed.error };
|
|
219
|
+
const valid = validateSkill(parsed.data, location);
|
|
220
|
+
if (!valid.ok)
|
|
221
|
+
return { reason: valid.reason.reason };
|
|
222
|
+
return { skill: valid.skill, body: parsed.body };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Discover every skill in the layers, HIGHEST PRIORITY FIRST. The first layer to
|
|
226
|
+
* define a name wins; a lower layer is not even parsed for that name. A rejected
|
|
227
|
+
* project skill therefore shadows (and does not fall back to) a global one, so a
|
|
228
|
+
* broken override is visible instead of silent. Absent roots are not an error.
|
|
229
|
+
*/
|
|
230
|
+
export function listSkills(layers, io = nodeSkillIo) {
|
|
231
|
+
const winners = new Map();
|
|
232
|
+
const rejected = [];
|
|
233
|
+
const claimed = new Set();
|
|
234
|
+
for (const layer of layers) {
|
|
235
|
+
const names = io.listDirectories(join(layer.root, SKILLS_SUBDIR)).slice().sort();
|
|
236
|
+
for (const name of names) {
|
|
237
|
+
if (claimed.has(name))
|
|
238
|
+
continue;
|
|
239
|
+
const location = candidateOf(layer, name);
|
|
240
|
+
const text = io.readText(location.file);
|
|
241
|
+
if (text === null)
|
|
242
|
+
continue;
|
|
243
|
+
claimed.add(name);
|
|
244
|
+
const outcome = acceptCandidate(location, text);
|
|
245
|
+
if ("skill" in outcome)
|
|
246
|
+
winners.set(name, outcome.skill);
|
|
247
|
+
else
|
|
248
|
+
rejected.push({ ...location, reason: outcome.reason });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const skills = [...winners.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
252
|
+
return { skills, rejected };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The BODY of one named skill, from the first layer that defines it. Returns the
|
|
256
|
+
* SKILL.md body only — child resources are never auto-loaded (progressive
|
|
257
|
+
* disclosure). The name must already be a valid skill name (no path traversal).
|
|
258
|
+
*/
|
|
259
|
+
export function loadSkillBody(layers, name, io = nodeSkillIo) {
|
|
260
|
+
if (!SKILL_NAME_PATTERN.test(name))
|
|
261
|
+
return { ok: false, error: "invalid skill name '" + name + "'" };
|
|
262
|
+
for (const layer of layers) {
|
|
263
|
+
const location = candidateOf(layer, name);
|
|
264
|
+
const text = io.readText(location.file);
|
|
265
|
+
if (text === null)
|
|
266
|
+
continue;
|
|
267
|
+
const outcome = acceptCandidate(location, text);
|
|
268
|
+
if ("skill" in outcome)
|
|
269
|
+
return { ok: true, skill: outcome.skill, body: outcome.body };
|
|
270
|
+
return { ok: false, error: "skill '" + name + "' rejected: " + outcome.reason };
|
|
271
|
+
}
|
|
272
|
+
return { ok: false, error: "unknown skill '" + name + "'" };
|
|
273
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process SSE bus (P0 skeleton; SDK-side, not an engine seam).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors src/main.rs:640-659 (envelope) and 871-896 (512-capacity broadcast
|
|
5
|
+
* with slow-client degradation). The `lagged` semantics are contract:
|
|
6
|
+
* a slow subscriber receives ONE status/lagged event and the stream continues;
|
|
7
|
+
* skipped events are NOT replayed.
|
|
8
|
+
*
|
|
9
|
+
* `subscribe(handler)` is PUSH delivery: each queued frame is handed to the
|
|
10
|
+
* handler on a microtask (W834 F02), so a caller that never awaits still has
|
|
11
|
+
* a bounded, non-reentrant queue behind it.
|
|
12
|
+
*
|
|
13
|
+
* Named SseBus (not EventBus): `EventBus` is the engine's typed plugin seam
|
|
14
|
+
* (see ./event-bus.ts, mirroring crates/core/src/event_bus.rs).
|
|
15
|
+
*/
|
|
16
|
+
import type { SseEnvelope, SseEventName, Statusline } from "./types.js";
|
|
17
|
+
export interface BusEvent {
|
|
18
|
+
kind: SseEventName;
|
|
19
|
+
data: SseEnvelope;
|
|
20
|
+
}
|
|
21
|
+
export declare const BUS_CAPACITY = 512;
|
|
22
|
+
export declare const LAGGED_HINT = "slow client, skipped events";
|
|
23
|
+
export interface Subscriber {
|
|
24
|
+
readonly id: number;
|
|
25
|
+
send(ev: BusEvent): void;
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
28
|
+
export interface SseBus {
|
|
29
|
+
emit(kind: SseEventName, turn: number, payload: Record<string, unknown>): BusEvent;
|
|
30
|
+
subscribe(handler: (ev: BusEvent) => void): Subscriber;
|
|
31
|
+
/** Current global sequence counter (next value to be handed out). */
|
|
32
|
+
seq(): number;
|
|
33
|
+
subscriberCount(): number;
|
|
34
|
+
}
|
|
35
|
+
export interface SseBusOptions {
|
|
36
|
+
capacity?: number;
|
|
37
|
+
statusline?: () => Statusline | Record<string, unknown>;
|
|
38
|
+
nextTurn?: () => number;
|
|
39
|
+
}
|
|
40
|
+
export declare function createSseBus(opts?: SseBusOptions): SseBus;
|
package/dist/sse-bus.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process SSE bus (P0 skeleton; SDK-side, not an engine seam).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors src/main.rs:640-659 (envelope) and 871-896 (512-capacity broadcast
|
|
5
|
+
* with slow-client degradation). The `lagged` semantics are contract:
|
|
6
|
+
* a slow subscriber receives ONE status/lagged event and the stream continues;
|
|
7
|
+
* skipped events are NOT replayed.
|
|
8
|
+
*
|
|
9
|
+
* `subscribe(handler)` is PUSH delivery: each queued frame is handed to the
|
|
10
|
+
* handler on a microtask (W834 F02), so a caller that never awaits still has
|
|
11
|
+
* a bounded, non-reentrant queue behind it.
|
|
12
|
+
*
|
|
13
|
+
* Named SseBus (not EventBus): `EventBus` is the engine's typed plugin seam
|
|
14
|
+
* (see ./event-bus.ts, mirroring crates/core/src/event_bus.rs).
|
|
15
|
+
*/
|
|
16
|
+
export const BUS_CAPACITY = 512;
|
|
17
|
+
export const LAGGED_HINT = "slow client, skipped events";
|
|
18
|
+
function laggedMarker(capacity, sub, turn, seq, opts) {
|
|
19
|
+
// Slow client: drop everything queued, then hand it the lagged marker.
|
|
20
|
+
sub.buffer = [];
|
|
21
|
+
sub.dropped += 1;
|
|
22
|
+
void capacity;
|
|
23
|
+
sub.buffer.push({
|
|
24
|
+
kind: "status",
|
|
25
|
+
data: {
|
|
26
|
+
v: 2,
|
|
27
|
+
session: null,
|
|
28
|
+
turn,
|
|
29
|
+
seq: seq,
|
|
30
|
+
payload: {
|
|
31
|
+
phase: "lagged",
|
|
32
|
+
hint: LAGGED_HINT,
|
|
33
|
+
statusline: opts.statusline ? opts.statusline() : {},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return seq + 1;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Queue one frame for delivery and drain on a microtask.
|
|
41
|
+
*
|
|
42
|
+
* W834 F02 (R3 batch A): `emit` used to stop at `sub.buffer.push`, so the
|
|
43
|
+
* handler passed to `subscribe` was never called. Delivery is deferred to a
|
|
44
|
+
* microtask on purpose: it is what keeps the 512-capacity LAGGED semantics
|
|
45
|
+
* observable (a synchronous drain would always empty the queue inside the same
|
|
46
|
+
* `emit`, and a slow client could never fall behind).
|
|
47
|
+
*/
|
|
48
|
+
function scheduleDrain(sub) {
|
|
49
|
+
if (sub.draining || sub.closed)
|
|
50
|
+
return;
|
|
51
|
+
sub.draining = true;
|
|
52
|
+
queueMicrotask(() => {
|
|
53
|
+
sub.draining = false;
|
|
54
|
+
if (sub.closed) {
|
|
55
|
+
sub.buffer = [];
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
while (sub.buffer.length > 0) {
|
|
59
|
+
const next = sub.buffer.shift();
|
|
60
|
+
if (next !== undefined)
|
|
61
|
+
sub.send(next);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function createSseBus(opts = {}) {
|
|
66
|
+
const capacity = opts.capacity ?? BUS_CAPACITY;
|
|
67
|
+
const subs = new Set();
|
|
68
|
+
let seq = 0;
|
|
69
|
+
let nextSubId = 1;
|
|
70
|
+
function emit(kind, turn, payload) {
|
|
71
|
+
const ev = { kind, data: { v: 2, session: null, turn, seq: seq++, payload } };
|
|
72
|
+
for (const sub of subs) {
|
|
73
|
+
if (sub.buffer.length >= capacity) {
|
|
74
|
+
// Slow client: drop everything queued, then hand it ONE lagged marker.
|
|
75
|
+
seq = laggedMarker(capacity, sub, turn, seq, opts);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
sub.buffer.push(ev);
|
|
79
|
+
}
|
|
80
|
+
scheduleDrain(sub);
|
|
81
|
+
}
|
|
82
|
+
return ev;
|
|
83
|
+
}
|
|
84
|
+
function subscribe(handler) {
|
|
85
|
+
const sub = {
|
|
86
|
+
id: nextSubId++,
|
|
87
|
+
buffer: [],
|
|
88
|
+
dropped: 0,
|
|
89
|
+
draining: false,
|
|
90
|
+
closed: false,
|
|
91
|
+
send(ev) {
|
|
92
|
+
if (!sub.closed)
|
|
93
|
+
handler(ev);
|
|
94
|
+
},
|
|
95
|
+
close() {
|
|
96
|
+
sub.closed = true;
|
|
97
|
+
sub.buffer = [];
|
|
98
|
+
subs.delete(sub);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
subs.add(sub);
|
|
102
|
+
return sub;
|
|
103
|
+
}
|
|
104
|
+
return { emit, subscribe, seq: () => seq, subscriberCount: () => subs.size };
|
|
105
|
+
}
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LLM request/stream seam payloads — ports of
|
|
3
|
+
* `crates/core/src/message.rs:65-153` (ModelRequest / StreamEvent / LlmError).
|
|
4
|
+
*
|
|
5
|
+
* `StreamEvent` is what a provider adapter yields: incremental deltas for the
|
|
6
|
+
* UI, then exactly one authoritative terminal event. Terminal variants never
|
|
7
|
+
* follow each other (`Done` after `Failed`/`Interrupted` is a contract
|
|
8
|
+
* violation), because the engine's five TurnOutcome states are derived from
|
|
9
|
+
* which terminal event arrived.
|
|
10
|
+
*
|
|
11
|
+
* A1 (W746): this module is the SINGLE source of the LLM vocabulary. Provider
|
|
12
|
+
* packages (`@celestea/llm`) re-export these symbols instead of redeclaring
|
|
13
|
+
* them, so `instanceof LlmError` and every structural type agree across
|
|
14
|
+
* packages. `LlmError` here carries the structured classification TS needs
|
|
15
|
+
* (see the class doc).
|
|
16
|
+
*/
|
|
17
|
+
import type { Message, Usage } from "./message.js";
|
|
18
|
+
import type { ToolSpec } from "./types.js";
|
|
19
|
+
/**
|
|
20
|
+
* `ModelRequest` — everything the model adapter needs for one call.
|
|
21
|
+
*
|
|
22
|
+
* A1 (W746): there is exactly ONE `ModelRequest` in the repo — `@celestea/llm`
|
|
23
|
+
* re-exports this one instead of declaring a structurally-similar copy. The
|
|
24
|
+
* shape is unchanged (the engine and the host both fill every field), and the
|
|
25
|
+
* provider's wire mapper reads it with the same "absent == empty" fallbacks it
|
|
26
|
+
* always had (`model` empty -> the configured model).
|
|
27
|
+
*/
|
|
28
|
+
export interface ModelRequest {
|
|
29
|
+
model: string;
|
|
30
|
+
system: string | null;
|
|
31
|
+
messages: Message[];
|
|
32
|
+
tools: ToolSpec[];
|
|
33
|
+
max_tokens: number | null;
|
|
34
|
+
temperature: number | null;
|
|
35
|
+
}
|
|
36
|
+
export type StreamEvent =
|
|
37
|
+
/** A final-answer text delta. */
|
|
38
|
+
{
|
|
39
|
+
kind: "text";
|
|
40
|
+
text: string;
|
|
41
|
+
}
|
|
42
|
+
/** A chain-of-thought / reasoning delta (never enters the model history). */
|
|
43
|
+
| {
|
|
44
|
+
kind: "thinking";
|
|
45
|
+
text: string;
|
|
46
|
+
}
|
|
47
|
+
/** Provider-reported usage for this response, just before the terminal event. */
|
|
48
|
+
| {
|
|
49
|
+
kind: "usage";
|
|
50
|
+
usage: Usage;
|
|
51
|
+
}
|
|
52
|
+
/** The single authoritative final message. */
|
|
53
|
+
| {
|
|
54
|
+
kind: "done";
|
|
55
|
+
message: Message;
|
|
56
|
+
}
|
|
57
|
+
/** The stream/generation broke mid-flight; terminal (no Done follows). */
|
|
58
|
+
| {
|
|
59
|
+
kind: "failed";
|
|
60
|
+
kindOf: "generate" | "stream";
|
|
61
|
+
message: string;
|
|
62
|
+
}
|
|
63
|
+
/** Torn stream without a terminal frame; terminal (no Done follows). */
|
|
64
|
+
| {
|
|
65
|
+
kind: "interrupted";
|
|
66
|
+
};
|
|
67
|
+
export declare const STREAM_EVENT_KINDS: readonly ["text", "thinking", "usage", "done", "failed", "interrupted"];
|
|
68
|
+
export type StreamEventKind = (typeof STREAM_EVENT_KINDS)[number];
|
|
69
|
+
/**
|
|
70
|
+
* The turn-outcome kind a provider failure maps to.
|
|
71
|
+
*
|
|
72
|
+
* "generate" = the pre-stream `generate()` call failed; "stream" = the stream
|
|
73
|
+
* broke mid-flight; "timeout" = a guard tripped (connect / response-header /
|
|
74
|
+
* SSE idle). The classification rides the `llm timeout:` message prefix;
|
|
75
|
+
* TS carries it as a field AS WELL (never instead of the text).
|
|
76
|
+
*/
|
|
77
|
+
export type LlmErrorKind = "generate" | "stream" | "timeout";
|
|
78
|
+
/** Which guard tripped when a failure was a timeout. */
|
|
79
|
+
export type TimeoutStage = "connect" | "response" | "idle";
|
|
80
|
+
/** Extra structured fields of `LlmError` (all optional; defaults are safe). */
|
|
81
|
+
export interface LlmErrorOptions {
|
|
82
|
+
isTimeout?: boolean;
|
|
83
|
+
timeoutStage?: TimeoutStage | null;
|
|
84
|
+
/** Status of the failed HTTP response; `null` = no response arrived. */
|
|
85
|
+
httpStatus?: number | null;
|
|
86
|
+
/** Whether retrying / switching target could plausibly help. */
|
|
87
|
+
retryable?: boolean;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `LlmError` — the provider-facing failure (`LlmError(String)` in the legacy engine).
|
|
91
|
+
*
|
|
92
|
+
* The semantics ride the canonical `llm timeout: …` message prefix; TS adds the
|
|
93
|
+
* machine-readable fields on top of the unchanged text:
|
|
94
|
+
* `kind` (the turn-outcome kind a caller should report), `isTimeout` +
|
|
95
|
+
* `timeoutStage`, `httpStatus` and `retryable` (the conservative default pair
|
|
96
|
+
* is `(null, false)`: a failure with no evidence of being transient is a
|
|
97
|
+
* local/configuration problem, not something to retry).
|
|
98
|
+
*/
|
|
99
|
+
export declare class LlmError extends Error {
|
|
100
|
+
/** Turn-outcome kind this failure maps to. */
|
|
101
|
+
readonly kind: LlmErrorKind;
|
|
102
|
+
/** True for any timeout; the message then carries the canonical prefix. */
|
|
103
|
+
readonly isTimeout: boolean;
|
|
104
|
+
/** Which guard tripped, when the failure was a timeout. */
|
|
105
|
+
readonly timeoutStage: TimeoutStage | null;
|
|
106
|
+
/** HTTP status of the failing response; `null` when none was received. */
|
|
107
|
+
readonly httpStatus: number | null;
|
|
108
|
+
/** True for transient causes (timeouts, transport, 408/425/429/5xx). */
|
|
109
|
+
readonly retryable: boolean;
|
|
110
|
+
constructor(message: string, kind?: LlmErrorKind, options?: LlmErrorOptions);
|
|
111
|
+
}
|
|
112
|
+
/** `LlmStream` — `Pin<Box<dyn Stream<Item = StreamEvent> + Send>>`. */
|
|
113
|
+
export type LlmStream = AsyncIterable<StreamEvent>;
|
|
114
|
+
/** True for the two terminal variants (no further event may follow). */
|
|
115
|
+
export declare function isTerminalStreamEvent(ev: StreamEvent): boolean;
|