@sigrun/sigrun 0.0.0-stage → 0.1.0
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 +204 -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 +90 -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,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skills convention (spec §3.2 + §9.4) — CORE-OWNED, not an extension
|
|
3
|
+
* package.
|
|
4
|
+
*
|
|
5
|
+
* v1 discovered ONE root: `skills/<dir>/SKILL.md` in the conversation
|
|
6
|
+
* workspace. W5 Task 4 (spec §9.4) grows discovery to THREE roots, with
|
|
7
|
+
* first-wins precedence in this order:
|
|
8
|
+
*
|
|
9
|
+
* 1. the WORKSPACE root — `skills/` under the conversation workspace, read
|
|
10
|
+
* through the conversation `ExecutionEnv` (the section via
|
|
11
|
+
* `PromptInput.env`, the tool via `ToolExecutionApi.env`), so discovery
|
|
12
|
+
* follows the resolved workspace;
|
|
13
|
+
* 2. the USER root — `<configDir>/skills/` (a named config-root entry),
|
|
14
|
+
* read from the host filesystem;
|
|
15
|
+
* 3. PACKAGE roots — skill directories shipped by extension packages
|
|
16
|
+
* (manifest `sigrun.skills`/`pi.skills` dirs and factory-result `skills`
|
|
17
|
+
* dirs, collected by the loader in install order), read from the host
|
|
18
|
+
* filesystem.
|
|
19
|
+
*
|
|
20
|
+
* A name that appears in several roots keeps the HIGHER root's skill and
|
|
21
|
+
* logs `skills: "<name>" from <lower root> shadowed by <higher root>`.
|
|
22
|
+
* `load_skill(name)` returns the WINNING skill's body, read from its own
|
|
23
|
+
* root — the env for workspace skills, the filesystem for the others.
|
|
24
|
+
* User/package skills belong to the deployment; workspace skills belong to
|
|
25
|
+
* the workspace (under a future remote workspace the two sources differ in
|
|
26
|
+
* location — intended).
|
|
27
|
+
*
|
|
28
|
+
* v1 semantics (spec §3.2, unchanged per root):
|
|
29
|
+
*
|
|
30
|
+
* - Discovery: `<dir>/SKILL.md` only; other files ignored.
|
|
31
|
+
* - Frontmatter `name` and `description` required (single-line values,
|
|
32
|
+
* matching quotes stripped); `disable-model-invocation: true` hides the
|
|
33
|
+
* skill from the model entirely — absent from the section, unknown to
|
|
34
|
+
* `load_skill` (pi's convention); all other fields ignored. A SKILL.md
|
|
35
|
+
* missing either required field — or the `---` block entirely — is
|
|
36
|
+
* skipped with a log.
|
|
37
|
+
* - Within one root, duplicate names resolve first-wins (the earlier
|
|
38
|
+
* directory in sorted order) with a log; across roots, the precedence
|
|
39
|
+
* order above decides.
|
|
40
|
+
* - The section lists `- name: description (path)` per skill, capped at
|
|
41
|
+
* `sectionMaxBytes()` (default 4KB, read LIVE per render — boot wires
|
|
42
|
+
* the settings store) with a truncation notice when capped; zero skills
|
|
43
|
+
* render no section.
|
|
44
|
+
* - `load_skill(name)` returns the SKILL.md body (everything after the
|
|
45
|
+
* frontmatter block); an unknown name is an explicit error listing the
|
|
46
|
+
* available skills.
|
|
47
|
+
*
|
|
48
|
+
* `listSkills` (Task 2c) is the `/skill:name`-facing list: the SAME
|
|
49
|
+
* three-root discovery but WITHOUT the model-visibility filter — a
|
|
50
|
+
* `disable-model-invocation: true` skill is hidden from the section and
|
|
51
|
+
* unknown to `load_skill` yet still expands through `/skill:name`
|
|
52
|
+
* (pi's convention). It carries the flag so the section keeps
|
|
53
|
+
* filtering; nothing about the section's behaviour changes.
|
|
54
|
+
* Built through `createSkillsExtension(opts)` so boot can wire the three
|
|
55
|
+
* roots; it still installs as the core extension named `sigrun.skills`
|
|
56
|
+
* (the reserved-section owner is unchanged) and never goes through the
|
|
57
|
+
* extension loader.
|
|
58
|
+
*/
|
|
59
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
60
|
+
import { defineExtension, defineTool, section, } from "@earendil-works/pi-durable";
|
|
61
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
62
|
+
import { join } from "node:path";
|
|
63
|
+
/** The section key the skills list renders under. */
|
|
64
|
+
export const SKILLS_SECTION_KEY = "skills";
|
|
65
|
+
/** Total rendered-section budget (spec §3.2: 4KB, notice included). */
|
|
66
|
+
export const SKILLS_SECTION_LIMIT_BYTES = 4096;
|
|
67
|
+
const SKILLS_DIRECTORY = "skills";
|
|
68
|
+
const SKILL_FILE = "SKILL.md";
|
|
69
|
+
const FRONTMATTER_DELIMITER = "---";
|
|
70
|
+
const SKILLS_SECTION_HEADER = "Workspace skills. Call the load_skill tool with a skill's name to read its full instructions; each entry shows the skill's path, so you can also read its SKILL.md directly.";
|
|
71
|
+
/** The three discovery roots, in precedence order (§9.4). */
|
|
72
|
+
const ROOT_PRECEDENCE = ["workspace", "user dir", "package"];
|
|
73
|
+
const encoder = new TextEncoder();
|
|
74
|
+
/** UTF-8 byte length — the unit the section cap is measured in. */
|
|
75
|
+
function byteLength(text) {
|
|
76
|
+
return encoder.encode(text).length;
|
|
77
|
+
}
|
|
78
|
+
/** Strip one matching pair of surrounding quotes, if present. */
|
|
79
|
+
function unquote(value) {
|
|
80
|
+
if (value.length >= 2) {
|
|
81
|
+
const first = value[0];
|
|
82
|
+
const last = value[value.length - 1];
|
|
83
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
84
|
+
return value.slice(1, -1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Parse a SKILL.md: the frontmatter must be a `---`-delimited block of
|
|
91
|
+
* `key: value` lines at the very top; the body is everything after the
|
|
92
|
+
* closing delimiter. Returns `undefined` when the block is absent or never
|
|
93
|
+
* closes (neither required field could have parsed).
|
|
94
|
+
*/
|
|
95
|
+
function parseSkillFile(text) {
|
|
96
|
+
const lines = text.split(/\r?\n/);
|
|
97
|
+
if (lines[0]?.trim() !== FRONTMATTER_DELIMITER)
|
|
98
|
+
return undefined;
|
|
99
|
+
let close = -1;
|
|
100
|
+
for (let i = 1; i < lines.length; i++) {
|
|
101
|
+
if (lines[i]?.trim() === FRONTMATTER_DELIMITER) {
|
|
102
|
+
close = i;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (close === -1)
|
|
107
|
+
return undefined;
|
|
108
|
+
const fields = {};
|
|
109
|
+
for (const line of lines.slice(1, close)) {
|
|
110
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
111
|
+
if (match === null)
|
|
112
|
+
continue;
|
|
113
|
+
const value = unquote(match[2].trim());
|
|
114
|
+
if (value !== "")
|
|
115
|
+
fields[match[1]] = value;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
name: fields.name,
|
|
119
|
+
description: fields.description,
|
|
120
|
+
disableModelInvocation: fields["disable-model-invocation"] === "true",
|
|
121
|
+
body: lines
|
|
122
|
+
.slice(close + 1)
|
|
123
|
+
.join("\n")
|
|
124
|
+
.replace(/^\r?\n/, ""),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Parse one read SKILL.md into a skill, applying the shared skip/hide
|
|
129
|
+
* rules with a log. `displayDir` is the path named in the logs and the
|
|
130
|
+
* section entry; `fileLabel` names the file in skip logs.
|
|
131
|
+
*/
|
|
132
|
+
function skillFromFile(text, displayDir, fileLabel) {
|
|
133
|
+
const parsed = parseSkillFile(text);
|
|
134
|
+
if (parsed === undefined) {
|
|
135
|
+
console.warn(`skills: skipping ${fileLabel} (the frontmatter block is required)`);
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
if (parsed.name === undefined) {
|
|
139
|
+
console.warn(`skills: skipping ${fileLabel} (the "name" field is required)`);
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
if (parsed.description === undefined) {
|
|
143
|
+
console.warn(`skills: skipping ${fileLabel} (the "description" field is required)`);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
name: parsed.name,
|
|
148
|
+
description: parsed.description,
|
|
149
|
+
body: parsed.body,
|
|
150
|
+
dir: displayDir,
|
|
151
|
+
disableModelInvocation: parsed.disableModelInvocation,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Directory-name comparison (plain code-point order, locale-independent). */
|
|
155
|
+
function byDirectoryName(a, b) {
|
|
156
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
157
|
+
}
|
|
158
|
+
/** Skill-name comparison (plain code-point order, locale-independent). */
|
|
159
|
+
function bySkillName(a, b) {
|
|
160
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
161
|
+
}
|
|
162
|
+
/** Read and parse one skill directory's SKILL.md; `undefined` = not a skill. */
|
|
163
|
+
async function readSkill(env, dir, context) {
|
|
164
|
+
const relative = `${SKILLS_DIRECTORY}/${dir.name}/${SKILL_FILE}`;
|
|
165
|
+
const joined = await env.joinPath([SKILLS_DIRECTORY, dir.name, SKILL_FILE], context);
|
|
166
|
+
if (!joined.ok) {
|
|
167
|
+
throw new Error(`Failed to build path for ${relative}: ${joined.error.message}`, {
|
|
168
|
+
cause: joined.error,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const read = await env.readTextFile(joined.value, context);
|
|
172
|
+
if (!read.ok) {
|
|
173
|
+
if (read.error.code === "not_found") {
|
|
174
|
+
// A directory without SKILL.md is simply not a skill (v1 ignores
|
|
175
|
+
// every other file in a skill directory).
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Failed to read ${relative}: ${read.error.message}`, {
|
|
179
|
+
cause: read.error,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return skillFromFile(read.value, `${SKILLS_DIRECTORY}/${dir.name}`, relative);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Scan the workspace's skills through the conversation `ExecutionEnv`.
|
|
186
|
+
* An absent `skills/` directory is the designed empty case; any other read
|
|
187
|
+
* failure fails loud. Directories are visited in sorted name order so
|
|
188
|
+
* duplicate frontmatter names resolve deterministically first-wins.
|
|
189
|
+
* `includeHidden` (the `listSkills` path): a skill with
|
|
190
|
+
* `disable-model-invocation: true` is KEPT in the list carrying the
|
|
191
|
+
* flag; the default (`false`, the section and `load_skill` path) hides
|
|
192
|
+
* it from the model entirely — it never reaches the section or the
|
|
193
|
+
* tool.
|
|
194
|
+
*/
|
|
195
|
+
export async function scanSkills(env, context, opts) {
|
|
196
|
+
const listing = await env.listDir(SKILLS_DIRECTORY, context);
|
|
197
|
+
if (!listing.ok) {
|
|
198
|
+
if (listing.error.code === "not_found")
|
|
199
|
+
return [];
|
|
200
|
+
throw new Error(`Failed to list the ${SKILLS_DIRECTORY}/ directory: ${listing.error.message}`, { cause: listing.error });
|
|
201
|
+
}
|
|
202
|
+
const directories = listing.value
|
|
203
|
+
.filter((entry) => entry.kind === "directory")
|
|
204
|
+
.sort(byDirectoryName);
|
|
205
|
+
const byName = new Map();
|
|
206
|
+
for (const entry of directories) {
|
|
207
|
+
const skill = await readSkill(env, entry, context);
|
|
208
|
+
if (skill === undefined)
|
|
209
|
+
continue;
|
|
210
|
+
if (skill.disableModelInvocation && opts?.includeHidden !== true) {
|
|
211
|
+
console.warn(`skills: hiding ${skill.dir} (disable-model-invocation)`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!byName.has(skill.name)) {
|
|
215
|
+
byName.set(skill.name, skill);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const winner = byName.get(skill.name);
|
|
219
|
+
console.warn(`skills: duplicate name "${skill.name}" (${skill.dir} ignored; ${winner.dir} wins)`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return [...byName.values()].sort(bySkillName);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Scan ONE filesystem root (the user dir or a package's skills dir).
|
|
226
|
+
* Same rules as the workspace root — `SKILL.md` only, sorted directory
|
|
227
|
+
* order, first-wins within the root — read synchronously through
|
|
228
|
+
* `node:fs` (the root is host-local by definition). An absent root is
|
|
229
|
+
* the designed empty case. `includeHidden` keeps
|
|
230
|
+
* `disable-model-invocation: true` skills (the `listSkills` path); the
|
|
231
|
+
* default hides them with a log, exactly like the workspace root.
|
|
232
|
+
*/
|
|
233
|
+
function scanFsRoot(root, opts) {
|
|
234
|
+
let entries;
|
|
235
|
+
try {
|
|
236
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
237
|
+
}
|
|
238
|
+
catch (cause) {
|
|
239
|
+
if (cause?.code === "ENOENT")
|
|
240
|
+
return [];
|
|
241
|
+
throw new Error(`Failed to list the skills root ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
242
|
+
}
|
|
243
|
+
const byName = new Map();
|
|
244
|
+
const directories = [...entries]
|
|
245
|
+
.filter((entry) => entry.isDirectory())
|
|
246
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
247
|
+
for (const entry of directories) {
|
|
248
|
+
const file = join(root, entry.name, SKILL_FILE);
|
|
249
|
+
let text;
|
|
250
|
+
try {
|
|
251
|
+
text = readFileSync(file, "utf8");
|
|
252
|
+
}
|
|
253
|
+
catch (cause) {
|
|
254
|
+
if (cause?.code === "ENOENT") {
|
|
255
|
+
// A directory without SKILL.md is simply not a skill.
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
throw new Error(`Failed to read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
259
|
+
}
|
|
260
|
+
const skill = skillFromFile(text, join(root, entry.name), file);
|
|
261
|
+
if (skill === undefined)
|
|
262
|
+
continue;
|
|
263
|
+
if (skill.disableModelInvocation && opts?.includeHidden !== true) {
|
|
264
|
+
console.warn(`skills: hiding ${skill.dir} (disable-model-invocation)`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!byName.has(skill.name)) {
|
|
268
|
+
byName.set(skill.name, skill);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const winner = byName.get(skill.name);
|
|
272
|
+
console.warn(`skills: duplicate name "${skill.name}" (${skill.dir} ignored; ${winner.dir} wins)`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return [...byName.values()].sort(bySkillName);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The §9.4 root label for a collision log: "workspace", "user dir", or
|
|
279
|
+
* the contributing package's extension name (packages are attributed).
|
|
280
|
+
*/
|
|
281
|
+
function rootLabelOf(root, extension) {
|
|
282
|
+
if (root === "package" && extension !== undefined)
|
|
283
|
+
return extension;
|
|
284
|
+
return root;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Merge the three roots in precedence order (workspace > user > packages,
|
|
288
|
+
* packages in install order): first-wins on the skill NAME, every loss
|
|
289
|
+
* logged as `skills: "<name>" from <lower root> shadowed by <higher root>`.
|
|
290
|
+
* `includeHidden` threads to each root scan (the `listSkills` path).
|
|
291
|
+
*/
|
|
292
|
+
export async function discoverSkills(env, context, opts, options) {
|
|
293
|
+
const byName = new Map();
|
|
294
|
+
const merge = (skills, root, extension) => {
|
|
295
|
+
for (const skill of skills) {
|
|
296
|
+
const existing = byName.get(skill.name);
|
|
297
|
+
if (existing === undefined) {
|
|
298
|
+
byName.set(skill.name, { skill, root, extension });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const higher = ROOT_PRECEDENCE.indexOf(root);
|
|
302
|
+
const lower = ROOT_PRECEDENCE.indexOf(existing.root);
|
|
303
|
+
if (higher < lower) {
|
|
304
|
+
// The new root outranks the recorded one — it wins, and the
|
|
305
|
+
// loser is logged.
|
|
306
|
+
byName.set(skill.name, { skill, root, extension });
|
|
307
|
+
console.warn(`skills: "${skill.name}" from ${rootLabelOf(existing.root, existing.extension)} shadowed by ${rootLabelOf(root, extension)}`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
console.warn(`skills: "${skill.name}" from ${rootLabelOf(root, extension)} shadowed by ${rootLabelOf(existing.root, existing.extension)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
if (env !== undefined) {
|
|
315
|
+
merge(await scanSkills(env, context, options), "workspace");
|
|
316
|
+
}
|
|
317
|
+
merge(scanFsRoot(opts.userDir, options), "user dir");
|
|
318
|
+
for (const root of opts.packageRoots()) {
|
|
319
|
+
merge(scanFsRoot(root.dir, options), "package", root.extension);
|
|
320
|
+
}
|
|
321
|
+
return [...byName.values()].map((entry) => entry.skill).sort(bySkillName);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The `/skill:name`-facing skill list (Task 2c): the same three-root
|
|
325
|
+
* discovery as `discoverSkills`, but `disable-model-invocation: true`
|
|
326
|
+
* skills are INCLUDED (carrying the flag) — `/skill:name` works for
|
|
327
|
+
* them while the section and `load_skill` keep them hidden. Read LIVE
|
|
328
|
+
* per call (the same posture as the section's per-render scan).
|
|
329
|
+
*/
|
|
330
|
+
export async function listSkills(env, context, opts) {
|
|
331
|
+
return discoverSkills(env, context, opts, { includeHidden: true });
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Assemble the section text: the header plus one `- name: description
|
|
335
|
+
* (path)` line per skill, capped at `limitBytes` bytes including the
|
|
336
|
+
* truncation notice. The path lets the model read the SKILL.md directly
|
|
337
|
+
* (and resolve bundled references) via the read tool, complementing
|
|
338
|
+
* `load_skill`. Whole lines drop from the end until the text fits; the
|
|
339
|
+
* notice names how many skills were cut. When even the header cannot
|
|
340
|
+
* fit, the NOTICE alone renders — the model learns skills exist and are
|
|
341
|
+
* reachable through `load_skill`, which is the section's whole point at
|
|
342
|
+
* that size. Zero skills render no section at all.
|
|
343
|
+
*/
|
|
344
|
+
export function renderSkillList(skills, limitBytes) {
|
|
345
|
+
// `disable-model-invocation: true` never reaches the section — the
|
|
346
|
+
// filter is the section's own (a hidden skill passed in through the
|
|
347
|
+
// `/skill:name` list is still invisible here).
|
|
348
|
+
const visible = skills.filter((skill) => !skill.disableModelInvocation);
|
|
349
|
+
if (visible.length === 0)
|
|
350
|
+
return undefined;
|
|
351
|
+
const header = `${SKILLS_SECTION_HEADER}\n\n`;
|
|
352
|
+
const lines = visible.map((skill) => `- ${skill.name}: ${skill.description} (${skill.dir})`);
|
|
353
|
+
const full = header + lines.join("\n");
|
|
354
|
+
if (byteLength(full) <= limitBytes)
|
|
355
|
+
return full;
|
|
356
|
+
let kept = lines.length - 1;
|
|
357
|
+
while (kept >= 0) {
|
|
358
|
+
const dropped = lines.length - kept;
|
|
359
|
+
const notice = `\n(skill list truncated: ${dropped} more not shown)`;
|
|
360
|
+
const body = lines.slice(0, kept).join("\n");
|
|
361
|
+
const text = header + body + notice;
|
|
362
|
+
if (byteLength(text) <= limitBytes)
|
|
363
|
+
return text;
|
|
364
|
+
kept -= 1;
|
|
365
|
+
}
|
|
366
|
+
// Not even the header fits: the notice alone (the smallest honest
|
|
367
|
+
// rendering — it says skills exist and how many were cut).
|
|
368
|
+
return `(skill list truncated: ${lines.length} more not shown — call load_skill by name)`;
|
|
369
|
+
}
|
|
370
|
+
/** Section render: discover all three roots, list the winning skills. */
|
|
371
|
+
async function renderSkills(input, context, opts) {
|
|
372
|
+
const skills = await discoverSkills(input.env, context, opts);
|
|
373
|
+
return renderSkillList(skills, opts.sectionMaxBytes());
|
|
374
|
+
}
|
|
375
|
+
/** An explicit tool error: text content the model can read and recover from. */
|
|
376
|
+
function skillError(message) {
|
|
377
|
+
return { output: [{ type: "text", text: message }], isError: true };
|
|
378
|
+
}
|
|
379
|
+
/** Build the `load_skill` tool against the same three-root discovery. */
|
|
380
|
+
function buildLoadSkillTool(opts) {
|
|
381
|
+
return defineTool({
|
|
382
|
+
name: "load_skill",
|
|
383
|
+
description: "Load one skill's full instructions by name, as listed in the skills section.",
|
|
384
|
+
parameters: Type.Object({
|
|
385
|
+
name: Type.String({
|
|
386
|
+
description: "The skill name exactly as listed in the skills section.",
|
|
387
|
+
}),
|
|
388
|
+
}),
|
|
389
|
+
async execute(args, api, context) {
|
|
390
|
+
const skills = await discoverSkills(api.env, context, opts);
|
|
391
|
+
const skill = skills.find((candidate) => candidate.name === args.name);
|
|
392
|
+
if (skill === undefined) {
|
|
393
|
+
if (skills.length === 0) {
|
|
394
|
+
return skillError(`load_skill: unknown skill "${args.name}"; no skills are installed.`);
|
|
395
|
+
}
|
|
396
|
+
const available = skills.map((candidate) => candidate.name).join(", ");
|
|
397
|
+
return skillError(`load_skill: unknown skill "${args.name}"; available skills: ${available}`);
|
|
398
|
+
}
|
|
399
|
+
api.output(skill.body);
|
|
400
|
+
return {};
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Build the skills extension (core-owned): one prompt section plus one
|
|
406
|
+
* tool over the three-root discovery. `openHarness` installs the result
|
|
407
|
+
* unconditionally before `Harness.open`, under the unchanged core name
|
|
408
|
+
* `sigrun.skills`.
|
|
409
|
+
*/
|
|
410
|
+
export function createSkillsExtension(opts) {
|
|
411
|
+
return defineExtension({
|
|
412
|
+
name: "sigrun.skills",
|
|
413
|
+
sections: [
|
|
414
|
+
section(SKILLS_SECTION_KEY, (input, context) => renderSkills(input, context, opts)),
|
|
415
|
+
],
|
|
416
|
+
tools: [buildLoadSkillTool(opts)],
|
|
417
|
+
});
|
|
418
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-10-21
|
|
11
|
+
### Added
|
|
12
|
+
- First published release of the shared leaf (workspace package of
|
|
13
|
+
[@sigrun/sigrun](https://github.com/sigrun-ai/sigrun)): theme tokens,
|
|
14
|
+
conversation/turn shaping, the channel wire types, session-meta,
|
|
15
|
+
commands, interactions, ambient records, access, settings, views,
|
|
16
|
+
view-delta, and jobs — the surfaces the webui, TUI, and server share.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael MacLeod
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @sigrun/shared
|
|
2
|
+
|
|
3
|
+
The pure client-shared leaf of [sigrun](https://github.com/sigrun-ai/sigrun):
|
|
4
|
+
theme tokens, turn/conversation shaping, and the channel wire types —
|
|
5
|
+
consumed by the webui, the TUI, and the server. Zero runtime dependencies;
|
|
6
|
+
every subpath is a types+code pair (`@sigrun/shared/themes`, `/conversations`,
|
|
7
|
+
`/turns`, …) exported from `dist`.
|
|
8
|
+
|
|
9
|
+
Part of the sigrun workspace; published from the repository root as
|
|
10
|
+
`@sigrun/shared` (see the root `CHANGELOG.md` for release history).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-resource sharing and content access (core hardening W1 Task 5, spec
|
|
3
|
+
* §5.9 ruling 6/10: per-resource sharing; groups cut).
|
|
4
|
+
*
|
|
5
|
+
* Part of the shared leaf package (`@sigrun/shared`): the server computes
|
|
6
|
+
* access through `accessOf`, the clients render the `sharing` state — one
|
|
7
|
+
* source of truth, no drift. PURE module: no imports at all (the
|
|
8
|
+
* views-purity guard enforces this across `shared/src`).
|
|
9
|
+
*
|
|
10
|
+
* The rules, in order (first match wins):
|
|
11
|
+
*
|
|
12
|
+
* 1. superuser → write;
|
|
13
|
+
* 2. ownerless resource → household write (everyone);
|
|
14
|
+
* 3. user === owner → write;
|
|
15
|
+
* 4. household sharing with a defined user → the shared level;
|
|
16
|
+
* 5. people sharing including the user → the shared level;
|
|
17
|
+
* 6. otherwise none (hidden: 404 on every :id route, absent from lists).
|
|
18
|
+
*
|
|
19
|
+
* Anonymous callers (a principal with no user) get nothing from a shared
|
|
20
|
+
* resource: sharing widens access for identified users only (rule 4/5
|
|
21
|
+
* require a defined user), while ownerless stays open to everyone by
|
|
22
|
+
* rule 2.
|
|
23
|
+
*/
|
|
24
|
+
/** What a conversation's owner grants beyond themselves. */
|
|
25
|
+
export type Sharing = {
|
|
26
|
+
readonly scope: "private";
|
|
27
|
+
} | {
|
|
28
|
+
readonly scope: "household";
|
|
29
|
+
readonly level: "read" | "write";
|
|
30
|
+
} | {
|
|
31
|
+
readonly scope: "people";
|
|
32
|
+
readonly users: string[];
|
|
33
|
+
readonly level: "read" | "write";
|
|
34
|
+
};
|
|
35
|
+
/** The resolved access level of a principal to one resource. */
|
|
36
|
+
export type Access = "none" | "read" | "write";
|
|
37
|
+
/** What accessOf needs of the caller (the server's Principal projects here). */
|
|
38
|
+
export interface AccessPrincipal {
|
|
39
|
+
readonly user?: string;
|
|
40
|
+
readonly superuser?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve one principal's access to one resource. Rule order per the
|
|
44
|
+
* module doc; the first matching rule wins.
|
|
45
|
+
*/
|
|
46
|
+
export declare function accessOf(p: AccessPrincipal, r: {
|
|
47
|
+
readonly owner?: string;
|
|
48
|
+
readonly sharing?: Sharing;
|
|
49
|
+
}): Access;
|
|
50
|
+
/**
|
|
51
|
+
* Type guard for the wire value: every scope shape checked strictly
|
|
52
|
+
* (wrong field types are not a Sharing). An empty `people.users` list is
|
|
53
|
+
* valid (shares with nobody yet) — validation is about shape, not policy.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isSharing(value: unknown): value is Sharing;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-resource sharing and content access (core hardening W1 Task 5, spec
|
|
3
|
+
* §5.9 ruling 6/10: per-resource sharing; groups cut).
|
|
4
|
+
*
|
|
5
|
+
* Part of the shared leaf package (`@sigrun/shared`): the server computes
|
|
6
|
+
* access through `accessOf`, the clients render the `sharing` state — one
|
|
7
|
+
* source of truth, no drift. PURE module: no imports at all (the
|
|
8
|
+
* views-purity guard enforces this across `shared/src`).
|
|
9
|
+
*
|
|
10
|
+
* The rules, in order (first match wins):
|
|
11
|
+
*
|
|
12
|
+
* 1. superuser → write;
|
|
13
|
+
* 2. ownerless resource → household write (everyone);
|
|
14
|
+
* 3. user === owner → write;
|
|
15
|
+
* 4. household sharing with a defined user → the shared level;
|
|
16
|
+
* 5. people sharing including the user → the shared level;
|
|
17
|
+
* 6. otherwise none (hidden: 404 on every :id route, absent from lists).
|
|
18
|
+
*
|
|
19
|
+
* Anonymous callers (a principal with no user) get nothing from a shared
|
|
20
|
+
* resource: sharing widens access for identified users only (rule 4/5
|
|
21
|
+
* require a defined user), while ownerless stays open to everyone by
|
|
22
|
+
* rule 2.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Resolve one principal's access to one resource. Rule order per the
|
|
26
|
+
* module doc; the first matching rule wins.
|
|
27
|
+
*/
|
|
28
|
+
export function accessOf(p, r) {
|
|
29
|
+
// 1. The superuser always writes.
|
|
30
|
+
if (p.superuser === true)
|
|
31
|
+
return "write";
|
|
32
|
+
// 2. Ownerless is household-write for everyone.
|
|
33
|
+
if (r.owner === undefined)
|
|
34
|
+
return "write";
|
|
35
|
+
// 3. The owner always writes.
|
|
36
|
+
if (p.user !== undefined && p.user === r.owner)
|
|
37
|
+
return "write";
|
|
38
|
+
// 4./5. Sharing widens identified users only — an anonymous caller
|
|
39
|
+
// (no user) falls straight through to none.
|
|
40
|
+
if (p.user !== undefined && r.sharing !== undefined) {
|
|
41
|
+
if (r.sharing.scope === "household")
|
|
42
|
+
return r.sharing.level;
|
|
43
|
+
if (r.sharing.scope === "people" && r.sharing.users.includes(p.user)) {
|
|
44
|
+
return r.sharing.level;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// 6. Hidden.
|
|
48
|
+
return "none";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Type guard for the wire value: every scope shape checked strictly
|
|
52
|
+
* (wrong field types are not a Sharing). An empty `people.users` list is
|
|
53
|
+
* valid (shares with nobody yet) — validation is about shape, not policy.
|
|
54
|
+
*/
|
|
55
|
+
export function isSharing(value) {
|
|
56
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const record = value;
|
|
60
|
+
switch (record.scope) {
|
|
61
|
+
case "private":
|
|
62
|
+
return true;
|
|
63
|
+
case "household":
|
|
64
|
+
return record.level === "read" || record.level === "write";
|
|
65
|
+
case "people":
|
|
66
|
+
return (Array.isArray(record.users) &&
|
|
67
|
+
record.users.every((user) => typeof user === "string") &&
|
|
68
|
+
(record.level === "read" || record.level === "write"));
|
|
69
|
+
default:
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|