@sigrun/ext-curator 0.0.0-stage → 0.2.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.
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Overlay drift detection (`src/steps/overlays.ts`, Task B3 — the
3
+ * DETECTION half; the re-check model step is Task B4's).
4
+ *
5
+ * `detectOverlayDrift` walks every stored overlay and compares the
6
+ * hash it was written against (`upstreamHash`) with the upstream as it
7
+ * stands (`upstream(user, key)?.hash`):
8
+ *
9
+ * - ABSENT upstream → `overlays(user).markOrphans(present, now)`: the
10
+ * host-side pass stamps `orphanedSince` (or clears it when the
11
+ * upstream is back). The present-predicate answers from this pass's
12
+ * upstream reads, so the mark and the drift walk agree on the same
13
+ * observation.
14
+ *
15
+ * - CHANGED hash → the overlay is QUEUED for a re-check in
16
+ * `overlay-recheck-queue.json` under the user's state dir (the file
17
+ * name this repo chose for the seam; the `overlay-recheck` step
18
+ * takes it from there). Queueing is all B3 does — deciding
19
+ * keep/drop/rewrite takes a model and is the next step's business.
20
+ * Keys dedup by their canonical JSON, so a pass that re-observes
21
+ * the same drift twice queues it once.
22
+ *
23
+ * Deterministic by design: reads + one `markOrphans` + one state-file
24
+ * write; no model, no interaction.
25
+ *
26
+ * Task B4 adds the RE-CHECK half: `recheckOverlays` (the loop-2 model
27
+ * step) drains the queue, one `digestRun` per drifted key, and applies
28
+ * the run's verdict — keep (re-anchor the same text on the new hash),
29
+ * drop (remove), rewrite (put the verdict's new text), or unsure (ONE
30
+ * `curator.overlay-question` interaction per skill in ONE new
31
+ * user-owned conversation per pass, applied by `overlayQuestionHandler`
32
+ * from `interactionSettled`). An unparseable verdict changes nothing.
33
+ */
34
+ import { randomUUID } from "node:crypto";
35
+ import { join } from "node:path";
36
+ import { readStateJson, writeStateJson } from "../queue.js";
37
+ import { CURATOR_OVERLAY_QUESTION_KIND } from "../index.js";
38
+ import { digestRunWithBudget, parseVerdict, stepLog, } from "./run.js";
39
+ /**
40
+ * The overlay-recheck queue's file name under the user's state dir —
41
+ * the seam B3 writes and Task B4's `overlay-recheck` step consumes.
42
+ */
43
+ export const RECHECK_QUEUE_FILE = "overlay-recheck-queue.json";
44
+ /** A stable identity for one overlay key (dedup and diffs). */
45
+ export function keyId(key) {
46
+ return JSON.stringify([key.root, key.skill, key.workspace ?? null]);
47
+ }
48
+ /** Read the queued re-check keys; absent/malformed → []. */
49
+ export async function readRecheckQueue(host, user) {
50
+ const parsed = await readStateJson(join(host.stores.user(user).paths.state, RECHECK_QUEUE_FILE), {});
51
+ if (!Array.isArray(parsed.keys))
52
+ return [];
53
+ return parsed.keys.filter((key) => typeof key === "object" &&
54
+ key !== null &&
55
+ typeof key.root === "string" &&
56
+ typeof key.skill === "string");
57
+ }
58
+ /**
59
+ * The queue B4 consumes: `take()` drains (read-and-clear — a taken
60
+ * re-check is the re-check step's to finish), `enqueue` appends deduped.
61
+ */
62
+ export class OverlayRecheckQueue {
63
+ host;
64
+ user;
65
+ constructor(host, user) {
66
+ this.host = host;
67
+ this.user = user;
68
+ }
69
+ /** Append keys, deduped against what is queued (by identity). */
70
+ async enqueue(keys) {
71
+ if (keys.length === 0)
72
+ return;
73
+ const queued = [...(await readRecheckQueue(this.host, this.user))];
74
+ const seen = new Set(queued.map(keyId));
75
+ for (const key of keys) {
76
+ if (seen.has(keyId(key)))
77
+ continue;
78
+ queued.push(key);
79
+ seen.add(keyId(key));
80
+ }
81
+ await writeStateJson(this.host.stores.user(this.user).paths.state, RECHECK_QUEUE_FILE, { keys: queued });
82
+ }
83
+ /** Read and clear the queue (B4's `overlay-recheck` step start). */
84
+ async take() {
85
+ const keys = await readRecheckQueue(this.host, this.user);
86
+ if (keys.length === 0)
87
+ return [];
88
+ await writeStateJson(this.host.stores.user(this.user).paths.state, RECHECK_QUEUE_FILE, { keys: [] });
89
+ return keys;
90
+ }
91
+ }
92
+ /**
93
+ * The overlay-detect step: mark orphans with the host and queue every
94
+ * drifted overlay for a re-check (loop 2 step `overlay-detect`).
95
+ */
96
+ export async function detectOverlayDrift(host, user) {
97
+ const store = host.curation.overlays(user);
98
+ const overlays = await store.list();
99
+ // One upstream read per overlay — the single observation both the
100
+ // orphan mark and the drift comparison answer from.
101
+ const upstreamByKey = new Map();
102
+ for (const overlay of overlays) {
103
+ upstreamByKey.set(keyId(overlay.key), await host.curation.upstream(user, overlay.key));
104
+ }
105
+ await store.markOrphans((key) => upstreamByKey.get(keyId(key)) !== undefined, new Date().toISOString());
106
+ const drifted = overlays
107
+ .filter((overlay) => {
108
+ const upstream = upstreamByKey.get(keyId(overlay.key));
109
+ return upstream !== undefined && upstream.hash !== overlay.upstreamHash;
110
+ })
111
+ .map((overlay) => overlay.key);
112
+ await new OverlayRecheckQueue(host, user).enqueue(drifted);
113
+ }
114
+ /** The overlay re-check's allow list: it writes nothing itself. */
115
+ const RECHECK_ALLOW = ["skill_manage"];
116
+ /** The pending questions file under the user's state dir. */
117
+ const PENDING_QUESTIONS_FILE = "overlay-questions.json";
118
+ /** Structural check: one parsed entry is a QuestionDetails. */
119
+ function isQuestion(entry) {
120
+ return (typeof entry === "object" &&
121
+ entry !== null &&
122
+ typeof entry.user === "string" &&
123
+ typeof entry.key?.root === "string" &&
124
+ typeof entry.key?.skill === "string" &&
125
+ typeof entry.upstreamHash === "string" &&
126
+ typeof entry.overlayText === "string" &&
127
+ typeof entry.callId === "string");
128
+ }
129
+ /** Read the user's pending overlay questions (absent/malformed → empty;
130
+ * a legacy top-level ARRAY reads as v1: every entry lands in `legacy`
131
+ * unchanged). The write shape is always v2. */
132
+ async function readPendingQuestions(host, user) {
133
+ const parsed = await readStateJson(join(host.stores.user(user).paths.state, PENDING_QUESTIONS_FILE), { v: 2, questions: {}, legacy: [] });
134
+ if (Array.isArray(parsed)) {
135
+ return { questions: {}, legacy: parsed.filter(isQuestion) };
136
+ }
137
+ if (typeof parsed !== "object" || parsed === null) {
138
+ return { questions: {}, legacy: [] };
139
+ }
140
+ const raw = parsed;
141
+ const questions = {};
142
+ if (typeof raw.questions === "object" &&
143
+ raw.questions !== null &&
144
+ !Array.isArray(raw.questions)) {
145
+ for (const [id, entry] of Object.entries(raw.questions)) {
146
+ if (isQuestion(entry) &&
147
+ typeof entry.conversationId === "number") {
148
+ questions[id] = entry;
149
+ }
150
+ }
151
+ }
152
+ const legacy = Array.isArray(raw.legacy)
153
+ ? raw.legacy.filter(isQuestion)
154
+ : [];
155
+ return { questions, legacy };
156
+ }
157
+ /** Write the user's pending overlay questions (the v2 shape — legacy
158
+ * questions keep their entries but the file is v2 from here on). */
159
+ async function writePendingQuestions(host, user, file) {
160
+ await writeStateJson(host.stores.user(user).paths.state, PENDING_QUESTIONS_FILE, { v: 2, questions: file.questions, legacy: file.legacy });
161
+ }
162
+ /**
163
+ * The overlay-recheck step (loop 2 step `overlay-recheck`): drain the
164
+ * re-check queue and re-check every drifted overlay — ONE digest run
165
+ * per key, under the daily budget. Verdicts apply immediately
166
+ * (keep/drop/rewrite); "unsure" asks the user (the design's only
167
+ * prompt) and the answer applies through {@link overlayQuestionHandler}.
168
+ * Answers FALSE when the daily budget ran out mid-queue (the pass
169
+ * stops for the day; the keys still queued re-check next pass — the
170
+ * re-check queue only drains as keys complete).
171
+ *
172
+ * Crash-durability posture (the B3 carry): `take()` is read-then-clear,
173
+ * never at-least-once — DETECTION is the system of record; a lost
174
+ * queue entry self-heals on the next detect pass. A crash after a
175
+ * run but before its verdict applied wastes one model run, nothing
176
+ * more (the verdict is re-derived next pass).
177
+ */
178
+ export async function recheckOverlays(deps) {
179
+ const { host, user } = deps;
180
+ const log = stepLog(deps);
181
+ if (deps.budget.left() < 1)
182
+ return false; // the day is spent: stop the pass
183
+ const keys = [...(await new OverlayRecheckQueue(host, user).take())];
184
+ // ONE lazily created question conversation for the whole pass (spec
185
+ // §5.6: one conversation per recheck call, not one per question).
186
+ const conversation = { promised: undefined, seeded: 0 };
187
+ for (const key of keys) {
188
+ if (deps.now().getTime() >= deps.deadlineAt)
189
+ return false;
190
+ const applied = await recheckOne(deps, key, conversation).catch((error) => {
191
+ log(`loop 2: ${user} overlay re-check of ${key.skill} failed: ${error instanceof Error ? error.message : String(error)}`);
192
+ return true;
193
+ });
194
+ if (!applied)
195
+ return false; // out of daily budget: stop for the day
196
+ }
197
+ return true;
198
+ }
199
+ /** Re-check one key: run, parse the verdict, apply it. */
200
+ async function recheckOne(deps, key, conversation) {
201
+ const { host, user } = deps;
202
+ const store = host.curation.overlays(user);
203
+ const overlay = await store.get(key);
204
+ const upstream = await host.curation.upstream(user, key);
205
+ if (overlay === undefined || upstream === undefined)
206
+ return true;
207
+ const run = await digestRunWithBudget(deps, {
208
+ input: recheckInput(overlay.text, overlay.upstreamHash, upstream.body, upstream.hash),
209
+ allow: RECHECK_ALLOW,
210
+ });
211
+ if (run === undefined)
212
+ return false;
213
+ if (run.result.status !== "completed")
214
+ return true;
215
+ const verdict = parseVerdict(run.result.text);
216
+ if (verdict === undefined)
217
+ return true; // unparseable: no change
218
+ const meta = {
219
+ actor: "curator",
220
+ reason: "overlay re-check after upstream drift",
221
+ reviewId: run.reviewId,
222
+ };
223
+ switch (verdict.verdict) {
224
+ case "keep":
225
+ await store.put(key, overlay.text, upstream.hash, meta);
226
+ return true;
227
+ case "drop":
228
+ await store.remove(key, meta);
229
+ return true;
230
+ case "rewrite":
231
+ if (typeof verdict.text !== "string" || verdict.text === "")
232
+ return true;
233
+ await store.put(key, verdict.text, upstream.hash, meta);
234
+ return true;
235
+ case "unsure":
236
+ await askOverlayQuestion(deps, key, overlay.text, upstream.hash, conversation);
237
+ return true;
238
+ default:
239
+ return true; // unknown verdict word: no change
240
+ }
241
+ }
242
+ /** The re-check run's input: the overlay, the version it was written
243
+ * against, and the NEW upstream — the three things the brief names. */
244
+ function recheckInput(overlayText, oldHash, newBody, newHash) {
245
+ return [
246
+ "You are the curator. This overlay (the user's notes for a skill they do not own) was written against an upstream skill that has since changed. Decide what happens to it.",
247
+ "",
248
+ "Your notes for the old upstream:",
249
+ overlayText,
250
+ "",
251
+ "The old upstream's version id:",
252
+ oldHash,
253
+ "",
254
+ "The new upstream in full:",
255
+ newBody,
256
+ "",
257
+ `Its new version id is ${newHash}.`,
258
+ "",
259
+ 'Decide, and answer with ONLY this JSON: {"verdict":"keep"} when the notes still apply to the new upstream (they will be re-anchored to it); {"verdict":"drop"} when the change makes the notes obsolete; {"verdict":"rewrite","text":"…"} when the notes need a rewrite for the new upstream (the new notes text); or {"verdict":"unsure"} when you cannot decide — the user will be asked.',
260
+ ].join("\n");
261
+ }
262
+ /** The zero-signal context the seed call runs under (a tick has no
263
+ * caller signal — the job's deadline is the bound that matters). */
264
+ const SEED_CONTEXT = { abortSignal: undefined };
265
+ /** The conversation's title: named for what it holds. */
266
+ function questionConversationTitle(seeded, skill) {
267
+ return seeded === 1 && skill !== undefined
268
+ ? `Your notes on "${skill}"`
269
+ : `Your notes on ${seeded} skills`;
270
+ }
271
+ /** The id of the pass's question conversation, creating it on the
272
+ * first question (owned by the user, titled for what it holds). */
273
+ async function questionConversation(deps, conversation, skill) {
274
+ if (conversation.promised === undefined) {
275
+ conversation.seeded = 1;
276
+ conversation.promised = deps.host.conversations.create({
277
+ owner: deps.user,
278
+ name: questionConversationTitle(1, skill),
279
+ });
280
+ return conversation.promised;
281
+ }
282
+ const id = await conversation.promised;
283
+ conversation.seeded += 1;
284
+ // A second question renames the conversation to cover what it now
285
+ // holds; a failed rename never loses the question (logged only).
286
+ try {
287
+ await deps.host.conversations.patchMeta(id, {
288
+ name: questionConversationTitle(conversation.seeded),
289
+ });
290
+ }
291
+ catch (error) {
292
+ stepLog(deps)(`loop 2: renaming the overlay-question conversation failed: ${error instanceof Error ? error.message : String(error)}`);
293
+ }
294
+ return id;
295
+ }
296
+ /**
297
+ * Ask ONE `curator.overlay-question` for a drifted overlay: the pass's
298
+ * shared NEW conversation (created lazily, owned by the user) gets the
299
+ * question, and the pending file keeps one open question per skill —
300
+ * the bounding rule (spec §5.5, ruling 8: no timers, no auto-expiry;
301
+ * keying and terminal statuses bound the file, never age).
302
+ *
303
+ * Order per question: create the conversation if needed, cancel the
304
+ * skill's old open question (a re-ask replaces it; a GONE old
305
+ * conversation — settle resolves false or rejects — still lets the new
306
+ * question be asked), seed, write the file. Only then, for the first
307
+ * question of the pass, send the owner a notice linking to the
308
+ * conversation; a FAILED notice never loses the question (try/catch-log).
309
+ */
310
+ async function askOverlayQuestion(deps, key, overlayText, upstreamHash, conversation) {
311
+ const { host, user } = deps;
312
+ const log = stepLog(deps);
313
+ const file = await readPendingQuestions(host, user);
314
+ const id = keyId(key);
315
+ const old = file.questions[id];
316
+ const conversationId = await questionConversation(deps, conversation, key.skill);
317
+ if (old !== undefined) {
318
+ try {
319
+ await host.settleInteraction(old.conversationId, old.callId, "cancelled", SEED_CONTEXT);
320
+ }
321
+ catch (error) {
322
+ log(`loop 2: cancelling the previous overlay question for ${key.skill} failed: ${error instanceof Error ? error.message : String(error)}`);
323
+ }
324
+ }
325
+ const callId = `overlay-question-${randomUUID()}`;
326
+ const details = {
327
+ user,
328
+ key,
329
+ upstreamHash,
330
+ overlayText,
331
+ callId,
332
+ };
333
+ const record = {
334
+ kind: CURATOR_OVERLAY_QUESTION_KIND,
335
+ callId,
336
+ status: "pending",
337
+ createdAt: deps.now().getTime(),
338
+ body: questionBody(key, overlayText),
339
+ // SAFETY: cast — `details` is opaque extension-owned JSON by
340
+ // contract; the structural QuestionDetails shape is JSON-safe.
341
+ details: details,
342
+ };
343
+ await host.seedInteraction(conversationId, record, SEED_CONTEXT);
344
+ await writePendingQuestions(host, user, {
345
+ questions: {
346
+ ...file.questions,
347
+ [id]: { ...details, conversationId: conversationId },
348
+ },
349
+ legacy: file.legacy,
350
+ });
351
+ if (conversation.seeded !== 1)
352
+ return; // only the first question notifies
353
+ try {
354
+ await host.notify({
355
+ user,
356
+ title: "Notes to review",
357
+ body: `The curator has a question about your notes on "${key.skill}".`,
358
+ kind: CURATOR_OVERLAY_QUESTION_KIND,
359
+ target: { conversation: conversationId },
360
+ });
361
+ }
362
+ catch (error) {
363
+ log(`loop 2: the overlay-question notice for ${user} failed: ${error instanceof Error ? error.message : String(error)}`);
364
+ }
365
+ }
366
+ /** The question's form body: exactly keep / drop. The overlay text
367
+ * rides a section (a form's children are fields and sections only;
368
+ * free blocks live inside a section — the interactions validator's
369
+ * rule). */
370
+ function questionBody(key, overlayText) {
371
+ return {
372
+ v: 1,
373
+ root: {
374
+ type: "form",
375
+ mode: "submit",
376
+ bind: "answer",
377
+ submitLabel: "Answer",
378
+ children: [
379
+ {
380
+ type: "section",
381
+ title: `The upstream skill "${key.skill}" changed under your notes`,
382
+ children: [
383
+ { type: "markdown", text: overlayText },
384
+ {
385
+ type: "choice",
386
+ name: "verdict",
387
+ label: "Keep the notes?",
388
+ options: [
389
+ { id: "keep", label: "Keep the notes" },
390
+ { id: "drop", label: "Drop the notes" },
391
+ ],
392
+ },
393
+ ],
394
+ },
395
+ ],
396
+ },
397
+ };
398
+ }
399
+ /**
400
+ * The `interactionSettled` half: apply a settled overlay question's
401
+ * answer. EVERY terminal status (answered, skipped, cancelled, expired)
402
+ * drops the entry from the pending file — the file is bounded by
403
+ * terminal statuses (one open entry per skill key). Only ANSWERED
404
+ * records with a `verdict` of keep/drop apply; `keep` re-anchors the
405
+ * remembered overlay text on the remembered hash; `drop` removes the
406
+ * overlay. The callId is looked up in BOTH the v2 `questions` map and
407
+ * the migrated `legacy` list, so pre-v2 questions stay answerable
408
+ * where they were asked. Idempotent per callId (an applied question
409
+ * leaves the pending file).
410
+ */
411
+ export function overlayQuestionHandler(host) {
412
+ return {
413
+ interactionSettled: async (event, _api, _context) => {
414
+ if (event.kind !== CURATOR_OVERLAY_QUESTION_KIND)
415
+ return;
416
+ if (event.status !== "answered" &&
417
+ event.status !== "skipped" &&
418
+ event.status !== "cancelled" &&
419
+ event.status !== "expired")
420
+ return;
421
+ const found = await findQuestion(host, event.callId);
422
+ if (found === undefined)
423
+ return;
424
+ const { user, question } = found;
425
+ if (event.status === "answered") {
426
+ const verdict = event.answer?.["verdict"];
427
+ if (verdict === "keep" || verdict === "drop") {
428
+ const store = host.curation.overlays(user);
429
+ const meta = {
430
+ actor: "curator",
431
+ reason: `user chose to ${verdict} the overlay`,
432
+ };
433
+ if (verdict === "keep") {
434
+ await store.put(question.key, question.overlayText, question.upstreamHash, meta);
435
+ }
436
+ else {
437
+ await store.remove(question.key, meta);
438
+ }
439
+ }
440
+ }
441
+ await dropQuestion(host, user, question.callId);
442
+ },
443
+ };
444
+ }
445
+ /** Find one pending question by callId — the handler scans every user's
446
+ * file (the event names the callId, not the user), both the v2
447
+ * `questions` map and the migrated `legacy` list. */
448
+ async function findQuestion(host, callId) {
449
+ for (const row of await host.users.list()) {
450
+ const file = await readPendingQuestions(host, row.user);
451
+ for (const question of Object.values(file.questions)) {
452
+ if (question.callId === callId)
453
+ return { user: row.user, question };
454
+ }
455
+ for (const question of file.legacy) {
456
+ if (question.callId === callId)
457
+ return { user: row.user, question };
458
+ }
459
+ }
460
+ return undefined;
461
+ }
462
+ /** Remove a question from its user's pending file — from BOTH the v2
463
+ * map (by matching callId, in case the file drifted) and the legacy
464
+ * list. */
465
+ async function dropQuestion(host, user, callId) {
466
+ const file = await readPendingQuestions(host, user);
467
+ const questions = {};
468
+ for (const [id, question] of Object.entries(file.questions)) {
469
+ if (question.callId !== callId)
470
+ questions[id] = question;
471
+ }
472
+ await writePendingQuestions(host, user, {
473
+ questions,
474
+ legacy: file.legacy.filter((question) => question.callId !== callId),
475
+ });
476
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The model-step plumbing (`src/steps/run.ts`, Task B4): the shared
3
+ * dependencies every loop-2 MODEL step runs under, and the one
4
+ * `digestRun` helper they all spend their budget through.
5
+ *
6
+ * The budget rule (the brief): every model step is ONE `digestRun` on
7
+ * `CuratorSettings.idleModel` and decrements the per-user daily budget
8
+ * (`dailyRuns`); at the cap the pass stops for the day. {@link RunBudget}
9
+ * is the cursor-backed handle: `left()` answers what remains, `spend()`
10
+ * takes one (persisting the increment through the caller's callback) or
11
+ * answers false when the day is spent.
12
+ *
13
+ * The verdict rule (the brief): steps that need a verdict ask for it as
14
+ * the run's final text in a fixed JSON shape and parse `result.text`;
15
+ * {@link parseVerdict} accepts the whole trimmed text, or the outermost
16
+ * `{…}` span inside it (models are told to answer with the JSON alone —
17
+ * the span fallback tolerates a stray lead-in line). An unparseable
18
+ * verdict is the caller's "no change".
19
+ */
20
+ import type { CurationRunResult, SigrunHost } from "@sigrun/sigrun/extension";
21
+ /** The tool-call budget one model step's digest run runs under. */
22
+ export declare const MODEL_STEP_MAX_TOOL_CALLS = 16;
23
+ /** The output-token budget one model step's digest run runs under. */
24
+ export declare const MODEL_STEP_MAX_OUTPUT_TOKENS = 4096;
25
+ /**
26
+ * The per-user daily model-run budget. `spend` persists its increment
27
+ * (the cursor's `dailyRunsUsed`) before the run starts — a crash after
28
+ * a spend may waste a run, it never overspends the day.
29
+ */
30
+ export interface RunBudget {
31
+ /** How many daily runs remain (0 = the day is spent). */
32
+ left(): number;
33
+ /** Take one run; false when none remain (nothing was taken). */
34
+ spend(): Promise<boolean>;
35
+ }
36
+ /** What every loop-2 model step receives. */
37
+ export interface ModelStepDeps {
38
+ /** The host the step reads and writes through. */
39
+ readonly host: SigrunHost;
40
+ /** The curated user. */
41
+ readonly user: string;
42
+ /** The clock (tests fake it). */
43
+ readonly now: () => Date;
44
+ /** The tick's deadline (epoch ms); runs and loops respect it. */
45
+ readonly deadlineAt: number;
46
+ /** The daily model-run budget. */
47
+ readonly budget: RunBudget;
48
+ /** One line per unexpected condition; defaults to `console.error`. */
49
+ readonly log?: (message: string) => void;
50
+ }
51
+ /** The default step logger. */
52
+ export declare function stepLog(deps: ModelStepDeps): (message: string) => void;
53
+ /** What {@link digestRunWithBudget} submits. */
54
+ export interface DigestRunInput {
55
+ /** The run's only input (the material plus its instruction). */
56
+ readonly input: string;
57
+ /** The run's allow list (non-empty — the gate names it). */
58
+ readonly allow: readonly string[];
59
+ }
60
+ /** One spent digest run: its result and the review id its changes carry. */
61
+ export interface DigestRun {
62
+ readonly result: CurationRunResult;
63
+ /** The run's identity — what `undoReview(user, id)` reverts. */
64
+ readonly reviewId: string;
65
+ }
66
+ /**
67
+ * ONE digest run under the budget: spend first (false → `undefined`,
68
+ * the caller stops for the day), then run on the user's idle model with
69
+ * the step bounds and the tick's remaining wall clock.
70
+ */
71
+ export declare function digestRunWithBudget(deps: ModelStepDeps, input: DigestRunInput): Promise<DigestRun | undefined>;
72
+ /**
73
+ * Parse a run's final text as one fixed-shape verdict object. The whole
74
+ * trimmed text first, then its outermost `{…}` span; anything else —
75
+ * prose, an empty answer, a JSON scalar — is `undefined` ("no change").
76
+ */
77
+ export declare function parseVerdict<T extends object>(text: string): T | undefined;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The model-step plumbing (`src/steps/run.ts`, Task B4): the shared
3
+ * dependencies every loop-2 MODEL step runs under, and the one
4
+ * `digestRun` helper they all spend their budget through.
5
+ *
6
+ * The budget rule (the brief): every model step is ONE `digestRun` on
7
+ * `CuratorSettings.idleModel` and decrements the per-user daily budget
8
+ * (`dailyRuns`); at the cap the pass stops for the day. {@link RunBudget}
9
+ * is the cursor-backed handle: `left()` answers what remains, `spend()`
10
+ * takes one (persisting the increment through the caller's callback) or
11
+ * answers false when the day is spent.
12
+ *
13
+ * The verdict rule (the brief): steps that need a verdict ask for it as
14
+ * the run's final text in a fixed JSON shape and parse `result.text`;
15
+ * {@link parseVerdict} accepts the whole trimmed text, or the outermost
16
+ * `{…}` span inside it (models are told to answer with the JSON alone —
17
+ * the span fallback tolerates a stray lead-in line). An unparseable
18
+ * verdict is the caller's "no change".
19
+ */
20
+ import { randomUUID } from "node:crypto";
21
+ import { readSettings } from "../settings.js";
22
+ /** The tool-call budget one model step's digest run runs under. */
23
+ export const MODEL_STEP_MAX_TOOL_CALLS = 16;
24
+ /** The output-token budget one model step's digest run runs under. */
25
+ export const MODEL_STEP_MAX_OUTPUT_TOKENS = 4096;
26
+ /** The default step logger. */
27
+ export function stepLog(deps) {
28
+ return (deps.log ?? ((message) => console.error(`curator: ${message}`)));
29
+ }
30
+ /**
31
+ * ONE digest run under the budget: spend first (false → `undefined`,
32
+ * the caller stops for the day), then run on the user's idle model with
33
+ * the step bounds and the tick's remaining wall clock.
34
+ */
35
+ export async function digestRunWithBudget(deps, input) {
36
+ if (!(await deps.budget.spend()))
37
+ return undefined;
38
+ const settings = await readSettings(deps.host, deps.user);
39
+ const reviewId = `digest-${randomUUID()}`;
40
+ const opts = {
41
+ instruction: input.input,
42
+ allow: [...input.allow],
43
+ maxToolCalls: MODEL_STEP_MAX_TOOL_CALLS,
44
+ maxOutputTokens: MODEL_STEP_MAX_OUTPUT_TOKENS,
45
+ deadlineMs: Math.max(1, deps.deadlineAt - deps.now().getTime()),
46
+ reviewId,
47
+ input: input.input,
48
+ ...(settings.idleModel === undefined ? {} : { model: settings.idleModel }),
49
+ };
50
+ const result = await deps.host.curation.digestRun(deps.user, opts);
51
+ return { result, reviewId };
52
+ }
53
+ /**
54
+ * Parse a run's final text as one fixed-shape verdict object. The whole
55
+ * trimmed text first, then its outermost `{…}` span; anything else —
56
+ * prose, an empty answer, a JSON scalar — is `undefined` ("no change").
57
+ */
58
+ export function parseVerdict(text) {
59
+ const trimmed = text.trim();
60
+ const candidates = [trimmed];
61
+ const first = trimmed.indexOf("{");
62
+ const last = trimmed.lastIndexOf("}");
63
+ if (first >= 0 && last > first)
64
+ candidates.push(trimmed.slice(first, last + 1));
65
+ for (const candidate of candidates) {
66
+ try {
67
+ const value = JSON.parse(candidate);
68
+ if (typeof value === "object" && value !== null)
69
+ return value;
70
+ }
71
+ catch {
72
+ // try the next candidate
73
+ }
74
+ }
75
+ return undefined;
76
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The targets step (`src/steps/targets.ts`, Task B4 — loop 2 step
3
+ * `targets`, the pass's LAST step): run each maintain-capable curation
4
+ * target's maintenance hook through `host.curation.maintain`, handing
5
+ * it the tick's deadline and the REMAINING daily digest runs (the
6
+ * budget the five model steps did not spend — the brief: "within the
7
+ * remaining budget").
8
+ *
9
+ * The step itself spends nothing — the target's own `run` calls are
10
+ * budgeted by the `runs` count it receives (one digest run each, per
11
+ * the P6 contract), and a target that reports `more` keeps the rest for
12
+ * the next pass. A failing target is logged and never blocks the others.
13
+ */
14
+ import { type ModelStepDeps } from "./run.js";
15
+ /** The loop-2 targets step: maintain every maintain-capable target. */
16
+ export declare function maintainTargets(deps: ModelStepDeps): Promise<boolean>;