@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
@@ -0,0 +1,342 @@
1
+ /**
2
+ * The curation host (curator P6, A9): `host.curation` — the
3
+ * extension-facing surface over everything Part A built.
4
+ *
5
+ * ONE object per boot, constructed in boot (where the per-user stores,
6
+ * the curation registry, the jobs records and the harness live) and
7
+ * exposed three ways: on the `OpenedHarness` (tests and server-side
8
+ * callers), on the host bridge (`HostBridge.curation` — the
9
+ * server-owned handle), and on every seam host (`SeamHost.curation`,
10
+ * delegating to the bridge, the same lazy-bind discipline as
11
+ * `conversations`).
12
+ *
13
+ * Read members (eligibility, authors, modelCallsSince, takeHints,
14
+ * targets, tools, skills, overlays, upstream, jobSkillRefs, usage) are
15
+ * thin reads over the A1–A8 primitives. The run producers (forkReview,
16
+ * digestRun, maintain) delegate to the run driver (runs.ts). The store
17
+ * subsets (`skills`, `overlays`) are the REAL per-user stores narrowed
18
+ * to the documented members — never a re-implementation.
19
+ */
20
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
21
+ import { AssistantEntry } from "@earendil-works/pi-durable";
22
+ import { authorsOfEntries } from "./attribution.js";
23
+ import { takeHints } from "./wrap.js";
24
+ import { digestRunRun, forkReviewRun, setChangeTargetResolver } from "./runs.js";
25
+ import { resolveUpstream } from "./overlays.js";
26
+ import { jobSkillRefs } from "../jobs/skills-refs.js";
27
+ import { SessionMetaDoc } from "../server/session-meta.js";
28
+ /** modelCallsSince's entry-page size (cursor-walked, never a cap). */
29
+ const MODEL_CALLS_PAGE = 500;
30
+ /** Scan bound for the cursor walk: a broken cursor loops, a count does not. */
31
+ const MODEL_CALLS_SCAN_LIMIT = 10_000;
32
+ /** Build the one curation host (boot constructs exactly one). */
33
+ export function createCurationHost(deps) {
34
+ const { harness, registry, curation } = deps;
35
+ // The change-target resolver: the registry's qualified ids, with the
36
+ // core skills store owning `skill_manage` (no target declares it).
37
+ setChangeTargetResolver((tool) => {
38
+ if (tool === "skill_manage")
39
+ return "sigrun.skills";
40
+ return curation.byTool(tool)?.id ?? `tool:${tool}`;
41
+ });
42
+ /** The conversation handle (loud when the id is unknown). */
43
+ const conversationOf = async (id, ctx) => {
44
+ const conversation = await harness.conversation(id, ctx);
45
+ if (conversation === undefined) {
46
+ throw new Error(`conversation ${id} not found`);
47
+ }
48
+ return conversation;
49
+ };
50
+ /** The per-user store pair (loud when the user is absent). */
51
+ const storesOf = (user) => {
52
+ const stores = deps.stores(user);
53
+ if (stores === undefined) {
54
+ throw new Error(`unknown user "${user}" — no curation stores`);
55
+ }
56
+ return stores;
57
+ };
58
+ /**
59
+ * The digest producer `maintain`'s budget hands the target: one
60
+ * digest run for the SAME user, bounded to the listed tools, with
61
+ * the remaining wall clock as the deadline.
62
+ */
63
+ const digestFor = async (user, input, tools, deadline) => {
64
+ const remaining = Math.max(1, deadline - Date.now());
65
+ return await digestRunRun({ harness, registry }, user, {
66
+ instruction: input,
67
+ allow: tools,
68
+ maxToolCalls: 8,
69
+ maxOutputTokens: 16_000,
70
+ deadlineMs: remaining,
71
+ reviewId: `digest-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
72
+ input,
73
+ }, deps.defaultModel, deps.defaultAgent);
74
+ };
75
+ const host = {
76
+ async eligibility(id) {
77
+ const meta = await harness.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT);
78
+ const conversation = await conversationOf(id, BACKGROUND_CONTEXT);
79
+ const page = await conversation.entries({}, 1, undefined, BACKGROUND_CONTEXT);
80
+ // The same map `authors` serves: eligibility is its aggregate view.
81
+ const authors = await host.authors(id);
82
+ let unattributed = 0;
83
+ let ownerOnly = true;
84
+ for (const [, author] of authors) {
85
+ if (author === undefined) {
86
+ unattributed += 1;
87
+ ownerOnly = false;
88
+ continue;
89
+ }
90
+ if (author !== meta?.owner)
91
+ ownerOnly = false;
92
+ }
93
+ return {
94
+ ...(meta?.owner === undefined ? {} : { owner: meta.owner }),
95
+ internal: meta?.internal === true,
96
+ ownerOnly,
97
+ unattributed,
98
+ ...(page.items[0]?.id === undefined
99
+ ? {}
100
+ : { newestEntry: page.items[0].id }),
101
+ };
102
+ },
103
+ async authors(id) {
104
+ return await authorsOfEntries(harness, id, BACKGROUND_CONTEXT);
105
+ },
106
+ async modelCallsSince(id, entryId) {
107
+ const conversation = await conversationOf(id, BACKGROUND_CONTEXT);
108
+ // Oldest-first from the anchor: `minEntryId` is an INCLUSIVE
109
+ // bound, so the anchor itself rides the first page and is
110
+ // filtered below — the count is the assistant entries STRICTLY
111
+ // after the anchor (the extension.ts contract). No anchor:
112
+ // the whole history.
113
+ const query = entryId === undefined
114
+ ? { order: "ascending" }
115
+ : { minEntryId: entryId, order: "ascending" };
116
+ // Cursor-complete walk (the feedOf pattern): pages until
117
+ // exhaustion so a long conversation never silently truncates,
118
+ // bounded so a cursor bug cannot loop forever.
119
+ let count = 0;
120
+ let scanned = 0;
121
+ let cursor;
122
+ for (;;) {
123
+ const page = await conversation.entries(query, MODEL_CALLS_PAGE, cursor, BACKGROUND_CONTEXT);
124
+ for (const entry of page.items) {
125
+ scanned += 1;
126
+ if (entry.kind !== AssistantEntry.kind)
127
+ continue;
128
+ if (entryId !== undefined && entry.id === entryId)
129
+ continue;
130
+ count += 1;
131
+ }
132
+ if (page.next === undefined ||
133
+ page.items.length === 0 ||
134
+ scanned >= MODEL_CALLS_SCAN_LIMIT) {
135
+ break;
136
+ }
137
+ cursor = page.next;
138
+ }
139
+ return count;
140
+ },
141
+ async takeHints(id) {
142
+ const conversation = await conversationOf(id, BACKGROUND_CONTEXT);
143
+ return await takeHints(conversation, BACKGROUND_CONTEXT);
144
+ },
145
+ async targets(user, id) {
146
+ // The selection filter: a target is served for a conversation
147
+ // only when EVERY listed tool is model-visible in that
148
+ // conversation's resolved agent. No id: every target.
149
+ const visible = id === undefined ? undefined : await modelVisibleTools(id);
150
+ const result = [];
151
+ for (const target of curation.targets()) {
152
+ if (visible !== undefined) {
153
+ if (target.tools.some((tool) => !visible.has(tool)))
154
+ continue;
155
+ }
156
+ const guidance = await target.guidance(user);
157
+ if (guidance.length === 0)
158
+ continue;
159
+ result.push({
160
+ id: target.id,
161
+ tools: [...target.tools],
162
+ guidance: [...guidance],
163
+ maintain: target.maintain !== undefined,
164
+ });
165
+ }
166
+ return result;
167
+ },
168
+ async tools(id) {
169
+ const visible = await modelVisibleTools(id);
170
+ return [...visible];
171
+ },
172
+ async forkReview(id, opts) {
173
+ return await forkReviewRun({ harness, registry }, id, opts);
174
+ },
175
+ async digestRun(user, opts) {
176
+ return await digestRunRun({ harness, registry }, user, opts, deps.defaultModel, deps.defaultAgent);
177
+ },
178
+ async maintain(user, targetId, budget) {
179
+ const target = curation
180
+ .targets()
181
+ .find((one) => one.id === targetId);
182
+ if (target === undefined) {
183
+ throw new Error(`unknown curation target "${targetId}"`);
184
+ }
185
+ if (target.maintain === undefined) {
186
+ throw new Error(`curation target "${targetId}" declares no maintain`);
187
+ }
188
+ // The budget's run: one digest run for the SAME user, bounded
189
+ // to the listed tools (the target asks for operations as text
190
+ // and applies them itself — the P6 contract).
191
+ const digestBudget = {
192
+ deadline: budget.deadline,
193
+ runs: budget.runs,
194
+ run: (input, tools) => digestFor(user, input, tools, budget.deadline),
195
+ };
196
+ const ctx = { abortSignal: undefined };
197
+ return await target.maintain(user, ctx, digestBudget);
198
+ },
199
+ skills(user) {
200
+ const { store } = storesOf(user);
201
+ return {
202
+ list: () => store.list(),
203
+ view: (name, file) => store.view(name, file),
204
+ feed: (limit, before) => store.feed(limit, before),
205
+ pin: (name, pinned) => store.pin(name, pinned),
206
+ undo: (entryIds) => store.undo(entryIds),
207
+ apply: (name, change, meta) => store.apply(name, change, {
208
+ actor: "curator",
209
+ ...(meta.reviewId === undefined
210
+ ? {}
211
+ : { reviewId: meta.reviewId }),
212
+ ...(meta.reason === undefined ? {} : { reason: meta.reason }),
213
+ }),
214
+ };
215
+ },
216
+ overlays(user) {
217
+ const { overlays } = storesOf(user);
218
+ return overlays;
219
+ },
220
+ async upstream(user, key) {
221
+ // The upstream read is user-independent (the skill roots are
222
+ // the host's); the user gate only proves a real curated user.
223
+ storesOf(user);
224
+ return await resolveUpstream(key, undefined, BACKGROUND_CONTEXT, {
225
+ userDir: deps.skillsOptions.userDir,
226
+ packageRoots: deps.skillsOptions.packageRoots,
227
+ });
228
+ },
229
+ async jobSkillRefs(user) {
230
+ return await jobSkillRefs(deps.storage, user, BACKGROUND_CONTEXT);
231
+ },
232
+ async undoReview(user, reviewId) {
233
+ const { store } = storesOf(user);
234
+ const entries = await feedOf(store, reviewId);
235
+ // The store undo: entries with a `before` version snapshot are
236
+ // the revertible ones; the store's own undo takes entry ids
237
+ // and restores the snapshots (the A4 machinery).
238
+ // Hand EVERY review feed entry to the store's undo: edit entries
239
+ // restore their before-snapshot, create entries (before ===
240
+ // undefined) archive the skill — the no-delete revert the A4
241
+ // store owns. The store itself ignores ids it cannot revert.
242
+ let reverted = 0;
243
+ if (entries.length > 0) {
244
+ const undone = await store.undo(entries.map((entry) => entry.id));
245
+ reverted = undone.length;
246
+ }
247
+ // The target undos: every change the review's TARGET tools
248
+ // reported. The feed only carries the core stores' entries;
249
+ // a target-owned change's id is the `details.curation`
250
+ // changeId the tool reported, which the caller (the run
251
+ // result's `changes`) holds — undoReview walks the RUN
252
+ // conversation's tool results for them.
253
+ await undoTargetChanges(user, reviewId);
254
+ return reverted;
255
+ },
256
+ async usage(user) {
257
+ const { usage, store } = storesOf(user);
258
+ const skills = await store.list();
259
+ const result = {};
260
+ for (const skill of skills) {
261
+ result[skill.name] = usage.lastUsed(skill.name);
262
+ }
263
+ return result;
264
+ },
265
+ };
266
+ return host;
267
+ /** The conversation's model-visible tool names. */
268
+ async function modelVisibleTools(id) {
269
+ const conversation = await conversationOf(id, BACKGROUND_CONTEXT);
270
+ const agent = await conversation.agent(BACKGROUND_CONTEXT);
271
+ return new Set(agent.tools.map((tool) => tool.name));
272
+ }
273
+ /** The feed entries one review caused (newest-first walk). */
274
+ async function feedOf(store, reviewId) {
275
+ const found = [];
276
+ let before;
277
+ for (;;) {
278
+ const page = await store.feed(100, before);
279
+ if (page.length === 0)
280
+ break;
281
+ for (const entry of page) {
282
+ if (entry.reviewId === reviewId)
283
+ found.push(entry);
284
+ }
285
+ before = page[page.length - 1]?.id;
286
+ if (page.length < 100)
287
+ break;
288
+ }
289
+ return found;
290
+ }
291
+ /**
292
+ * Undo the target-owned changes one review caused: find the run
293
+ * conversation by its run doc (`reviewId`), walk its tool results
294
+ * for `details.curation` changeIds, and hand each to the owning
295
+ * target's `undo` with the curated user.
296
+ */
297
+ async function undoTargetChanges(user, reviewId) {
298
+ const runId = await runConversationOf(reviewId);
299
+ if (runId === undefined)
300
+ return;
301
+ const conversation = await harness.conversation(runId, BACKGROUND_CONTEXT);
302
+ if (conversation === undefined)
303
+ return;
304
+ const page = await conversation.entries({ order: "ascending" }, 500, undefined, BACKGROUND_CONTEXT);
305
+ for (const entry of page.items) {
306
+ if (entry.kind !== "pi.tool-result")
307
+ continue;
308
+ for (const message of entry.model ?? []) {
309
+ if (message.role !== "toolResult")
310
+ continue;
311
+ // SAFETY: the tool-result details carry the A6/A8
312
+ // `details.curation` shape ({changeId, summary}); the
313
+ // structural read below only touches those two fields.
314
+ const details = message.details;
315
+ const change = details?.curation;
316
+ if (change === undefined)
317
+ continue;
318
+ const target = curation.byTool(message.toolName);
319
+ if (target === undefined)
320
+ continue;
321
+ await target.undo(user, change.changeId, { abortSignal: undefined });
322
+ }
323
+ }
324
+ }
325
+ /** The run conversation carrying one reviewId (or undefined). */
326
+ async function runConversationOf(reviewId) {
327
+ const { CurationRunDoc } = await import("./run-doc.js");
328
+ let cursor;
329
+ for (;;) {
330
+ const page = await deps.storage.scanConversations({ order: "descending" }, 50, cursor, BACKGROUND_CONTEXT);
331
+ for (const record of page.items) {
332
+ const run = await harness.snapshot(CurationRunDoc, record.id, BACKGROUND_CONTEXT);
333
+ if (run?.reviewId === reviewId)
334
+ return record.id;
335
+ }
336
+ if (page.next === undefined)
337
+ return undefined;
338
+ cursor = page.next;
339
+ }
340
+ }
341
+ }
342
+ ;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Internal runs (curator P5, spec §6.1): conversations created for curator
3
+ * work are invisible on every owner-keyed path.
4
+ *
5
+ * The marker is the server-owned `SessionMeta.internal: true`, stamped by
6
+ * {@link markInternal} in the conversation's CREATING commit — alongside
7
+ * the curated user as `owner`, so the env, the acting user, and the
8
+ * target tools resolve to them. The flag never changes after creation
9
+ * (there is no writer besides the creating commit), which every consumer
10
+ * may rely on for caching.
11
+ *
12
+ * Host paths that check it (each hidden from internal conversations):
13
+ * the HTTP listing, the host bridge's `conversations.list`, the `:id`
14
+ * route loader (404 for every caller, superuser included), the SSE
15
+ * per-principal event stream, the notifications surface, the auto-namer,
16
+ * and the event bus's idle clock, `run.settled` host emit, and extension
17
+ * `runSettled`/lifecycle delivery.
18
+ *
19
+ * Internal conversations are NOT purged: pi-durable 1.4.0 has no
20
+ * conversation deletion, and a fork reads its parent's history through
21
+ * ancestry, so each review costs only its own turn's rows. Backup copies
22
+ * the whole database; nothing per-conversation can be excluded (accepted
23
+ * per spec §6.1 P5 — the audit record is the change feed).
24
+ */
25
+ import { SessionMetaDoc } from "../server/session-meta.js";
26
+ /**
27
+ * Mark one conversation internal and owned by `owner`, in the creating
28
+ * commit: the P6 fork-review/digest producers call this from
29
+ * `createConversation`'s `init` (the conversation's first commit, so the
30
+ * marker predates every observer). Stamps `owner` too — the curated user
31
+ * the run acts for.
32
+ */
33
+ export async function markInternal(tx, conversationId, owner) {
34
+ const meta = await tx.doc(SessionMetaDoc, conversationId);
35
+ meta.owner = owner;
36
+ meta.internal = true;
37
+ }
38
+ /**
39
+ * Whether one conversation is internal, from any `DocumentReader`
40
+ * (including the narrowed reader an extension handler receives). Absent
41
+ * doc or absent flag → false: an ordinary conversation is the default,
42
+ * and a read failure must never hide an ordinary conversation from its
43
+ * owner (fail open on the LISTING side only; the emit-side gates live in
44
+ * the event bus, which resolves once per settle group and caches).
45
+ */
46
+ export async function isInternal(read, conversationId, ctx) {
47
+ try {
48
+ const meta = await read.snapshot(SessionMetaDoc, conversationId, ctx);
49
+ return meta?.internal === true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
@@ -0,0 +1,5 @@
1
+ const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
2
+ /** Skill names are also their directory names: reject, never normalize. */
3
+ export function validSkillName(name) {
4
+ return SKILL_NAME.test(name);
5
+ }