@sigrun/sigrun 0.4.1 → 0.5.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 (56) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +46 -23
  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 +282 -0
  21. package/dist/harness/boot.js +209 -9
  22. package/dist/harness/builtins.js +12 -9
  23. package/dist/harness/event-bus.js +64 -0
  24. package/dist/harness/loader.js +114 -1
  25. package/dist/harness/naming.js +6 -0
  26. package/dist/harness/packages.js +14 -9
  27. package/dist/harness/reserved-sections.js +2 -0
  28. package/dist/harness/seam-host.js +139 -26
  29. package/dist/harness/settings.js +2 -2
  30. package/dist/harness/tools.js +70 -0
  31. package/dist/jobs/fire.js +33 -11
  32. package/dist/jobs/skills-refs.js +40 -0
  33. package/dist/sandbox/policy.js +14 -0
  34. package/dist/server/blocks/answers.js +9 -3
  35. package/dist/server/conversation-ops.js +21 -3
  36. package/dist/server/helpers.js +1 -1
  37. package/dist/server/host-bridge.js +6 -1
  38. package/dist/server/notifications.js +6 -0
  39. package/dist/server/routes/meta.js +8 -3
  40. package/dist/server/routes/submissions.js +5 -0
  41. package/dist/server/routes/surfaces.js +3 -1
  42. package/dist/server/routes/users.js +7 -0
  43. package/dist/server/schema-fixture.js +168 -0
  44. package/dist/server/session-meta.js +12 -10
  45. package/dist/settings/core-sections.js +9 -0
  46. package/dist/settings/registry.js +6 -0
  47. package/dist/skills/index.js +88 -19
  48. package/node_modules/@sigrun/shared/CHANGELOG.md +9 -1
  49. package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
  50. package/node_modules/@sigrun/shared/dist/access.js +8 -0
  51. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
  52. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
  53. package/node_modules/@sigrun/shared/package.json +3 -2
  54. package/package.json +9 -7
  55. package/webui/dist/assets/{index-DWmsAQl2.js → index-BtczbUXo.js} +1 -1
  56. package/webui/dist/index.html +1 -1
@@ -0,0 +1,579 @@
1
+ /**
2
+ * The curator run driver (curator P6, spec §6.5): the host-side core
3
+ * behind `host.curation.forkReview` and `host.curation.digestRun`.
4
+ *
5
+ * A curator run is ONE submission on ONE internal conversation whose
6
+ * creating commit carries the run doc (`CurationRunDoc` — A6) and the
7
+ * internal marker (`markInternal` — A2), whose agent selects the gate
8
+ * (`sigrun.curation-gate` — A8), and whose every mutating tool call
9
+ * executes under the store-user/run-allow discipline A6/A8 built. This
10
+ * module creates that conversation (a fork of the reviewed parent, or a
11
+ * fresh digest conversation) and DRIVES the run:
12
+ *
13
+ * - aborts at `maxToolCalls` TOOL RESULTS (the budget counts committed
14
+ * results, not offered calls — a blocked call is free);
15
+ * - aborts at `deadlineMs` (wall clock from the producer's start);
16
+ * - aborts when the caller's `signal` fires;
17
+ * - aborts when the PARENT's owner, sharing, head (reset/compaction)
18
+ * or admission state changes from the values captured at start —
19
+ * the review reviews the conversation it saw (→ `cancelled`);
20
+ * - collects `details.curation` from the run's tool results into
21
+ * `changes` (tool-result order), and the final assistant text into
22
+ * `text`;
23
+ * - stamps the `reviewId` on every feed entry the run causes — through
24
+ * the run doc, which A6's tool reads for exactly that stamp.
25
+ *
26
+ * The agent rebuild (fork reviews) follows the cache-parity spike
27
+ * exactly: rebuilt from the parent's STORED `pi.agent` — model,
28
+ * thinkingLevel (explicit), extensions as `{name}` objects plus the
29
+ * gate, tools, modelTools (`agent.modelTools ?? undefined`) — which
30
+ * gives a byte-identical prefix (a section change costs exactly one
31
+ * `pi.system` tail patch and still hits the cache fully).
32
+ *
33
+ * `maxOutputTokens` is an ADVISORY bound: pi-durable 1.4.0 exposes no
34
+ * per-submission output cap (no `maxTokens` on the stream options), so
35
+ * the driver cannot hand it to the provider. It is validated and
36
+ * recorded; the wall-clock and tool-call budgets remain the enforced
37
+ * limits. (Deviation recorded in the A9 report — never silent.)
38
+ */
39
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
40
+ import { AgentDoc, AssistantEntry, LiveDoc, ToolResultEntry, } from "@earendil-works/pi-durable";
41
+ import { ensureRequestId, recordAuthor } from "./attribution.js";
42
+ import { isInternal, markInternal } from "./internal.js";
43
+ import { curationRun, CurationRunDoc } from "./run-doc.js";
44
+ import { CURATION_GATE_EXTENSION } from "./gate.js";
45
+ import { SessionMetaDoc } from "../server/session-meta.js";
46
+ /** The target-id resolver for collected changes (the host installs it). */
47
+ let targetOfTool = (tool) => tool === "skill_manage" ? "sigrun.skills" : `tool:${tool}`;
48
+ /** The host installs the registry-backed resolver (the qualified ids). */
49
+ export function setChangeTargetResolver(resolver) {
50
+ targetOfTool = resolver;
51
+ }
52
+ /** Validate the shared RunOpts invariants (the producers' fail-loud gate). */
53
+ export function checkRunOpts(opts) {
54
+ if (opts.allow.length === 0) {
55
+ throw new Error("a curator run requires a non-empty allow list — the gate's block message names the allowed tools");
56
+ }
57
+ if (opts.maxToolCalls < 1) {
58
+ throw new Error("maxToolCalls must be at least 1");
59
+ }
60
+ if (opts.maxOutputTokens < 1) {
61
+ throw new Error("maxOutputTokens must be at least 1");
62
+ }
63
+ if (opts.deadlineMs < 1) {
64
+ throw new Error("deadlineMs must be at least 1");
65
+ }
66
+ if (opts.reviewId === "") {
67
+ throw new Error("reviewId must not be empty");
68
+ }
69
+ }
70
+ /** A `{name}` object (the AgentChange member spelling for extensions/tools). */
71
+ function named(name) {
72
+ return { name };
73
+ }
74
+ /** SAFETY: pi-durable's applyChange reads only `item.name` from each member
75
+ * of an extensions/tools/modelTools array (harness/agent.js `names()`), so a
76
+ * bare `{name}` object satisfies every field the store ever reads; the cast
77
+ * only bridges the compile-time ToolRegistration shape. */
78
+ function namedTools(names) {
79
+ // SAFETY: cast — the store reads only `name` (see the comment above).
80
+ return names.map(named);
81
+ }
82
+ /**
83
+ * The cache-parity agent rebuild: the parent's stored `pi.agent` with
84
+ * the gate appended to the extension selection. Every field is rebuilt
85
+ * from the STORED state (the spike's recipe) — model, thinkingLevel
86
+ * (explicit), extensions as `{name}` objects (plus the gate), tools,
87
+ * modelTools — so the fork's prompt prefix is byte-identical to the
88
+ * parent's except the gate's (hooks-only, no sections) contribution.
89
+ */
90
+ export function rebuildAgentWithGate(stored) {
91
+ const gate = named(CURATION_GATE_EXTENSION);
92
+ const extensions = stored?.extensions;
93
+ let rebuiltExtensions;
94
+ if (extensions === undefined || extensions === null) {
95
+ // An unset selection follows the host default; an {add} edit adds
96
+ // the gate WITHOUT pinning the default (a pin would freeze a
97
+ // later default change into this run).
98
+ rebuiltExtensions = { add: [gate] };
99
+ }
100
+ else if (Array.isArray(extensions)) {
101
+ rebuiltExtensions = [
102
+ ...extensions.map(named),
103
+ gate,
104
+ ];
105
+ }
106
+ else {
107
+ const edit = extensions;
108
+ rebuiltExtensions = {
109
+ ...(edit.add === undefined
110
+ ? {}
111
+ : { add: [...edit.add.map(named), gate] }),
112
+ ...(edit.remove === undefined
113
+ ? {}
114
+ : { remove: edit.remove.map(named) }),
115
+ };
116
+ }
117
+ const tools = stored?.tools;
118
+ const modelTools = stored?.modelTools;
119
+ return {
120
+ ...(stored?.model === undefined || stored?.model === null
121
+ ? {}
122
+ : { model: stored.model }),
123
+ ...(stored?.thinkingLevel === undefined || stored?.thinkingLevel === null
124
+ ? {}
125
+ : {
126
+ thinkingLevel: stored.thinkingLevel,
127
+ }),
128
+ extensions: rebuiltExtensions,
129
+ ...(tools === undefined || tools === null
130
+ ? {}
131
+ : Array.isArray(tools)
132
+ ? { tools: namedTools(tools) }
133
+ : {
134
+ tools: {
135
+ remove: namedTools(tools.remove),
136
+ },
137
+ }),
138
+ ...(modelTools === undefined || modelTools === null
139
+ ? {}
140
+ : Array.isArray(modelTools)
141
+ ? { modelTools: namedTools(modelTools) }
142
+ : {
143
+ modelTools: {
144
+ ...(modelTools.remove === undefined
145
+ ? {}
146
+ : {
147
+ remove: namedTools(modelTools.remove),
148
+ }),
149
+ ...(modelTools.add === undefined
150
+ ? {}
151
+ : {
152
+ add: namedTools(modelTools.add),
153
+ }),
154
+ },
155
+ }),
156
+ };
157
+ }
158
+ /** The parent's captured state (the cancel oracle's start values). */
159
+ async function captureParent(harness, id, ctx) {
160
+ const meta = await harness.snapshot(SessionMetaDoc, id, ctx);
161
+ const conversation = await harness.conversation(id, ctx);
162
+ const newest = conversation === undefined
163
+ ? undefined
164
+ : await newestEntry(conversation, ctx);
165
+ let busy = false;
166
+ try {
167
+ const live = await harness.snapshot(LiveDoc, id, ctx);
168
+ busy = live?.run !== undefined;
169
+ }
170
+ catch {
171
+ busy = false;
172
+ }
173
+ return {
174
+ owner: meta?.owner,
175
+ sharing: meta?.sharing,
176
+ head: newest,
177
+ busy,
178
+ };
179
+ }
180
+ /** The text blocks of one assistant entry's message, joined. */
181
+ function assistantText(entry) {
182
+ let text = "";
183
+ for (const message of entry.model ?? []) {
184
+ if (message.role !== "assistant")
185
+ continue;
186
+ // SAFETY: an assistant entry's model array is `[AssistantMessage]`
187
+ // (pi-durable's entries contract); the structural read below only
188
+ // touches role/content, which every Message variant carries.
189
+ const content = message.content;
190
+ if (typeof content === "string") {
191
+ text += content;
192
+ continue;
193
+ }
194
+ for (const block of content) {
195
+ if (block.type === "text" && typeof block.text === "string") {
196
+ text += block.text;
197
+ }
198
+ }
199
+ }
200
+ return text;
201
+ }
202
+ /** Whether `id` is strictly after `anchor` in the session's ascending id order. */
203
+ function isAfter(id, anchor) {
204
+ if (anchor === undefined)
205
+ return true;
206
+ return id > anchor;
207
+ }
208
+ /** A stable comparison key for the sharing state. */
209
+ function sharingKey(sharing) {
210
+ return sharing === undefined ? "" : JSON.stringify(sharing);
211
+ }
212
+ /** The newest visible entry of one conversation, or undefined. */
213
+ async function newestEntry(conversation, ctx) {
214
+ const page = await conversation.entries({}, 1, undefined, ctx);
215
+ return page.items[0]?.id;
216
+ }
217
+ /** The failed/"not eligible" shape. */
218
+ function notEligible(why) {
219
+ return {
220
+ status: "failed",
221
+ text: "",
222
+ changes: [],
223
+ error: `not eligible: ${why}`,
224
+ };
225
+ }
226
+ /** The result of one stopped run, classified. */
227
+ function stoppedResult(reason) {
228
+ switch (reason.kind) {
229
+ case "deadline":
230
+ return {
231
+ status: "deadline",
232
+ text: "",
233
+ changes: [],
234
+ error: "the run exceeded its deadline",
235
+ };
236
+ case "signal":
237
+ return {
238
+ status: "cancelled",
239
+ text: "",
240
+ changes: [],
241
+ error: "the caller's signal fired",
242
+ };
243
+ case "maxToolCalls":
244
+ return {
245
+ status: "cancelled",
246
+ text: "",
247
+ changes: [],
248
+ error: "the run exceeded its maxToolCalls budget",
249
+ };
250
+ case "parent":
251
+ return {
252
+ status: "cancelled",
253
+ text: "",
254
+ changes: [],
255
+ error: `the parent changed under review: ${reason.what}`,
256
+ };
257
+ }
258
+ }
259
+ /**
260
+ * Drive one curator run: submit `opts.instruction` on the prepared run
261
+ * conversation and supervise it to settlement under the RunOpts bounds.
262
+ * `prepare` resolves the run conversation (fork or fresh); `parent`
263
+ * (fork reviews) is watched for drift.
264
+ */
265
+ export async function driveRun(deps, prepare, opts, parent) {
266
+ const { harness } = deps;
267
+ // The stop lane: one promise; the first cause to fire aborts the run
268
+ // and classifies the result.
269
+ let stopped;
270
+ let releaseStop;
271
+ const stoppedPromise = new Promise((resolve) => {
272
+ releaseStop = resolve;
273
+ });
274
+ const stop = (reason) => {
275
+ if (stopped !== undefined)
276
+ return;
277
+ stopped = reason;
278
+ releaseStop?.();
279
+ };
280
+ const timer = setTimeout(() => stop({ kind: "deadline" }), opts.deadlineMs);
281
+ const onAbort = () => stop({ kind: "signal" });
282
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
283
+ // The commit watcher: publications carry the entry/submission/doc
284
+ // records; two watches run in ONE listener — (a) this run's tool
285
+ // results (the maxToolCalls budget), and (b) the PARENT's drift.
286
+ // Both are synchronous reads of the publication's own changes (the
287
+ // subscribeCommits listener contract: no blocking, no Session calls).
288
+ //
289
+ // Parent drift, precisely: an ENTRY change on the parent beyond the
290
+ // start-captured head (a reset/compaction marker or a newly admitted
291
+ // run's user entry — "the conversation was reset or compacted" /
292
+ // "a new run was admitted"), a SUBMISSION change on the parent (the
293
+ // admission state moving), or a SESSION-META DOCUMENT change on the
294
+ // parent (owner or sharing). Entry ids ascend, so `id > start.head`
295
+ // is the beyond-the-captured-head test; a document change reads the
296
+ // kind alone (the read of the value is still the publication's own
297
+ // committed record — no Session API).
298
+ let toolResults = 0;
299
+ // The parent's start state, captured BEFORE the fork exists (the
300
+ // fork's own creating commit touches the parent's nothing — a fork
301
+ // writes only the NEW conversation's rows).
302
+ const start = parent === undefined
303
+ ? undefined
304
+ : await captureParent(harness, parent, BACKGROUND_CONTEXT);
305
+ // The run conversation's id — bound after `prepare` resolves; the
306
+ // watcher below reads it live so the budget count starts at the
307
+ // run's FIRST tool result and never sees the parent's own rows.
308
+ let runId;
309
+ let watcher;
310
+ const watch = () => {
311
+ watcher?.();
312
+ watcher = harness.subscribeCommits((publication) => {
313
+ for (const change of publication.changes) {
314
+ if (change.type === "entry") {
315
+ if (runId !== undefined &&
316
+ change.value.conversationId === runId &&
317
+ change.value.kind === ToolResultEntry.kind) {
318
+ toolResults += 1;
319
+ if (toolResults >= opts.maxToolCalls) {
320
+ stop({ kind: "maxToolCalls" });
321
+ }
322
+ }
323
+ if (parent !== undefined && start !== undefined) {
324
+ if (change.value.conversationId === parent &&
325
+ change.value.id !== start.head &&
326
+ isAfter(change.value.id, start.head)) {
327
+ stop({
328
+ kind: "parent",
329
+ what: change.value.kind === "pi.reset"
330
+ ? "the conversation was reset or compacted"
331
+ : "a new run was admitted",
332
+ });
333
+ }
334
+ }
335
+ continue;
336
+ }
337
+ if (parent === undefined || start === undefined)
338
+ continue;
339
+ if (change.type === "submission") {
340
+ if (change.value.conversationId === parent) {
341
+ stop({ kind: "parent", what: "a new run was admitted" });
342
+ }
343
+ continue;
344
+ }
345
+ if (change.type === "document") {
346
+ if (change.conversationId === parent &&
347
+ change.record.kind === "sigrun.session-meta" &&
348
+ change.value !== null) {
349
+ // Compare the COMMITTED VALUE's owner/sharing fields —
350
+ // the activity stamper writes `lastActivityAt` on the
351
+ // same doc after every settle (a benign change that
352
+ // must never cancel a review). SAFETY: the value is
353
+ // the adopted JSON revision; the structural read
354
+ // touches two optional fields only.
355
+ const value = change.value;
356
+ if (value.owner !== start.owner ||
357
+ sharingKey(value.sharing) !== sharingKey(start.sharing)) {
358
+ stop({
359
+ kind: "parent",
360
+ what: "the parent's owner or sharing changed",
361
+ });
362
+ }
363
+ }
364
+ }
365
+ }
366
+ });
367
+ };
368
+ watch();
369
+ const cleanup = () => {
370
+ clearTimeout(timer);
371
+ opts.signal?.removeEventListener("abort", onAbort);
372
+ watcher?.();
373
+ watcher = undefined;
374
+ };
375
+ try {
376
+ const conversation = await prepare();
377
+ runId = conversation.id;
378
+ // The run's instruction is an input like any other (the A1 pin
379
+ // applies): it carries a requestId and records the curated user as
380
+ // its author on both keys, so the run's own transcript attributes
381
+ // consistently with user submissions.
382
+ // The run's user (the creating commit wrote the run doc): the
383
+ // instruction is submitted on their behalf, so it records them as
384
+ // author on both keys exactly like a user input (A1's pin).
385
+ const run = await curationRun(harness, conversation.id, BACKGROUND_CONTEXT);
386
+ const requestId = ensureRequestId();
387
+ if (run?.user !== undefined) {
388
+ await recordAuthor(conversation, { requestId }, run.user, BACKGROUND_CONTEXT);
389
+ }
390
+ const submission = await conversation.submit({ type: "input", content: opts.instruction, requestId }, BACKGROUND_CONTEXT);
391
+ if (run?.user !== undefined) {
392
+ await recordAuthor(conversation, { submissionId: submission.id }, run.user, BACKGROUND_CONTEXT);
393
+ }
394
+ // Race the settlement against the stop lane. The wait's own
395
+ // context is never cancelled (the run conversation gets a real
396
+ // abort below instead), so an aborted settlement can only mean the
397
+ // stop lane fired first.
398
+ const settled = await Promise.race([
399
+ submission.wait(BACKGROUND_CONTEXT).then((record) => ({ record }), () => undefined),
400
+ stoppedPromise.then(() => undefined),
401
+ ]);
402
+ if (settled === undefined) {
403
+ // The stop lane won (or the wait itself failed): abort the run
404
+ // conversation, then classify. The abort withdraws the queued
405
+ // input and marks the live task; the internal conversation is
406
+ // never listed, so nothing else observes the partial run.
407
+ await conversation.abort(BACKGROUND_CONTEXT).catch(() => undefined);
408
+ return stoppedResult(stopped ?? { kind: "deadline" });
409
+ }
410
+ const record = settled.record;
411
+ if (record.status === "unanswered") {
412
+ return {
413
+ status: "failed",
414
+ text: "",
415
+ changes: [],
416
+ error: `the run ended unanswered: ${record.reason}`,
417
+ };
418
+ }
419
+ const changes = await collectChanges(conversation);
420
+ const text = await answerText(conversation, record.answer);
421
+ return { status: "completed", text, changes };
422
+ }
423
+ catch (error) {
424
+ return {
425
+ status: "failed",
426
+ text: "",
427
+ changes: [],
428
+ error: error instanceof Error ? error.message : String(error),
429
+ };
430
+ }
431
+ finally {
432
+ cleanup();
433
+ }
434
+ }
435
+ /** Collect the run's `details.curation` changes, in entry order. */
436
+ async function collectChanges(conversation) {
437
+ const page = await conversation.entries({ order: "ascending" }, 500, undefined, BACKGROUND_CONTEXT);
438
+ const changes = [];
439
+ for (const entry of page.items) {
440
+ if (entry.kind !== ToolResultEntry.kind)
441
+ continue;
442
+ for (const message of entry.model ?? []) {
443
+ if (message.role !== "toolResult")
444
+ continue;
445
+ const details = message.details;
446
+ const curation = details?.curation;
447
+ if (curation === undefined ||
448
+ typeof curation.changeId !== "string" ||
449
+ typeof curation.summary !== "string") {
450
+ continue;
451
+ }
452
+ changes.push({
453
+ changeId: curation.changeId,
454
+ summary: curation.summary,
455
+ target: targetOfTool(message.toolName),
456
+ });
457
+ }
458
+ }
459
+ return changes;
460
+ }
461
+ /** The run's answer entry text (the settlement's answer, or ""). */
462
+ async function answerText(conversation, answer) {
463
+ if (answer === undefined)
464
+ return "";
465
+ // The answer entry is an assistant entry: read it through the
466
+ // conversation's own entry query (maxEntryId lands the answer).
467
+ const page = await conversation.entries({ maxEntryId: answer }, 1, undefined, BACKGROUND_CONTEXT);
468
+ const entry = page.items[0];
469
+ if (entry === undefined || entry.id !== answer)
470
+ return "";
471
+ void AssistantEntry;
472
+ return assistantText(entry);
473
+ }
474
+ /**
475
+ * The fork-review producer: eligibility checks, the fork with the
476
+ * cache-parity agent rebuild, and the run doc in the creating commit.
477
+ * Returns the failed/"not eligible" shape without creating anything
478
+ * when the parent refuses.
479
+ */
480
+ export async function forkReviewRun(deps, id, opts) {
481
+ checkRunOpts(opts);
482
+ const { harness } = deps;
483
+ const parent = await harness.conversation(id, BACKGROUND_CONTEXT);
484
+ if (parent === undefined) {
485
+ return notEligible("the conversation does not exist");
486
+ }
487
+ if (await isInternal(harness, id, BACKGROUND_CONTEXT)) {
488
+ return notEligible("the conversation is internal");
489
+ }
490
+ const meta = await harness.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT);
491
+ const owner = meta?.owner;
492
+ if (owner === undefined) {
493
+ return notEligible("the conversation has no owner");
494
+ }
495
+ const newest = await newestEntry(parent, BACKGROUND_CONTEXT);
496
+ if (newest === undefined) {
497
+ return notEligible("the conversation has no history to review");
498
+ }
499
+ // The model must be able to emit tool calls: a resolved agent with a
500
+ // model and a non-empty offered tool set. (pi-ai exposes no
501
+ // per-model "supports tools" flag; an empty offered set is the
502
+ // honest proxy — the model has nothing to call.)
503
+ const agent = await parent.agent(BACKGROUND_CONTEXT);
504
+ if (agent.model === undefined) {
505
+ return notEligible("the conversation has no model");
506
+ }
507
+ if (agent.tools.length === 0) {
508
+ return notEligible("the conversation's model has no tools to call");
509
+ }
510
+ // SAFETY: AgentDoc's stored state is the JsonObject mirror of
511
+ // pi-durable's AgentState (typed names/arrays); the record is only
512
+ // ever read field-by-field below through the same casts
513
+ // rebuildAgentWithGate applies, so the widening is read-only.
514
+ const stored = (await harness.snapshot(AgentDoc, id, BACKGROUND_CONTEXT));
515
+ const fork = await parent.fork(newest, {
516
+ ownership: { kind: "ownerless" },
517
+ agent: rebuildAgentWithGate(stored),
518
+ init: async (tx, forkId) => {
519
+ await markInternal(tx, forkId, owner);
520
+ const doc = await tx.doc(CurationRunDoc, forkId);
521
+ doc.user = owner;
522
+ doc.reviewId = opts.reviewId;
523
+ doc.allow = [...opts.allow];
524
+ doc.parent = id;
525
+ // The fork's copied meta carries the parent's sharing; an
526
+ // internal run is nobody's shared copy (A2's invisibility
527
+ // makes the key inert, but the doc should not carry a
528
+ // stale sharing set either). SAFETY: the tracked-draft
529
+ // write pattern mutates through a structural view; deleting
530
+ // the optional key is the same patch forkOp's own reset
531
+ // applies.
532
+ const forkMeta = await tx.doc(SessionMetaDoc, forkId);
533
+ if (forkMeta.sharing !== undefined) {
534
+ // SAFETY: cast — the structural view above (comment says why).
535
+ delete forkMeta.sharing;
536
+ }
537
+ },
538
+ }, BACKGROUND_CONTEXT);
539
+ return await driveRun(deps, async () => fork, opts, id);
540
+ }
541
+ /**
542
+ * The digest-run producer: a fresh internal conversation owned by
543
+ * `user`, model = `opts.model` ?? the server default, selection =
544
+ * default + gate, same run doc and driver.
545
+ */
546
+ export async function digestRunRun(deps, user, opts, defaultModel, defaultAgent) {
547
+ checkRunOpts(opts);
548
+ const { harness } = deps;
549
+ const conversation = await harness.createConversation({
550
+ ownership: { kind: "ownerless" },
551
+ agent: {
552
+ ...defaultAgent(),
553
+ model: opts.model === undefined
554
+ ? defaultModel
555
+ : parseModel(opts.model, defaultModel),
556
+ // The gate rides the DEFAULT selection (an {add} edit, never
557
+ // a pin — a digest has no parent selection to inherit).
558
+ extensions: { add: [named(CURATION_GATE_EXTENSION)] },
559
+ },
560
+ init: async (tx, id) => {
561
+ await markInternal(tx, id, user);
562
+ const doc = await tx.doc(CurationRunDoc, id);
563
+ doc.user = user;
564
+ doc.reviewId = opts.reviewId;
565
+ doc.allow = [...opts.allow];
566
+ },
567
+ }, BACKGROUND_CONTEXT);
568
+ return await driveRun(deps, async () => conversation, { ...opts, instruction: opts.input });
569
+ }
570
+ /**
571
+ * Parse `provider/model` (the wire grammar), falling back to the
572
+ * default when the string has no provider part.
573
+ */
574
+ function parseModel(model, defaultModel) {
575
+ const slash = model.indexOf("/");
576
+ if (slash <= 0)
577
+ return defaultModel;
578
+ return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
579
+ }
@@ -0,0 +1,30 @@
1
+ /** Deliberately conservative: learned text is instructions, not a secret store. */
2
+ const RULES = {
3
+ credential: [
4
+ /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[a-z0-9_-]{12,}/i,
5
+ /\bsk-[a-z0-9_-]{12,}/i,
6
+ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
7
+ ],
8
+ injection: [
9
+ /\bignore\s+(?:all\s+|any\s+)?(?:previous|prior|above)\s+instructions\b/i,
10
+ /\bdisregard\s+(?:all\s+)?(?:previous|prior)\s+instructions\b/i,
11
+ /\bsystem\s+prompt\s*:\s*you\s+(?:are|must)\b/i,
12
+ ],
13
+ exfiltration: [
14
+ /\b(?:send|upload|post|transmit)\b[^\n]*(?:~\/\.ssh|\/etc\/passwd|credentials?|secrets?)[^\n]*https?:\/\//i,
15
+ /\b(?:curl|wget)\b[^\n]*(?:~\/\.ssh|\/etc\/passwd|credentials?|secrets?)/i,
16
+ ],
17
+ };
18
+ const ORDER = ["credential", "injection", "exfiltration"];
19
+ /** Scan line-by-line so a refusal can name the exact line to revise. */
20
+ export function scanContent(text) {
21
+ const lines = text.split(/\r?\n/);
22
+ for (const rule of ORDER) {
23
+ for (let index = 0; index < lines.length; index++) {
24
+ if (RULES[rule].some((pattern) => pattern.test(lines[index]))) {
25
+ return { ok: false, line: index + 1, rule };
26
+ }
27
+ }
28
+ }
29
+ return { ok: true };
30
+ }