@sigrun/sigrun 0.4.2 → 0.6.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 (68) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +63 -33
  3. package/dist/commands/expand.js +37 -5
  4. package/dist/curation/attribution.js +135 -0
  5. package/dist/curation/deletion.js +32 -0
  6. package/dist/curation/gate.js +50 -0
  7. package/dist/curation/host.js +342 -0
  8. package/dist/curation/internal.js +54 -0
  9. package/dist/curation/names.js +5 -0
  10. package/dist/curation/overlays.js +325 -0
  11. package/dist/curation/paths.js +17 -0
  12. package/dist/curation/run-doc.js +64 -0
  13. package/dist/curation/runs.js +579 -0
  14. package/dist/curation/scan.js +30 -0
  15. package/dist/curation/skill-tool.js +308 -0
  16. package/dist/curation/store.js +444 -0
  17. package/dist/curation/targets.js +77 -0
  18. package/dist/curation/usage.js +124 -0
  19. package/dist/curation/wrap.js +212 -0
  20. package/dist/extension.d.ts +336 -1
  21. package/dist/harness/boot.js +236 -13
  22. package/dist/harness/builtins.js +19 -12
  23. package/dist/harness/event-bus.js +64 -0
  24. package/dist/harness/identity/primary-admin.js +19 -0
  25. package/dist/harness/loader.js +114 -1
  26. package/dist/harness/naming.js +6 -0
  27. package/dist/harness/packages.js +14 -9
  28. package/dist/harness/reserved-sections.js +4 -0
  29. package/dist/harness/seam-host.js +154 -26
  30. package/dist/harness/sections/context-files.js +6 -3
  31. package/dist/harness/sections/job-state.js +24 -0
  32. package/dist/harness/settings.js +2 -2
  33. package/dist/harness/tools.js +70 -0
  34. package/dist/harness/workspace.js +30 -3
  35. package/dist/jobs/fire.js +37 -14
  36. package/dist/jobs/index.js +34 -7
  37. package/dist/jobs/reconcile.js +9 -0
  38. package/dist/jobs/skills-refs.js +40 -0
  39. package/dist/jobs/state-dir.js +16 -0
  40. package/dist/jobs/transfer.js +14 -0
  41. package/dist/sandbox/policy.js +27 -4
  42. package/dist/serve.js +5 -3
  43. package/dist/server/blocks/answers.js +9 -3
  44. package/dist/server/conversation-ops.js +34 -3
  45. package/dist/server/helpers.js +1 -1
  46. package/dist/server/host-bridge.js +31 -1
  47. package/dist/server/notifications.js +108 -16
  48. package/dist/server/routes/jobs.js +31 -0
  49. package/dist/server/routes/meta.js +8 -3
  50. package/dist/server/routes/submissions.js +5 -0
  51. package/dist/server/routes/surfaces.js +3 -1
  52. package/dist/server/routes/users.js +7 -0
  53. package/dist/server/schema-fixture.js +168 -0
  54. package/dist/server/session-meta.js +12 -10
  55. package/dist/settings/core-sections.js +18 -0
  56. package/dist/settings/registry.js +12 -0
  57. package/dist/skills/index.js +88 -19
  58. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -1
  59. package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
  60. package/node_modules/@sigrun/shared/dist/access.js +8 -0
  61. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
  62. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
  63. package/node_modules/@sigrun/shared/package.json +1 -1
  64. package/package.json +9 -7
  65. package/webui/dist/assets/index-BR0di0OG.css +1 -0
  66. package/webui/dist/assets/{index-DWmsAQl2.js → index-BzrA1T4g.js} +5 -5
  67. package/webui/dist/index.html +2 -2
  68. package/webui/dist/assets/index-h7zDY9vD.css +0 -1
package/CHANGELOG.md CHANGED
@@ -5,7 +5,97 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.6.0] - 2026-11-02
9
+
10
+ ### Added
11
+ - **Escalations core.** Extensions send notices through `host.notify`
12
+ (validated targets, delivered on the bounded side lane), look jobs up
13
+ with `host.jobs.get`, create job-scoped conversations, and read a
14
+ conversation's history through the trusted `host.conversations.entries`
15
+ / `entry` (callers enforce access themselves).
16
+ - **Per-job state.** Each job gets a state folder under its owner's jobs
17
+ root (jail-granted to the run); the run's prompt names it. State follows
18
+ the job through delete, transfer and retire, and resets on request
19
+ (`POST /v1/jobs/{id}/state/reset`, and a Reset button in the jobs tab).
20
+ - Extension views and panels render through the block renderer (they were
21
+ a JSON placeholder in 0.5.0 — the curator view showed raw data).
22
+ - The settings page gains the `builtinExtensions.gateway` toggle.
23
+
24
+ ### Changed
25
+ - **Ownerless jobs belong to the primary admin** (the earliest-created
26
+ directory admin): run conversations, access checks, `host.jobs.get` and
27
+ job state all use that effective owner. A single-operator install with
28
+ an empty directory stays ownerless.
29
+ - The identity prompt section is multi-user and no longer names the agent
30
+ (naming is persona territory, `SOUL.md`).
31
+ - Built-in documentation says six built-ins everywhere (codemode, MCP,
32
+ tool search, gateway, OAuth, curator); everything else is optional.
33
+
34
+ ### Fixed
35
+ - The web conversation list heals after the events stream drops: it
36
+ reloads on every reconnect and when the tab becomes visible again, and
37
+ reopens an EventSource the browser gave up on (5-60 s backoff).
38
+ Conversations created on another device no longer stay invisible until
39
+ a reload.
40
+ - Leaving an extension view: the Chats rail entry works and picking a
41
+ conversation in the sidebar returns to the chat.
42
+ - Phone layout: a long URL in a user message no longer makes the chat
43
+ slide sideways; tables keep readable columns and scroll inside their
44
+ card; the composer's controls stay on one row.
45
+ - Extension surfaces: params-carrying action/data URLs, zero-width surface
46
+ pages, and cadence refreshes.
47
+ - `SIGRUN_EXTENSIONS` without a model env no longer crashes boot.
48
+
49
+ ## [0.5.0] - 2026-11-02
50
+
51
+ ### Added
52
+ - **The curator** (sixth built-in, `@sigrun/ext-curator`): per-user
53
+ curation over learned skills and curation targets. Message attribution
54
+ records the submitting user per input; fork reviews (loop 1) run after
55
+ conversation activity past a threshold; the idle maintenance pass
56
+ (loop 2, the `curator-tick` job) digests mixed-authorship
57
+ conversations — per-entry attribution keeps other users' turns out of
58
+ the digest input; the versioned per-user skill store (feed, pins,
59
+ content scan, undo); usage telemetry; internal runs hidden from every
60
+ owner-keyed path; the curation-target contract (`@sigrun/memory-markdown`
61
+ 0.2.0 is the first target: per-user USER.md/MEMORY.md with hard limits
62
+ and versions); notices + the undo view; hint-by-wrapping interception
63
+ with provable authorship.
64
+ - A default `stream.timeoutMs` (5 minutes) via the boot settings getter:
65
+ a model request with no explicit timeout could wedge forever (the
66
+ OpenAI SDK's timeout is a headers-arrival deadline; a request that
67
+ never reaches the provider never gets headers). Caller seam and
68
+ settings file override field-wise.
69
+
70
+ ### Fixed
71
+ - The seam host's pre-bind `curation.skills(user)`/`overlays(user)`
72
+ returned a promise of the store instead of a store (loop 2's archive
73
+ step read `.list` off a promise). A lazy store proxy now preserves the
74
+ sync-store contract through the pre-bind window.
75
+
76
+ ### Added
77
+ - `host.notify` lets extensions send validated explicit notices through
78
+ every declared notifier, with optional conversation or declared-view
79
+ deep links. Delivery is enqueued on the bounded side lane and is not
80
+ gated by `notifications.runSettled`.
81
+ - Extension host job hooks: `host.jobs.get(id)` returns a read-only live-job
82
+ projection with its effective owner, and `host.conversations.create({job})`
83
+ stamps a validated job association in the session-meta creation commit.
84
+ Unknown, retired, and owner-mismatched jobs are rejected before storage
85
+ writes; creating the conversation does not create a job run or `lastRun`.
86
+
87
+ ### Changed
88
+ - Ownerless and orphaned-owner jobs now run as the primary admin; their
89
+ runs are no longer visible to every user. The effective owner is
90
+ resolved live without rewriting the declared record.
91
+ - Reworded the `pi` manifest handling so it no longer implies pi
92
+ extension compatibility. Sigrun reads `pi.extensions`/`pi.skills` only
93
+ to LOCATE files and shares the SKILL.md format; it does not implement
94
+ pi's `ExtensionAPI` (a pi extension's `registerTool`/`registerCommand`
95
+ have no counterpart here). `docs/extending.md` gains a "Not pi
96
+ extensions" section stating the two ABIs are unrelated and that only
97
+ the skills layer transfers. The `.ts` skip and empty-entry warnings now
98
+ say `pi manifest:` instead of `pi-compat:`.
9
99
 
10
100
  ## [0.4.2] - 2026-10-31
11
101
 
@@ -650,3 +740,4 @@ recreate).*
650
740
  surface, settings panel, command surface).
651
741
  - pi-durable upgraded 1.0.2 → 1.2.0: ToolExecutionResult `output` rename across
652
742
  core, registry-driven settings roster, `modelTools` two-layer filtering.
743
+
package/README.md CHANGED
@@ -1,19 +1,19 @@
1
1
  # Sigrun
2
2
 
3
- Sigrun is a **durable, multi-user personal-assistant server** built on
4
- [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable).
5
- One agent loop serves every surface — a web UI, a terminal UI, an HTTP
6
- API, and (via the gateway extension) chat channels — over long-lived
7
- conversations stored in SQLite.
8
- An extension model installs extra tools, providers, prompt sections,
9
- memory systems, and login providers at boot.
10
-
11
- **Why use it?** Conversations survive restarts and resume where they
12
- stopped; memory and integrations are extension-owned and individually
13
- replaceable; multi-user boundaries are explicit (real authentication plus
14
- a per-user execution jail); and it deploys as an npm package, a Docker
15
- compose reference, or a Helm chart. [`docs/why.md`](docs/why.md) is the
16
- longer rationale.
3
+ Sigrun is a **self-hosted, multi-user personal-assistant server**. Each
4
+ user signs in to their own conversations, settings, credentials, and
5
+ tools, and their agent runs in its own execution jail. One agent loop
6
+ serves every surface — a web UI, a terminal UI, an HTTP API, scheduled
7
+ jobs, and (via the gateway extension) chat-channel adapters. The core
8
+ stays small: memory, model providers, tools, MCP support, and login are
9
+ extensions installed at boot.
10
+
11
+ **Why use it?** Several people can share one install without sharing an
12
+ agent: real authentication, per-user extensions and secrets, and a
13
+ per-user execution jail that fails closed. Memory and integrations are
14
+ extension-owned and individually replaceable. Conversations survive
15
+ restarts, and it deploys as an npm package, a Docker compose reference,
16
+ or a Helm chart. [`docs/why.md`](docs/why.md) is the longer rationale.
17
17
 
18
18
  **Status:** 0.x — useful and exercised, but extension and settings
19
19
  contracts can still evolve between minor releases. See
@@ -24,12 +24,12 @@ posture, and [`CONTRIBUTING.md`](CONTRIBUTING.md) to build and test.
24
24
  [Install](#install) and [Quickstart](#quickstart) for the model/credential
25
25
  setup every install needs.
26
26
 
27
- - **Durable** — conversations, submissions, and schedules live in one
28
- SQLite database (`SIGRUN_STORAGE_PATH`); a restart resumes where it
29
- stopped.
30
- - **pi-durable core** — the harness primitives (conversations, inbox
31
- steering/follow-up modes, retries, streaming, compaction) come from
32
- pi-durable; sigrun is the deployable shell around them.
27
+ - **Multi-user** — token, proxy, and OIDC/GitHub/Discord sign-in, with
28
+ invites after the first admin claims the install; per-user extension
29
+ settings, secrets, and services; and a Landlock execution jail per user.
30
+ A second user is refused when isolation is unavailable. See
31
+ [Authentication](#authentication) and
32
+ [Multi-user execution isolation](#multi-user-execution-isolation).
33
33
  - **Extensions** — separate npm packages loaded from `SIGRUN_EXTENSIONS` (or the settings file's
34
34
  top-level `extensions` list); a failing extension is quarantined and
35
35
  reported, never a boot abort.
@@ -42,6 +42,12 @@ setup every install needs.
42
42
  API the web UI consumes (feature parity by design): conversation
43
43
  sidebar, streaming conversation pane, fork, the interaction and
44
44
  ambient seams, slash commands. See [Launch modes](#launch-modes).
45
+ - **Restart-safe** — conversations, submissions, and schedules live in one
46
+ SQLite database (`SIGRUN_STORAGE_PATH`), and a restart resumes where it
47
+ stopped. The harness primitives (conversations, inbox steering/follow-up
48
+ modes, retries, streaming, compaction) come from
49
+ [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable);
50
+ sigrun is the deployable shell around them.
45
51
 
46
52
  ## Install
47
53
 
@@ -525,8 +531,8 @@ form when the effective auth methods include `token`; `/v1/auth/providers`
525
531
  returns an empty list on proxy-only deployments because their completed
526
532
  sign-ins could not authenticate the resulting session. The TUI continues to
527
533
  use tokens. Login providers come from extensions.
528
- The reference implementation is **`@sigrun/oauth`, the fourth built-in
529
- extension**. It is bundled and loads by default, so no `extensions` entry is
534
+ The reference implementation is **`@sigrun/oauth`, one of the six built-in
535
+ extensions**. It is bundled and loads by default, so no `extensions` entry is
530
536
  needed. Set `"builtinExtensions": {"oauth": false}` to disable it; like the
531
537
  other built-in toggles, this is restart-required. A configured extension
532
538
  whose package name is also `@sigrun/oauth` replaces the built-in. If the
@@ -716,7 +722,8 @@ links, stops their worker and services, and removes their per-user tool
716
722
  entries. Extensions then receive `userDeleted` while the old record and
717
723
  secrets remain readable, so they can revoke external provider
718
724
  credentials. Finally core removes the directory record, per-user secrets,
719
- user-settings directory, and per-user cache directories.
725
+ user-settings directory, per-user cache directories, and that user's
726
+ learned skills and curation state (never transferred).
720
727
 
721
728
  **Credentials belong to a person and never transfer.** Data and state
722
729
  stay for the extension to retain, move, or delete according to its own
@@ -1008,11 +1015,11 @@ Entry grammar:
1008
1015
  checkout `FETCH_HEAD`; present without a ref → fetch only (the
1009
1016
  checkout stays where you left it). Entry discovery: the object form's
1010
1017
  `entry` → a `sigrun` manifest (`{"sigrun": {"extension": "./index.js"}}`)
1011
- → a pi-compat manifest (the first non-`.ts` `pi.extensions` entry; each
1018
+ → a `pi` manifest (the first non-`.ts` `pi.extensions` entry; each
1012
1019
  `.ts` entry is skipped with one warning — it cannot load under plain
1013
1020
  node) → `dist/index.js`.
1014
1021
  - **Skills from packages** — a package may ship skills instead of (or
1015
- beside) extension code: manifest `sigrun.skills` or pi-compat
1022
+ beside) extension code: manifest `sigrun.skills` or the `pi`-shaped
1016
1023
  `pi.skills` (`["./skills"]`, the same SKILL.md format), or a
1017
1024
  factory-result `skills` field (absolute dirs). A package that declares
1018
1025
  skills and has no loadable entry is a valid **skills-only** package
@@ -1075,10 +1082,13 @@ a dashboard) are in
1075
1082
 
1076
1083
  ## Built-in extensions
1077
1084
 
1078
- Five extensions are built into core — **codemode**, **MCP**, **tool
1079
- search**, **gateway**, and **OAuth login**. The first three mirror
1080
- what pi 1.3.0 ships built-in; the gateway is sigrun's own channel-adapter
1081
- engine; OAuth provides OIDC, GitHub, and Discord login providers. They
1085
+ Six extensions are built into core — **codemode**, **MCP**, **tool
1086
+ search**, **gateway**, **OAuth login**, and the **curator**. The first
1087
+ three mirror what pi 1.3.0 ships built-in; the gateway is sigrun's own
1088
+ channel-adapter engine; OAuth provides OIDC, GitHub, and Discord login
1089
+ providers; the curator learns per-user skills (see
1090
+ [Skills and prompt templates](#skills-and-prompt-templates)). Every other extension is optional
1091
+ and loads only when `extensions` names it. The built-ins
1082
1092
  load by default on every boot, with no settings entry, and resolve from
1083
1093
  core's own `node_modules` (they are core dependencies, never installed
1084
1094
  into the packages dir — `extensions` has nothing to install for them,
@@ -1086,7 +1096,8 @@ unless a same-named package is deliberately installed to replace one;
1086
1096
  see below).
1087
1097
  A default install therefore has pi's tool surface without configuring
1088
1098
  anything. On `GET /v1/extensions` they report under the names
1089
- `codemode`, `sigrun-mcp`, `tool-search`, `gateway`, and `oauth`.
1099
+ `codemode`, `sigrun-mcp`, `tool-search`, `gateway`, `oauth`, and
1100
+ `curator`.
1090
1101
  Configure OAuth providers in `settings/oauth.json`; see the
1091
1102
  `@sigrun/oauth` package README for provider settings and secrets.
1092
1103
 
@@ -1102,7 +1113,8 @@ default. They join the offered set when an enabled MCP server's
1102
1113
  automatic decision (auto-enable only narrows the default set, never a
1103
1114
  named list).
1104
1115
  - **Turning one off** — `"builtinExtensions": {"codemode": false,
1105
- "mcp": false, "toolSearch": false, "gateway": false, "oauth": false}`.
1116
+ "mcp": false, "toolSearch": false, "gateway": false, "oauth": false,
1117
+ "curator": false}`.
1106
1118
  Each defaults to `true`; each is restart-required. A disabled built-in
1107
1119
  disappears from `GET /v1/extensions` entirely — no row, not even a
1108
1120
  skipped one — and its config is then ignored.
@@ -1129,12 +1141,13 @@ docs/extending.md).
1129
1141
  "extensions": [ // configured extensions install + load as above
1130
1142
  "@acme/sigrun-ext-example@^0.1.0"
1131
1143
  ],
1132
- "builtinExtensions": { // all five default to true; restart required
1144
+ "builtinExtensions": { // all six default to true; restart required
1133
1145
  "codemode": true,
1134
1146
  "mcp": true,
1135
1147
  "toolSearch": false,
1136
1148
  "gateway": true,
1137
- "oauth": true
1149
+ "oauth": true,
1150
+ "curator": true
1138
1151
  }
1139
1152
  }
1140
1153
  ```
@@ -1193,6 +1206,23 @@ typed — the submission's user entry carries
1193
1206
  `{kind: "sigrun.user.expanded", data: {typed, command}}` while the
1194
1207
  model receives the expanded text.
1195
1208
 
1209
+ **The curator and learned skills.** Beyond the three roots above, every
1210
+ user has a private fourth root — learned skills the curator records for
1211
+ them under `<data-dir>/users/<user>/skills/`. For skills it ranks second:
1212
+ the workspace wins over it, and it wins over the user dir and packages. The curator reviews the
1213
+ user's conversations (owner-authored tool calls are captured as
1214
+ "Noted" hints, never executed on the spot) and writes through the
1215
+ versioned `skill_manage` surface, so every learned skill keeps its last
1216
+ ten versions, a change feed, and one-call undo. Learned skills never
1217
+ leave their user: they are not shared, not transferred on ownership
1218
+ moves, and removed with the user on `DELETE /v1/users/{user}`. Each
1219
+ user can pause or disable the curator in their own per-user settings —
1220
+ `PUT /v1/me/extensions/curator/settings` with `{"paused": true}` stops
1221
+ new reviews until flipped back, `{"enabled": false}` turns the curator
1222
+ off entirely (both are per-user and take effect on the next run) — and
1223
+ an operator can disable it server-wide with
1224
+ `"builtinExtensions": {"curator": false}` (restart required).
1225
+
1196
1226
  ## Jobs
1197
1227
 
1198
1228
  A job is a schedule plus an action, durable by construction — one
@@ -31,6 +31,7 @@
31
31
  * kind must not start with `pi.`).
32
32
  */
33
33
  import { listSkills, } from "../skills/index.js";
34
+ import { OVERLAY_HEADING, overlayForSkill } from "../curation/overlays.js";
34
35
  import { discoverTemplates, } from "./templates.js";
35
36
  /** The user entry kind an expanded input is placed as. */
36
37
  export const EXPANDED_ENTRY_KIND = "sigrun.user.expanded";
@@ -149,8 +150,13 @@ export function expandTemplate(text, templates) {
149
150
  * shape): the `<skill …>` block, plus a blank line and the trimmed
150
151
  * arguments ONLY when arguments were given. An unknown skill — or any
151
152
  * other text — passes through UNCHANGED.
153
+ *
154
+ * `overlays` (curator A5, P2): the acting owner's overlay text per
155
+ * skill name — when present, the block's body carries the upstream
156
+ * body followed by `OVERLAY_HEADING` and the overlay text, the same
157
+ * shape `load_skill` serves. Absent = the plain pi shape.
152
158
  */
153
- export function expandSkillCommand(text, skills) {
159
+ export function expandSkillCommand(text, skills, overlays) {
154
160
  if (!text.startsWith("/skill:"))
155
161
  return text;
156
162
  const spaceIndex = text.indexOf(" ");
@@ -160,9 +166,12 @@ export function expandSkillCommand(text, skills) {
160
166
  if (skill === undefined)
161
167
  return text;
162
168
  const location = `${skill.dir}/SKILL.md`;
169
+ const overlay = overlays?.get(skill.name);
163
170
  // Pi parity: the body is the frontmatter-stripped SKILL.md, trimmed —
164
171
  // `</skill>` sits on its own line with no stray blank lines.
165
- const body = skill.body.trim();
172
+ const body = (overlay === undefined
173
+ ? skill.body
174
+ : `${skill.body}\n${OVERLAY_HEADING}\n\n${overlay}`).trim();
166
175
  const block = `<skill name="${skill.name}" location="${location}">\n` +
167
176
  `References are relative to ${skill.dir}.\n` +
168
177
  `\n${body}\n` +
@@ -205,7 +214,7 @@ export function expandableTemplates(templates, commandNames) {
205
214
  * error `parseCommandArgs` raises) — the submit path maps it to a
206
215
  * client error.
207
216
  */
208
- export async function expandInput(parts, expansion, env, context) {
217
+ export async function expandInput(parts, expansion, env, context, owner) {
209
218
  // Only a LEADING text part starting with `/` is a command: the
210
219
  // command is what the user typed first (attachments and later text
211
220
  // parts pass through untouched, in place).
@@ -223,10 +232,33 @@ export async function expandInput(parts, expansion, env, context) {
223
232
  if (reserved.has(commandName))
224
233
  return { parts };
225
234
  if (commandName.startsWith("skill:")) {
226
- const skills = await listSkills(env, context, expansion.skills);
227
- const expanded = expandSkillCommand(text, skills);
235
+ const skillName = commandName.slice("skill:".length);
236
+ const skills = await listSkills(env, context, expansion.skills, { owner });
237
+ // Curator A5 (P2): the owner's overlay rides on the upstream body,
238
+ // the same shape `load_skill` serves — discovered skills without
239
+ // one (or an owner without a store) map nothing, and the block
240
+ // keeps pi's plain shape.
241
+ const overlays = new Map();
242
+ for (const skill of skills) {
243
+ const overlay = await overlayForSkill(expansion.skills, owner, skill, env);
244
+ if (overlay !== undefined)
245
+ overlays.set(skill.name, overlay);
246
+ }
247
+ const expanded = expandSkillCommand(text, skills, overlays);
228
248
  if (expanded === text)
229
249
  return { parts };
250
+ // Curator A6 (P4): a `/skill:name` that actually expanded counts as
251
+ // usage for the owner (the discovered skill was a learned one).
252
+ if (owner !== undefined) {
253
+ const skill = skills.find((candidate) => candidate.name === skillName);
254
+ if (skill?.root === "learned") {
255
+ const usage = expansion.skills.usage?.(owner);
256
+ if (usage !== undefined) {
257
+ usage.bump(skill.name, "expand");
258
+ void usage.flush();
259
+ }
260
+ }
261
+ }
230
262
  return {
231
263
  parts: parts.map((part) => (part === textPart ? { type: "text", text: expanded } : part)),
232
264
  typed: text,
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Message attribution (curator P1, spec §6.1): every user input records
3
+ * the submitting user through every admission path — HTTP submit (the
4
+ * resolved principal), the extension host bridge (`opts.user`, recorded
5
+ * verbatim; extensions own their user mapping), a block answer (the
6
+ * acting user), and a job fire (the job's owner).
7
+ *
8
+ * The carrier is a SIDE record, the `sigrun.authors` conversation doc:
9
+ * `pi.user` entries are `Entry<never>` (they cannot carry data), and a
10
+ * new input entry kind would have to be taught to every `UserEntry.is`
11
+ * consumer. The doc keys the author two ways:
12
+ *
13
+ * - `byRequest` — written BEFORE `conversation.submit` (the requestId
14
+ * exists pre-admission, so this never races). An orphan record from a
15
+ * failed submit is harmless.
16
+ * - `bySubmission` — written right after admission returns: the key the
17
+ * live run's input ids resolve against, readable from a tool through
18
+ * any `DocumentReader`. A tool call that lands before this write reads
19
+ * "unattributed" and fails closed.
20
+ *
21
+ * An `undefined` user (an unidentified caller) records nothing: its
22
+ * entries read unattributed, exactly like every entry admitted before
23
+ * this doc existed — unattributed entries are never curation input.
24
+ */
25
+ import { randomUUID } from "node:crypto";
26
+ import { defineDoc, UserEntry } from "@earendil-works/pi-durable";
27
+ import { EXPANDED_ENTRY_KIND } from "../commands/expand.js";
28
+ import { BlocksAnswerEntry } from "../harness/blocks/entries.js";
29
+ /** The `sigrun.authors` doc (latest-only singleton per conversation). */
30
+ export const AuthorsDoc = defineDoc({
31
+ kind: "sigrun.authors",
32
+ version: 1,
33
+ scope: "conversation",
34
+ checkpointWhen: () => false,
35
+ history: "latest",
36
+ fork: "current",
37
+ initial: () => ({ byRequest: {}, bySubmission: {} }),
38
+ });
39
+ /** The entry kinds an input submission places (the author carriers). */
40
+ const INPUT_KINDS = new Set([
41
+ UserEntry.kind,
42
+ EXPANDED_ENTRY_KIND,
43
+ BlocksAnswerEntry.kind,
44
+ ]);
45
+ /** Whether one entry is a placed user input (any of {@link INPUT_KINDS}). */
46
+ function isInputEntry(entry) {
47
+ return INPUT_KINDS.has(entry.kind);
48
+ }
49
+ /**
50
+ * The requestId an input is admitted under: the caller's own id, or a
51
+ * generated `sigrun:`-prefixed UUID when the caller passed none — every
52
+ * input submission carries one, so `byRequest` can key every admission.
53
+ */
54
+ export function ensureRequestId(requestId) {
55
+ return requestId ?? `sigrun:${randomUUID()}`;
56
+ }
57
+ /**
58
+ * Record `user` as the author of one input, keyed by its requestId
59
+ * (pre-admission) or its submission id (post-admission). A no-op for an
60
+ * `undefined` user: an unidentified caller leaves no record and its
61
+ * entries read unattributed.
62
+ */
63
+ export async function recordAuthor(conversation, key, user, ctx) {
64
+ if (user === undefined)
65
+ return;
66
+ await conversation.commit(async (tx) => {
67
+ const doc = await tx.doc(AuthorsDoc, conversation.id);
68
+ if ("requestId" in key)
69
+ doc.byRequest[key.requestId] = user;
70
+ else
71
+ doc.bySubmission[String(key.submissionId)] = user;
72
+ }, ctx);
73
+ }
74
+ /**
75
+ * The author of one submission, from any `DocumentReader` — including the
76
+ * `ToolExecutionApi` a tool runs with. Reads `bySubmission` only, so a
77
+ * tool call that lands before the post-admission write resolves
78
+ * `undefined` (unattributed — fail closed, never guess).
79
+ */
80
+ export async function authorOfSubmission(read, conversationId, submissionId, ctx) {
81
+ const doc = await read.snapshot(AuthorsDoc, conversationId, ctx);
82
+ return doc?.bySubmission[String(submissionId)];
83
+ }
84
+ /**
85
+ * Every user-input entry in the conversation's ACTIVE context mapped to
86
+ * its author — host-side (full `Session` access; the harness resolves
87
+ * both record keys through the submission's `entry` link and its
88
+ * `requestId`). Entries with no attribution (everything admitted before
89
+ * this doc, or by an unidentified caller) are present with `undefined`:
90
+ * the map's presence set is the input entries, the values are authors.
91
+ */
92
+ export async function authorsOfEntries(harness, conversationId, ctx) {
93
+ const result = new Map();
94
+ const conversation = await harness.conversation(conversationId, ctx);
95
+ if (conversation === undefined)
96
+ return result;
97
+ const view = await conversation.context(ctx);
98
+ for (const entry of view.entries) {
99
+ if (isInputEntry(entry))
100
+ result.set(entry.id, undefined);
101
+ }
102
+ const doc = await harness.snapshot(AuthorsDoc, conversationId, ctx);
103
+ if (doc === undefined)
104
+ return result;
105
+ // The byRequest join: the requestId resolves to the submission (and
106
+ // its placed entry) through the transaction's conversation-scoped
107
+ // lookup — the one typed path from a requestId to the record.
108
+ const byRequest = await harness.commit(async (tx) => {
109
+ const resolved = [];
110
+ for (const [requestId, user] of Object.entries(doc.byRequest)) {
111
+ const submission = await tx.submissionByRequest(conversationId, requestId);
112
+ if (submission?.entry !== undefined) {
113
+ resolved.push({ entry: submission.entry, user });
114
+ }
115
+ }
116
+ return resolved;
117
+ }, ctx);
118
+ for (const { entry, user } of byRequest) {
119
+ if (result.has(entry))
120
+ result.set(entry, user);
121
+ }
122
+ // The bySubmission join (authoritative: keyed by the exact id). The
123
+ // submission handle re-reads the live record, so a still-queued input
124
+ // joins once its entry places.
125
+ for (const [id, user] of Object.entries(doc.bySubmission)) {
126
+ const submission = await harness.submission(Number(id), ctx);
127
+ if (submission === undefined)
128
+ continue;
129
+ const record = await submission.status(ctx);
130
+ if (record.entry !== undefined && result.has(record.entry)) {
131
+ result.set(record.entry, user);
132
+ }
133
+ }
134
+ return result;
135
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Curation state removal on user deletion (curator A10, spec §6.4):
3
+ * a departing user's learned skills and curation state are REMOVED with
4
+ * them, never transferred — a skill distilled from their conversations
5
+ * can carry their paths, hosts and preferences, and must not load for
6
+ * the recipient (memory's shape; the spec's call).
7
+ *
8
+ * Core owns exactly the two roots under the managed user area:
9
+ * `<userArea>/skills` (the learned root) and `<userArea>/curation`
10
+ * (versions, overlays, the feed, usage counters). The user area itself,
11
+ * workspaces and every extension-owned per-user dir stay for the §10.6
12
+ * recipient/retention rules; targets clean their own data through their
13
+ * `userDeleted` handler.
14
+ *
15
+ * Fail-closed counterpart (Review Focus 1): the per-user stores gate
16
+ * EVERY operation on the live directory record (A4/A5's `userExists`),
17
+ * so a curator run in flight when its user is deleted cannot write —
18
+ * its late calls fail `user removed` and create nothing. This function
19
+ * only removes what exists; it never recreates a root.
20
+ */
21
+ import { rm } from "node:fs/promises";
22
+ import { curationDir, learnedSkillsDir } from "./paths.js";
23
+ /**
24
+ * Remove one user's learned skills root and curation state directory.
25
+ * Idempotent (`force`): absent roots are a no-op, and nothing is ever
26
+ * created. Symlink-safe by construction — `rm` removes a symlinked
27
+ * root itself, never its target.
28
+ */
29
+ export async function removeUserCuration(userArea) {
30
+ await rm(learnedSkillsDir(userArea), { recursive: true, force: true });
31
+ await rm(curationDir(userArea), { recursive: true, force: true });
32
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The curator-run gate (curator P6): `sigrun.curation-gate` — a
3
+ * hooks-only core extension that confines an internal curator run to
4
+ * its allow list.
5
+ *
6
+ * Selected ONLY in the run conversations A9's producer creates (this
7
+ * task installs it; boot keeps it OUT of the host default extension
8
+ * selection, the same never-defaulted treatment as per-user entries).
9
+ * Its `beforeTool` blocks any tool not in `CurationRunDoc.allow` with
10
+ * `"<tool> is not available in this review; use one of: <allow,
11
+ * comma-separated>"` — a decision only; it writes nothing (a hook
12
+ * cannot commit, and the gate has no write to make). A conversation
13
+ * with no run doc (never, for a correctly-created run) allows
14
+ * everything: the gate is a confinement, not a filter on ordinary
15
+ * conversations.
16
+ */
17
+ import { defineExtension, hook, ToolTask } from "@earendil-works/pi-durable";
18
+ import { curationRun } from "./run-doc.js";
19
+ /** The gate extension's registered name. */
20
+ export const CURATION_GATE_EXTENSION = "sigrun.curation-gate";
21
+ /**
22
+ * Build the gate extension: one `pi.tool` hook, no tools, no sections,
23
+ * no prompt bytes. Boot installs it into the registry; A9's producer
24
+ * selects it in the internal run conversations it creates.
25
+ */
26
+ export function createCurationGateExtension() {
27
+ return defineExtension({
28
+ name: CURATION_GATE_EXTENSION,
29
+ hooks: [
30
+ hook(ToolTask, {
31
+ /**
32
+ * Block any tool outside the run's allow list, naming the
33
+ * allowed list so the model can recover without another
34
+ * round of guessing. `undefined` (no run doc, or the tool
35
+ * is allowed) lets the call proceed.
36
+ */
37
+ async beforeTool(call, api, context) {
38
+ const run = await curationRun(api, api.conversationId, context);
39
+ if (run === undefined)
40
+ return undefined;
41
+ if (run.allow.includes(call.name))
42
+ return undefined;
43
+ return {
44
+ block: `${call.name} is not available in this review; use one of: ${run.allow.join(", ")}`,
45
+ };
46
+ },
47
+ }),
48
+ ],
49
+ });
50
+ }