@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. 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
+ }