@sigrun/sigrun 0.0.0-stage → 0.1.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/CHANGELOG.md +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command EXPANSION at admission (Task 2c, pi parity): `/skill:name
|
|
3
|
+
* [args]` and prompt-template `/name [args]` inputs are rewritten into
|
|
4
|
+
* the text the model actually sees, server-side in `submitOp` BEFORE
|
|
5
|
+
* content-part resolution — so the webui, the TUI,
|
|
6
|
+
* `host.conversations.submit` and the HTTP API behave identically, and
|
|
7
|
+
* job prompts can use templates too.
|
|
8
|
+
*
|
|
9
|
+
* Shapes (pi 1.3.0's, byte-for-byte):
|
|
10
|
+
*
|
|
11
|
+
* - `/skill:name args` → `<skill name="NAME" location="PATH">\nReferences
|
|
12
|
+
* are relative to DIR.\n\nBODY\n</skill>` plus `\n\nARGS` ONLY when
|
|
13
|
+
* arguments were given. An unknown skill passes through UNCHANGED
|
|
14
|
+
* (never an error — pi's behaviour). A `disable-model-invocation:
|
|
15
|
+
* true` skill is hidden from the prompt section but still expands
|
|
16
|
+
* here (`listSkills` returns it with the flag; the section filters).
|
|
17
|
+
* - `/template args` → the template body with `$1`/`$@`-family
|
|
18
|
+
* placeholders substituted (see `substituteArgs`).
|
|
19
|
+
*
|
|
20
|
+
* A `/name` that is a core or extension command keeps its existing
|
|
21
|
+
* meaning — expansion claims only `skill:*` and template names. At
|
|
22
|
+
* admission the reserved-name check simply passes such inputs through
|
|
23
|
+
* (the composer drives the endpoint); the CATALOG build additionally
|
|
24
|
+
* skips a colliding template with one warning (R6), and a template
|
|
25
|
+
* claiming the reserved `skill:` prefix is skipped the same way.
|
|
26
|
+
*
|
|
27
|
+
* The user entry carries what was typed: callers place the expanded
|
|
28
|
+
* text as the admitted input with
|
|
29
|
+
* `entry: {kind: "sigrun.user.expanded", data: {typed, command}}`, so
|
|
30
|
+
* the transcript shows the typed text (pi-durable `InputEntry`; the
|
|
31
|
+
* kind must not start with `pi.`).
|
|
32
|
+
*/
|
|
33
|
+
import { listSkills, } from "../skills/index.js";
|
|
34
|
+
import { discoverTemplates, } from "./templates.js";
|
|
35
|
+
/** The user entry kind an expanded input is placed as. */
|
|
36
|
+
export const EXPANDED_ENTRY_KIND = "sigrun.user.expanded";
|
|
37
|
+
/**
|
|
38
|
+
* A malformed command line the user typed (an unterminated quote in
|
|
39
|
+
* template arguments) — the one usage error the expansion path raises.
|
|
40
|
+
* Typed so the submit path can map it to a client 400 (the
|
|
41
|
+
* `invalid_input` shape) without string-matching messages, while any
|
|
42
|
+
* OTHER failure still surfaces as a genuine server error.
|
|
43
|
+
*/
|
|
44
|
+
export class CommandUsageError extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "CommandUsageError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse a command's raw argument text: whitespace-separated, single and
|
|
52
|
+
* double quotes group, no escape processing beyond a literal quote
|
|
53
|
+
* inside the other quote kind, no variable expansion, no globbing. An
|
|
54
|
+
* UNTERMINATED quote is a usage error (fail loud — never a silent
|
|
55
|
+
* swallow of half the input).
|
|
56
|
+
*/
|
|
57
|
+
export function parseCommandArgs(argsString) {
|
|
58
|
+
const args = [];
|
|
59
|
+
let current = "";
|
|
60
|
+
// Tracks whether a token has started, so quoted empty strings (""
|
|
61
|
+
// or '') are kept as arguments.
|
|
62
|
+
let hasToken = false;
|
|
63
|
+
let inQuote = null;
|
|
64
|
+
for (const char of argsString) {
|
|
65
|
+
if (inQuote !== null) {
|
|
66
|
+
if (char === inQuote) {
|
|
67
|
+
inQuote = null;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
current += char;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (char === '"' || char === "'") {
|
|
74
|
+
inQuote = char;
|
|
75
|
+
hasToken = true;
|
|
76
|
+
}
|
|
77
|
+
else if (/\s/.test(char)) {
|
|
78
|
+
if (hasToken) {
|
|
79
|
+
args.push(current);
|
|
80
|
+
current = "";
|
|
81
|
+
hasToken = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
current += char;
|
|
86
|
+
hasToken = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (inQuote !== null) {
|
|
90
|
+
throw new CommandUsageError(`unterminated quote in command arguments: ${argsString}`);
|
|
91
|
+
}
|
|
92
|
+
if (hasToken)
|
|
93
|
+
args.push(current);
|
|
94
|
+
return args;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Substitute argument placeholders in template content. Supported forms
|
|
98
|
+
* (pi parity): `$1`, `$2`, …; `$@` and `$ARGUMENTS` (all arguments,
|
|
99
|
+
* space-joined); `${N:-default}`; `${@:-default}`/`${ARGUMENTS:-default}`;
|
|
100
|
+
* `${@:N}` (from the Nth argument onwards); `${@:N:L}` (L arguments
|
|
101
|
+
* starting at the Nth). Replacement is single-pass: argument and
|
|
102
|
+
* default values containing `$…` patterns are NOT recursively
|
|
103
|
+
* substituted.
|
|
104
|
+
*/
|
|
105
|
+
export function substituteArgs(content, args) {
|
|
106
|
+
const allArgs = args.join(" ");
|
|
107
|
+
return content.replace(/\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, (_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple) => {
|
|
108
|
+
if (defaultTarget !== undefined) {
|
|
109
|
+
const value = defaultTarget === "@" || defaultTarget === "ARGUMENTS"
|
|
110
|
+
? allArgs
|
|
111
|
+
: args[Number.parseInt(defaultTarget, 10) - 1];
|
|
112
|
+
return value ? value : (defaultValue ?? "");
|
|
113
|
+
}
|
|
114
|
+
if (sliceStart !== undefined) {
|
|
115
|
+
// 1-indexed (bash convention: args start at 1); 0 is treated
|
|
116
|
+
// as 1.
|
|
117
|
+
let start = Number.parseInt(sliceStart, 10) - 1;
|
|
118
|
+
if (start < 0)
|
|
119
|
+
start = 0;
|
|
120
|
+
if (sliceLength !== undefined) {
|
|
121
|
+
const length = Number.parseInt(sliceLength, 10);
|
|
122
|
+
return args.slice(start, start + length).join(" ");
|
|
123
|
+
}
|
|
124
|
+
return args.slice(start).join(" ");
|
|
125
|
+
}
|
|
126
|
+
if (simple === "ARGUMENTS" || simple === "@")
|
|
127
|
+
return allArgs;
|
|
128
|
+
return args[Number.parseInt(simple ?? "0", 10) - 1] ?? "";
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Expand `/name args` against the given templates: a matching name
|
|
133
|
+
* parses the arguments (shell-quoted) and substitutes them into the
|
|
134
|
+
* template body; anything else passes through UNCHANGED.
|
|
135
|
+
*/
|
|
136
|
+
export function expandTemplate(text, templates) {
|
|
137
|
+
if (!text.startsWith("/"))
|
|
138
|
+
return text;
|
|
139
|
+
const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text);
|
|
140
|
+
if (match === null)
|
|
141
|
+
return text;
|
|
142
|
+
const template = templates.find((candidate) => candidate.name === match?.[1]);
|
|
143
|
+
if (template === undefined)
|
|
144
|
+
return text;
|
|
145
|
+
return substituteArgs(template.content, parseCommandArgs(match[2] ?? ""));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Expand `/skill:name [args]` against the given skills (pi's exact
|
|
149
|
+
* shape): the `<skill …>` block, plus a blank line and the trimmed
|
|
150
|
+
* arguments ONLY when arguments were given. An unknown skill — or any
|
|
151
|
+
* other text — passes through UNCHANGED.
|
|
152
|
+
*/
|
|
153
|
+
export function expandSkillCommand(text, skills) {
|
|
154
|
+
if (!text.startsWith("/skill:"))
|
|
155
|
+
return text;
|
|
156
|
+
const spaceIndex = text.indexOf(" ");
|
|
157
|
+
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
|
|
158
|
+
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
|
|
159
|
+
const skill = skills.find((candidate) => candidate.name === skillName);
|
|
160
|
+
if (skill === undefined)
|
|
161
|
+
return text;
|
|
162
|
+
const location = `${skill.dir}/SKILL.md`;
|
|
163
|
+
// Pi parity: the body is the frontmatter-stripped SKILL.md, trimmed —
|
|
164
|
+
// `</skill>` sits on its own line with no stray blank lines.
|
|
165
|
+
const body = skill.body.trim();
|
|
166
|
+
const block = `<skill name="${skill.name}" location="${location}">\n` +
|
|
167
|
+
`References are relative to ${skill.dir}.\n` +
|
|
168
|
+
`\n${body}\n` +
|
|
169
|
+
`</skill>`;
|
|
170
|
+
return args === "" ? block : `${block}\n\n${args}`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Drop templates whose names cannot be expansion commands: a name the
|
|
174
|
+
* full command set already claims (core or extension — one warning per
|
|
175
|
+
* template), and the reserved `skill:` namespace (the `/skill:` prefix
|
|
176
|
+
* is the skills channel, never a template's).
|
|
177
|
+
*/
|
|
178
|
+
export function expandableTemplates(templates, commandNames) {
|
|
179
|
+
const kept = [];
|
|
180
|
+
for (const template of templates) {
|
|
181
|
+
if (commandNames.has(template.name)) {
|
|
182
|
+
console.warn(`templates: skipping "${template.name}" (${template.dir}) — ` +
|
|
183
|
+
"the name is already a command; commands keep their meaning");
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (template.name.startsWith("skill:")) {
|
|
187
|
+
console.warn(`templates: skipping "${template.name}" (${template.dir}) — ` +
|
|
188
|
+
'the "skill:" prefix is reserved for /skill:name expansion');
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
kept.push(template);
|
|
192
|
+
}
|
|
193
|
+
return kept;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Expand one input's leading text part, when it starts with `/` and
|
|
197
|
+
* names a skill or an expandable template. Everything else — non-`/`
|
|
198
|
+
* text, a `/name` that is a command, an unknown skill or template —
|
|
199
|
+
* passes through untouched; the OTHER parts (attachments, further text
|
|
200
|
+
* parts) pass through in place regardless.
|
|
201
|
+
*
|
|
202
|
+
* Returns the (possibly rewritten) parts plus `typed`/`command` for the
|
|
203
|
+
* `sigrun.user.expanded` entry; `typed` absent means nothing expanded.
|
|
204
|
+
* An unterminated quote in a template's arguments throws (the usage
|
|
205
|
+
* error `parseCommandArgs` raises) — the submit path maps it to a
|
|
206
|
+
* client error.
|
|
207
|
+
*/
|
|
208
|
+
export async function expandInput(parts, expansion, env, context) {
|
|
209
|
+
// Only a LEADING text part starting with `/` is a command: the
|
|
210
|
+
// command is what the user typed first (attachments and later text
|
|
211
|
+
// parts pass through untouched, in place).
|
|
212
|
+
const textPart = parts[0];
|
|
213
|
+
if (textPart === undefined ||
|
|
214
|
+
textPart.type !== "text" ||
|
|
215
|
+
!textPart.text.startsWith("/")) {
|
|
216
|
+
return { parts };
|
|
217
|
+
}
|
|
218
|
+
const text = textPart.text;
|
|
219
|
+
// The command token: everything up to the first whitespace.
|
|
220
|
+
const spaceIndex = text.search(/\s/);
|
|
221
|
+
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
222
|
+
const reserved = expansion.commandNames();
|
|
223
|
+
if (reserved.has(commandName))
|
|
224
|
+
return { parts };
|
|
225
|
+
if (commandName.startsWith("skill:")) {
|
|
226
|
+
const skills = await listSkills(env, context, expansion.skills);
|
|
227
|
+
const expanded = expandSkillCommand(text, skills);
|
|
228
|
+
if (expanded === text)
|
|
229
|
+
return { parts };
|
|
230
|
+
return {
|
|
231
|
+
parts: parts.map((part) => (part === textPart ? { type: "text", text: expanded } : part)),
|
|
232
|
+
typed: text,
|
|
233
|
+
command: commandName,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const templates = expandableTemplates(await discoverTemplates(env, context, expansion.templates), reserved);
|
|
237
|
+
const expanded = expandTemplate(text, templates);
|
|
238
|
+
if (expanded === text)
|
|
239
|
+
return { parts };
|
|
240
|
+
return {
|
|
241
|
+
parts: parts.map((part) => (part === textPart ? { type: "text", text: expanded } : part)),
|
|
242
|
+
typed: text,
|
|
243
|
+
command: commandName,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-template discovery (Task 2c, pi parity) — the `/name` commands
|
|
3
|
+
* whose bodies are Markdown files. Mirrors the skills convention's
|
|
4
|
+
* three-root precedence EXACTLY (src/skills/index.ts):
|
|
5
|
+
*
|
|
6
|
+
* 1. the WORKSPACE root — `prompts/` under the conversation workspace,
|
|
7
|
+
* read through the conversation `ExecutionEnv`, so discovery follows
|
|
8
|
+
* the resolved workspace;
|
|
9
|
+
* 2. the USER root — `<configDir>/prompts/`, read from the host
|
|
10
|
+
* filesystem;
|
|
11
|
+
* 3. PACKAGE roots — each installed package's `prompts/` directory
|
|
12
|
+
* (attributed to the extension), read from the host filesystem.
|
|
13
|
+
*
|
|
14
|
+
* A name appearing in several roots keeps the HIGHER root's template
|
|
15
|
+
* (first-wins across roots in precedence order); within one root the
|
|
16
|
+
* filenames are unique by construction. Direct `.md` children only — a
|
|
17
|
+
* nested `prompts/sub/x.md` is ignored. Both lists are re-scanned LIVE
|
|
18
|
+
* (per `/v1/commands` request and per expansion — the same
|
|
19
|
+
* live-roots posture as the skills section), so no reload command.
|
|
20
|
+
*
|
|
21
|
+
* Template shape (pi parity): frontmatter `description` (else the first
|
|
22
|
+
* non-empty line of the body) and optional `argument-hint`; the body is
|
|
23
|
+
* everything after the frontmatter block. `expandTemplate` (src/commands/
|
|
24
|
+
* expand.ts) substitutes `$1`/`$@`-family placeholders in it.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
27
|
+
import { basename, dirname, join } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
/** The prompts directory name under each of the three roots. */
|
|
30
|
+
export const PROMPTS_DIRECTORY = "prompts";
|
|
31
|
+
const FRONTMATTER_DELIMITER = "---";
|
|
32
|
+
/**
|
|
33
|
+
* Split a leading `---`-delimited frontmatter block off `text`. The
|
|
34
|
+
* block must start at the very first line; the content is everything
|
|
35
|
+
* after the closing delimiter (leading newlines stripped, trailing
|
|
36
|
+
* whitespace kept — substitutions are byte-faithful to the file).
|
|
37
|
+
* Returns the unstripped text as content when no block is present.
|
|
38
|
+
*/
|
|
39
|
+
function parseTemplateFile(text) {
|
|
40
|
+
const lines = text.split(/\r?\n/);
|
|
41
|
+
if (lines[0]?.trim() !== FRONTMATTER_DELIMITER) {
|
|
42
|
+
return { content: text.trim() };
|
|
43
|
+
}
|
|
44
|
+
let close = -1;
|
|
45
|
+
for (let i = 1; i < lines.length; i++) {
|
|
46
|
+
if (lines[i]?.trim() === FRONTMATTER_DELIMITER) {
|
|
47
|
+
close = i;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (close === -1) {
|
|
52
|
+
// An unterminated block is not frontmatter — the whole file is
|
|
53
|
+
// body (the skills parser treats the file as invalid instead;
|
|
54
|
+
// templates have no required fields, so the honest read is body).
|
|
55
|
+
return { content: text };
|
|
56
|
+
}
|
|
57
|
+
const fields = {};
|
|
58
|
+
for (const line of lines.slice(1, close)) {
|
|
59
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
60
|
+
if (match === null)
|
|
61
|
+
continue;
|
|
62
|
+
const value = match[2].trim();
|
|
63
|
+
if (value.length >= 2 &&
|
|
64
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
65
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
66
|
+
fields[match[1]] = value.slice(1, -1);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
fields[match[1]] = value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
description: fields.description === "" ? undefined : fields.description,
|
|
74
|
+
argsHint: fields["argument-hint"] === "" || fields["argument-hint"] === undefined
|
|
75
|
+
? undefined
|
|
76
|
+
: fields["argument-hint"],
|
|
77
|
+
// Pi parity: the substitution body is the frontmatter-stripped
|
|
78
|
+
// file, TRIMMED — `/name args` expands to the body with no leading
|
|
79
|
+
// or trailing blank lines.
|
|
80
|
+
content: lines.slice(close + 1).join("\n").trim(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Build one template from a file's text; `undefined` when it is not `.md`. */
|
|
84
|
+
function templateFromFile(file, text, dir, extension) {
|
|
85
|
+
const parsed = parseTemplateFile(text);
|
|
86
|
+
let description = parsed.description;
|
|
87
|
+
if (description === undefined || description === "") {
|
|
88
|
+
const firstLine = parsed.content
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.find((line) => line.trim() !== "");
|
|
91
|
+
description = firstLine?.trim();
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
name: basename(file).replace(/\.md$/, ""),
|
|
95
|
+
description: description ?? "",
|
|
96
|
+
...(parsed.argsHint === undefined ? {} : { argsHint: parsed.argsHint }),
|
|
97
|
+
content: parsed.content,
|
|
98
|
+
dir,
|
|
99
|
+
...(extension === undefined ? {} : { extension }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Name comparison (plain code-point order, locale-independent). */
|
|
103
|
+
function byTemplateName(a, b) {
|
|
104
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Read the direct `.md` children of one prompts directory through the
|
|
108
|
+
* conversation `ExecutionEnv` (the workspace root). An absent directory
|
|
109
|
+
* is the designed empty case; any other failure fails loud. Nested
|
|
110
|
+
* directories and non-`.md` files are ignored.
|
|
111
|
+
*/
|
|
112
|
+
async function scanEnvRoot(env, context) {
|
|
113
|
+
const listing = await env.listDir(PROMPTS_DIRECTORY, context);
|
|
114
|
+
if (!listing.ok) {
|
|
115
|
+
if (listing.error.code === "not_found")
|
|
116
|
+
return [];
|
|
117
|
+
throw new Error(`Failed to list the ${PROMPTS_DIRECTORY}/ directory: ${listing.error.message}`, { cause: listing.error });
|
|
118
|
+
}
|
|
119
|
+
const templates = [];
|
|
120
|
+
for (const entry of [...listing.value].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
121
|
+
if (entry.kind !== "file" || !entry.name.endsWith(".md"))
|
|
122
|
+
continue;
|
|
123
|
+
const read = await env.readTextFile(join(PROMPTS_DIRECTORY, entry.name), context);
|
|
124
|
+
if (!read.ok) {
|
|
125
|
+
if (read.error.code === "not_found")
|
|
126
|
+
continue;
|
|
127
|
+
throw new Error(`Failed to read ${PROMPTS_DIRECTORY}/${entry.name}: ${read.error.message}`, { cause: read.error });
|
|
128
|
+
}
|
|
129
|
+
templates.push(templateFromFile(`${PROMPTS_DIRECTORY}/${entry.name}`, read.value, PROMPTS_DIRECTORY));
|
|
130
|
+
}
|
|
131
|
+
return templates;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Read the direct `.md` children of ONE host-filesystem prompts
|
|
135
|
+
* directory (the user dir or a package's). Same rules as the workspace
|
|
136
|
+
* root; absent roots are the designed empty case. Reads synchronously —
|
|
137
|
+
* the root is host-local by definition (the skills module's posture).
|
|
138
|
+
*/
|
|
139
|
+
function scanFsRoot(root, extension) {
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
143
|
+
}
|
|
144
|
+
catch (cause) {
|
|
145
|
+
if (cause?.code === "ENOENT")
|
|
146
|
+
return [];
|
|
147
|
+
throw new Error(`Failed to list the prompts root ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
148
|
+
}
|
|
149
|
+
const templates = [];
|
|
150
|
+
for (const entry of [...entries].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
151
|
+
const file = join(root, entry.name);
|
|
152
|
+
// A symlink to a file counts (pi parity); a broken one is skipped.
|
|
153
|
+
let isFile = entry.isFile();
|
|
154
|
+
if (entry.isSymbolicLink()) {
|
|
155
|
+
try {
|
|
156
|
+
isFile = statSync(file).isFile();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!isFile || !entry.name.endsWith(".md"))
|
|
163
|
+
continue;
|
|
164
|
+
let text;
|
|
165
|
+
try {
|
|
166
|
+
text = readFileSync(file, "utf8");
|
|
167
|
+
}
|
|
168
|
+
catch (cause) {
|
|
169
|
+
throw new Error(`Failed to read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
170
|
+
}
|
|
171
|
+
templates.push(templateFromFile(file, text, root, extension));
|
|
172
|
+
}
|
|
173
|
+
return templates;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Discover the templates across the three roots in precedence order
|
|
177
|
+
* (workspace > user dir > packages, packages in install order),
|
|
178
|
+
* first-wins on the NAME. A name lost to a higher root logs
|
|
179
|
+
* `templates: "<name>" from <lower root> shadowed by <higher root>`
|
|
180
|
+
* (the skills convention's shape).
|
|
181
|
+
*/
|
|
182
|
+
export async function discoverTemplates(env, context, opts) {
|
|
183
|
+
const byName = new Map();
|
|
184
|
+
/**
|
|
185
|
+
* First-wins merge with a shadow log. `root` names the root the
|
|
186
|
+
* templates came from ("workspace", "user dir", "package");
|
|
187
|
+
* `extension` names the contributing package for package roots.
|
|
188
|
+
*/
|
|
189
|
+
const merge = (templates, root, extension) => {
|
|
190
|
+
for (const template of templates) {
|
|
191
|
+
const existing = byName.get(template.name);
|
|
192
|
+
if (existing !== undefined) {
|
|
193
|
+
const existingRoot = existing.extension ?? "a higher root";
|
|
194
|
+
console.warn(`templates: "${template.name}" from ${extension ?? root} shadowed by ${existingRoot}`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
byName.set(template.name, template);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
if (env !== undefined) {
|
|
201
|
+
merge(await scanEnvRoot(env, context), "workspace");
|
|
202
|
+
}
|
|
203
|
+
merge(scanFsRoot(opts.userDir), "user dir");
|
|
204
|
+
for (const root of opts.packageRoots()) {
|
|
205
|
+
merge(scanFsRoot(root.dir, root.extension), "package", root.extension);
|
|
206
|
+
}
|
|
207
|
+
return [...byName.values()].sort(byTemplateName);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The package prompt roots for one boot's loaded entries: each absolute
|
|
211
|
+
* `file://` entry's package root (the nearest `package.json` at or above
|
|
212
|
+
* the entry's directory) that SHIPS a `prompts/` directory, attributed
|
|
213
|
+
* to the package's manifest name (falling back to the directory name).
|
|
214
|
+
* Bare specifiers (no location) and packages without `prompts/`
|
|
215
|
+
* contribute nothing. The loader's skill dirs stay skills-only; this
|
|
216
|
+
* derivation exists because the loader has no fs coupling by contract
|
|
217
|
+
* and the module dir is only resolvable from the entry URL.
|
|
218
|
+
*/
|
|
219
|
+
export function packagePromptRootsOf(entries) {
|
|
220
|
+
const roots = [];
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
let entryPath;
|
|
223
|
+
if (entry.startsWith("file://")) {
|
|
224
|
+
try {
|
|
225
|
+
entryPath = fileURLToPath(entry);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (entry.startsWith("/")) {
|
|
232
|
+
entryPath = entry;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// The nearest package.json at or above the entry (the loader's
|
|
238
|
+
// bounded-walk shape): the package root owning the entry.
|
|
239
|
+
let current = dirname(entryPath);
|
|
240
|
+
let packageRoot;
|
|
241
|
+
for (let hops = 0; hops < 6; hops++) {
|
|
242
|
+
if (existsSync(join(current, "package.json"))) {
|
|
243
|
+
packageRoot = current;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
const parent = dirname(current);
|
|
247
|
+
if (parent === current)
|
|
248
|
+
break;
|
|
249
|
+
current = parent;
|
|
250
|
+
}
|
|
251
|
+
if (packageRoot === undefined)
|
|
252
|
+
continue;
|
|
253
|
+
const promptsDir = join(packageRoot, PROMPTS_DIRECTORY);
|
|
254
|
+
if (!existsSync(promptsDir))
|
|
255
|
+
continue;
|
|
256
|
+
let name;
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
259
|
+
if (typeof parsed.name === "string" && parsed.name !== "") {
|
|
260
|
+
name = parsed.name;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
name = undefined;
|
|
265
|
+
}
|
|
266
|
+
roots.push({
|
|
267
|
+
extension: name ?? basename(packageRoot),
|
|
268
|
+
dir: promptsDir,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return roots;
|
|
272
|
+
}
|