@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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/dist/backoff.d.ts +22 -0
- package/dist/backoff.js +39 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +143 -0
- package/dist/learn.d.ts +60 -0
- package/dist/learn.js +97 -0
- package/dist/loop1.d.ts +77 -0
- package/dist/loop1.js +262 -0
- package/dist/loop2.d.ts +108 -0
- package/dist/loop2.js +323 -0
- package/dist/notices.d.ts +54 -0
- package/dist/notices.js +127 -0
- package/dist/prompts.d.ts +45 -0
- package/dist/prompts.js +57 -0
- package/dist/queue.d.ts +111 -0
- package/dist/queue.js +237 -0
- package/dist/settings.d.ts +38 -0
- package/dist/settings.js +56 -0
- package/dist/steps/archive.d.ts +40 -0
- package/dist/steps/archive.js +88 -0
- package/dist/steps/diet.d.ts +29 -0
- package/dist/steps/diet.js +131 -0
- package/dist/steps/digests.d.ts +55 -0
- package/dist/steps/digests.js +243 -0
- package/dist/steps/merge.d.ts +17 -0
- package/dist/steps/merge.js +81 -0
- package/dist/steps/overlays.d.ts +92 -0
- package/dist/steps/overlays.js +476 -0
- package/dist/steps/run.d.ts +77 -0
- package/dist/steps/run.js +76 -0
- package/dist/steps/targets.d.ts +16 -0
- package/dist/steps/targets.js +42 -0
- package/dist/view.d.ts +105 -0
- package/dist/view.js +412 -0
- package/package.json +55 -5
|
@@ -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>;
|