@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,131 @@
1
+ /**
2
+ * The skill-diet step (`src/steps/diet.ts`, Task B4 — loop 2 step
3
+ * `diet`): ONE oversized learned skill per pass is rewritten so its
4
+ * `##` sections move out to `references/` files verbatim (openclaw's
5
+ * diet), and a SECOND model run compares old and new and answers
6
+ * `{"loss": false}` — anything else (a reported loss, or an unparseable
7
+ * answer) undoes the rewrite through `skills.undo`, so no unverified
8
+ * diet ever stands.
9
+ *
10
+ * Eligibility (the brief): a learned skill whose SKILL.md is > 12 KB
11
+ * and unchanged for 24h — "unchanged" reads the skill store's feed: the
12
+ * newest curator/agent-authored entry for the skill must be older than
13
+ * 24h (a skill with no feed entry at all counts as its creation, so a
14
+ * freshly created giant waits a day before its first diet).
15
+ *
16
+ * The runs: the FIRST carries the full SKILL.md and instructs the
17
+ * move-to-references rewrite (the run performs it through its allowed
18
+ * `skill_manage` tool — `write_file` under `references/` plus the
19
+ * trimmed patch); the SECOND carries old and new side by side and asks
20
+ * only the fixed JSON loss answer. Both are ordinary budget-spending
21
+ * digest runs on the idle model.
22
+ */
23
+ import { digestRunWithBudget, parseVerdict, stepLog } from "./run.js";
24
+ /** The diet threshold (the brief: > 12 KB). */
25
+ export const DIET_BYTES = 12 * 1024;
26
+ /** How long a skill must be unchanged before a diet (the brief: 24h). */
27
+ export const DIET_UNCHANGED_MS = 24 * 60 * 60 * 1000;
28
+ /** The diet's allow list: the rewrite runs through skill_manage. */
29
+ const DIET_ALLOW = ["skill_manage"];
30
+ /** The loop-2 diet step: diet at most one eligible skill. */
31
+ export async function dietStep(deps) {
32
+ const { host, user } = deps;
33
+ const log = stepLog(deps);
34
+ if (deps.budget.left() < 1)
35
+ return false; // the day is spent: stop the pass
36
+ const store = host.curation.skills(user);
37
+ const feed = await store.feed(200);
38
+ for (const row of await store.list()) {
39
+ if (deps.now().getTime() >= deps.deadlineAt)
40
+ return false;
41
+ if (row.archived)
42
+ continue;
43
+ if (row.bytes <= DIET_BYTES)
44
+ continue;
45
+ const last = newestEntry(feed, row.name) ?? row.createdAt;
46
+ if (deps.now().getTime() - Date.parse(last) < DIET_UNCHANGED_MS)
47
+ continue;
48
+ const done = await dietOne(deps, row.name).catch((error) => {
49
+ log(`loop 2: ${user} diet of skill ${row.name} failed: ${error instanceof Error ? error.message : String(error)}`);
50
+ return true;
51
+ });
52
+ return done; // ONE skill per pass, whatever happened
53
+ }
54
+ return true;
55
+ }
56
+ /** Diet one skill: rewrite, compare, undo on loss or unparseable. */
57
+ async function dietOne(deps, name) {
58
+ const { host, user } = deps;
59
+ const store = host.curation.skills(user);
60
+ const oldBody = await store.view(name);
61
+ if (oldBody === "")
62
+ return true; // unreadable: nothing to diet safely
63
+ // Run 1 — the rewrite (the run moves `##` sections to references/
64
+ // verbatim through its skill_manage tool).
65
+ const rewrite = await digestRunWithBudget(deps, {
66
+ input: rewriteInput(name, oldBody),
67
+ allow: DIET_ALLOW,
68
+ });
69
+ if (rewrite === undefined)
70
+ return false; // no budget left today
71
+ if (rewrite.result.status !== "completed" || rewrite.result.changes.length === 0) {
72
+ return true;
73
+ }
74
+ // Run 2 — the loss compare over old and NEW bodies.
75
+ const newBody = await store.view(name);
76
+ const compare = await digestRunWithBudget(deps, {
77
+ input: compareInput(name, oldBody, newBody),
78
+ allow: DIET_ALLOW, // the compare may read files through skill_manage
79
+ });
80
+ if (compare === undefined) {
81
+ // Budget ran out mid-diet: an UNVERIFIED rewrite must not stand.
82
+ await undoChanges(store, rewrite.result);
83
+ return false;
84
+ }
85
+ const verdict = compare.result.status === "completed"
86
+ ? parseVerdict(compare.result.text)
87
+ : undefined;
88
+ if (verdict !== undefined && verdict.loss === false)
89
+ return true; // verified
90
+ // A reported loss, an unparseable answer, or a failed compare run:
91
+ // the rewrite is undone (the brief — or the change is undone).
92
+ await undoChanges(store, rewrite.result);
93
+ return true;
94
+ }
95
+ /** Undo one run's changes through the skill store. */
96
+ async function undoChanges(store, run) {
97
+ const ids = run.changes
98
+ .map((change) => change.changeId)
99
+ .filter((id) => typeof id === "string" && id !== "");
100
+ if (ids.length > 0)
101
+ await store.undo(ids);
102
+ }
103
+ /** The feed's newest entry for one skill (feed is newest-first). */
104
+ function newestEntry(feed, skill) {
105
+ return feed.find((entry) => entry.skill === skill)?.at;
106
+ }
107
+ /** The rewrite run's input (run 1). */
108
+ function rewriteInput(name, body) {
109
+ return [
110
+ `You are the curator. The learned skill "${name}" is too large. Diet it: keep the skill's SKILL.md to its opening summary and its core procedure, and move every \`##\` section that is reference material (examples, long tables, background) into files under \`references/\` VERBATIM — unchanged text, only relocated. Re-link them from SKILL.md with a one-line pointer each.`,
111
+ "",
112
+ "The current SKILL.md in full:",
113
+ body,
114
+ "",
115
+ "Use your skill_manage tool to write the files (write_file for each references/ file, then patch SKILL.md). Move text verbatim; never summarize, merge, or drop a rule. When you are done, answer with one line naming what you moved.",
116
+ ].join("\n");
117
+ }
118
+ /** The loss-compare run's input (run 2). */
119
+ function compareInput(name, oldBody, newBody) {
120
+ return [
121
+ `You are the curator. The learned skill "${name}" was just dieted: sections moved to references/ files. Compare the OLD body with the NEW body plus the reference files' pointers, and decide whether any rule, pitfall, or instruction present in the old body was dropped or weakened anywhere in the new shape.`,
122
+ "",
123
+ "The OLD SKILL.md:",
124
+ oldBody,
125
+ "",
126
+ "The NEW SKILL.md (references/ files are reachable through its pointers):",
127
+ newBody,
128
+ "",
129
+ 'Answer with ONLY this JSON: {"loss": false} when nothing was dropped or weakened, or {"loss": true} when something was.',
130
+ ].join("\n");
131
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The mixed-conversation digest step (`src/steps/digests.ts`, Task B4 —
3
+ * loop 2 step `digests`): the conversations loop 1 refused to fork
4
+ * (mixed authorship, unattributed entries) are reviewed here, NEVER
5
+ * forked — a fresh internal digest run over a text digest of the
6
+ * OWNER'S attributed turns plus the assistant replies to them.
7
+ *
8
+ * Attribution (fail closed, P1's rule — another user's words can never
9
+ * enter the owner's digest): the PRIMARY per-entry source is
10
+ * `curation.authors(conversationId)` — core's map of every user-input
11
+ * entry in the active context to its author (an entry ABSENT from the
12
+ * map or mapped to `undefined` is unattributed and never the owner's).
13
+ * When the map is EMPTY (no attributed entry at all — a pre-P1-shaped
14
+ * or fresh conversation), the step falls back to `curation.eligibility`:
15
+ * an owner-only conversation with zero unattributed entries attributes
16
+ * EVERY user turn to the owner (that is what `ownerOnly` means). A
17
+ * mixed conversation whose map has no entry for the digesting user has
18
+ * no attributable turn — none is included, the fail-closed skip below
19
+ * leaves it queued.
20
+ *
21
+ * ✅ HOST GAP RESOLVED (2026-11-01): this step originally read per-entry
22
+ * authors from a `sigrun.authors` doc projected onto the watched view
23
+ * (`docs["sigrun.authors"].byEntry`) — but pi-durable's structural view
24
+ * mounts only its five built-in docs, so that projection never existed
25
+ * and mixed conversations digested NOTHING. Core now serves the map
26
+ * directly (`host.curation.authors`, core commit "feat(curation):
27
+ * per-entry author map on host.curation"); the view-doc path is gone.
28
+ *
29
+ * Assistant replies: an assistant entry is included only when it
30
+ * belongs to the run an OWNER turn started — the entries after an
31
+ * owner-attributed user entry, up to the next user entry (a reply that
32
+ * follows another user's turn answers that turn, and is excluded with
33
+ * it).
34
+ *
35
+ * Durability: the queue file is read WITHOUT clearing; an entry is
36
+ * removed only after its review COMPLETED (a failed run leaves it for
37
+ * the next pass). The B2 carry: consumption dedups hints by id — the
38
+ * entry's own hints merge with the conversation's refunded batch
39
+ * (`pending-hints.json`), and completing the review drains both.
40
+ */
41
+ import type { ConversationId } from "@earendil-works/pi-durable";
42
+ import { type ModelStepDeps } from "./run.js";
43
+ /** The digest step's own view-acquisition grace: how long to wait for
44
+ * the watched conversation's first frame (the real host delivers the
45
+ * snapshot before `watch` resolves; the grace only bounds an anomaly). */
46
+ export declare const DIGEST_VIEW_GRACE_MS = 10000;
47
+ /** The loop-2 digest step: review every queued mixed conversation.
48
+ * Answers FALSE when the daily budget ran out (the pass stops). */
49
+ export declare function digestsStep(deps: ModelStepDeps): Promise<boolean>;
50
+ /**
51
+ * Review ONE conversation (exported for the step loop): watch its view,
52
+ * build the owner's digest, run the review, and consume the queue
53
+ * entry on completion. False = out of budget (the caller stops).
54
+ */
55
+ export declare function reviewDigest(deps: ModelStepDeps, conversationId: ConversationId): Promise<boolean>;
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The mixed-conversation digest step (`src/steps/digests.ts`, Task B4 —
3
+ * loop 2 step `digests`): the conversations loop 1 refused to fork
4
+ * (mixed authorship, unattributed entries) are reviewed here, NEVER
5
+ * forked — a fresh internal digest run over a text digest of the
6
+ * OWNER'S attributed turns plus the assistant replies to them.
7
+ *
8
+ * Attribution (fail closed, P1's rule — another user's words can never
9
+ * enter the owner's digest): the PRIMARY per-entry source is
10
+ * `curation.authors(conversationId)` — core's map of every user-input
11
+ * entry in the active context to its author (an entry ABSENT from the
12
+ * map or mapped to `undefined` is unattributed and never the owner's).
13
+ * When the map is EMPTY (no attributed entry at all — a pre-P1-shaped
14
+ * or fresh conversation), the step falls back to `curation.eligibility`:
15
+ * an owner-only conversation with zero unattributed entries attributes
16
+ * EVERY user turn to the owner (that is what `ownerOnly` means). A
17
+ * mixed conversation whose map has no entry for the digesting user has
18
+ * no attributable turn — none is included, the fail-closed skip below
19
+ * leaves it queued.
20
+ *
21
+ * ✅ HOST GAP RESOLVED (2026-11-01): this step originally read per-entry
22
+ * authors from a `sigrun.authors` doc projected onto the watched view
23
+ * (`docs["sigrun.authors"].byEntry`) — but pi-durable's structural view
24
+ * mounts only its five built-in docs, so that projection never existed
25
+ * and mixed conversations digested NOTHING. Core now serves the map
26
+ * directly (`host.curation.authors`, core commit "feat(curation):
27
+ * per-entry author map on host.curation"); the view-doc path is gone.
28
+ *
29
+ * Assistant replies: an assistant entry is included only when it
30
+ * belongs to the run an OWNER turn started — the entries after an
31
+ * owner-attributed user entry, up to the next user entry (a reply that
32
+ * follows another user's turn answers that turn, and is excluded with
33
+ * it).
34
+ *
35
+ * Durability: the queue file is read WITHOUT clearing; an entry is
36
+ * removed only after its review COMPLETED (a failed run leaves it for
37
+ * the next pass). The B2 carry: consumption dedups hints by id — the
38
+ * entry's own hints merge with the conversation's refunded batch
39
+ * (`pending-hints.json`), and completing the review drains both.
40
+ */
41
+ import { clearPendingHints, mergeHints, pendingHintsFor, readDigestQueue, removeDigestEntry, } from "../queue.js";
42
+ import { notify } from "../notices.js";
43
+ import { reviewInstruction } from "../prompts.js";
44
+ import { digestRunWithBudget, stepLog } from "./run.js";
45
+ /** The digest step's own view-acquisition grace: how long to wait for
46
+ * the watched conversation's first frame (the real host delivers the
47
+ * snapshot before `watch` resolves; the grace only bounds an anomaly). */
48
+ export const DIGEST_VIEW_GRACE_MS = 10_000;
49
+ /** The entry kinds that carry a user turn (attribution.ts's set). */
50
+ const USER_KINDS = new Set([
51
+ "pi.user",
52
+ "sigrun.user.expanded",
53
+ "sigrun.blocks.answer",
54
+ ]);
55
+ /** The loop-2 digest step: review every queued mixed conversation.
56
+ * Answers FALSE when the daily budget ran out (the pass stops). */
57
+ export async function digestsStep(deps) {
58
+ const { host, user } = deps;
59
+ const log = stepLog(deps);
60
+ if (deps.budget.left() < 1)
61
+ return false; // the day is spent: stop the pass
62
+ const entries = await readDigestQueue(host, user);
63
+ for (const entry of entries) {
64
+ if (deps.now().getTime() >= deps.deadlineAt)
65
+ return false;
66
+ const done = await reviewDigest(deps, entry.id).catch((error) => {
67
+ log(`loop 2: ${user} digest of conversation ${String(entry.id)} failed: ${error instanceof Error ? error.message : String(error)}`);
68
+ return true;
69
+ });
70
+ if (!done)
71
+ return false; // out of daily budget: stop for the day
72
+ }
73
+ return true;
74
+ }
75
+ /**
76
+ * Review ONE conversation (exported for the step loop): watch its view,
77
+ * build the owner's digest, run the review, and consume the queue
78
+ * entry on completion. False = out of budget (the caller stops).
79
+ */
80
+ export async function reviewDigest(deps, conversationId) {
81
+ const { host, user } = deps;
82
+ const log = stepLog(deps);
83
+ const view = await watchFirstFrame(deps, conversationId);
84
+ if (view === undefined) {
85
+ log(`loop 2: ${user} digest of conversation ${String(conversationId)} saw no view`);
86
+ return true; // unreadable: leave it queued, move on
87
+ }
88
+ const digest = await ownerDigest(deps, conversationId, view);
89
+ if (digest === undefined) {
90
+ log(`loop 2: ${user} digest of conversation ${String(conversationId)} has no attributable turns`);
91
+ return true; // fail closed: leave it queued for a per-entry source
92
+ }
93
+ // The B2 carry: the entry owns its hints; the refund file may hold
94
+ // the same ids (a crash between queueDigest and the clear) — merge
95
+ // by hint id so a stranded hint is consumed exactly once.
96
+ const hints = mergeHints(await pendingHintsFor(host, user, conversationId), (await readDigestQueue(host, user)).find((entry) => entry.id === conversationId)?.hints ?? []);
97
+ const targets = await host.curation.targets(user, conversationId);
98
+ const selection = await host.curation.tools(conversationId);
99
+ const allow = [
100
+ ...new Set([
101
+ "skill_manage",
102
+ ...targets.flatMap((target) => [...target.tools]),
103
+ ...(selection.includes("read") ? ["read"] : []),
104
+ ]),
105
+ ];
106
+ const instruction = reviewInstruction({
107
+ hints,
108
+ targets: targets.map((target) => ({
109
+ id: target.id,
110
+ guidance: [...target.guidance],
111
+ })),
112
+ allow,
113
+ });
114
+ const run = await digestRunWithBudget(deps, {
115
+ input: `${digest}\n\n${instruction}`,
116
+ allow,
117
+ });
118
+ if (run === undefined)
119
+ return false; // no budget left today
120
+ if (run.result.status !== "completed")
121
+ return true; // leave it queued
122
+ // The review consumed these hints for good: the queue entry goes and
123
+ // the refund file drains of the same ids.
124
+ await removeDigestEntry(deps.host, user, conversationId);
125
+ await clearPendingHints(host, user, conversationId, hints);
126
+ // Task B5: a digest review that CHANGED something announces itself
127
+ // on the originating (mixed) conversation — the same notice shape a
128
+ // fork review sets, undoable from the view. The notice is isolated:
129
+ // a failing setAmbient or ledger write degrades to a missing notice,
130
+ // never a failed step (the catch above would strand a COMPLETED
131
+ // review's entry back in the queue as if the run had failed).
132
+ await notify(host, conversationId, user, {
133
+ ...run.result,
134
+ reviewId: run.reviewId,
135
+ }).catch((error) => {
136
+ log(`loop 2: ${user} notice after the digest of conversation ${String(conversationId)} could not be set: ${error instanceof Error ? error.message : String(error)}`);
137
+ });
138
+ return true;
139
+ }
140
+ /**
141
+ * Watch the conversation and resolve its FIRST frame (the snapshot the
142
+ * real host pushes before `watch` resolves), bounded by the grace.
143
+ */
144
+ async function watchFirstFrame(deps, conversationId) {
145
+ let frame;
146
+ let releaseFrame;
147
+ const first = new Promise((resolve) => {
148
+ releaseFrame = resolve;
149
+ });
150
+ const stop = await deps.host.conversations.watch(conversationId, (view) => {
151
+ frame = view;
152
+ releaseFrame();
153
+ });
154
+ try {
155
+ if (frame === undefined) {
156
+ const grace = Math.min(DIGEST_VIEW_GRACE_MS, Math.max(0, deps.deadlineAt - deps.now().getTime()));
157
+ await Promise.race([
158
+ first,
159
+ new Promise((resolve) => setTimeout(resolve, grace)),
160
+ ]);
161
+ }
162
+ }
163
+ finally {
164
+ await stop();
165
+ }
166
+ return frame;
167
+ }
168
+ /** The owner's digest of one view, or undefined when no user turn is
169
+ * attributable (the fail-closed skip). */
170
+ async function ownerDigest(deps, conversationId, view) {
171
+ const { host, user } = deps;
172
+ // Per-entry attribution, the PRIMARY source: entry id → author,
173
+ // skipping unattributed entries (author undefined — an entry absent
174
+ // from this record reads unattributed, so `undefined === user` is
175
+ // false and the turn is excluded: never learn from unattributed).
176
+ const attributed = await host.curation.authors(conversationId);
177
+ const byEntry = {};
178
+ for (const [entryId, author] of attributed) {
179
+ if (author !== undefined)
180
+ byEntry[String(entryId)] = author;
181
+ }
182
+ // The empty map means "no attributed entry at all" (a pre-P1-shaped
183
+ // or fresh conversation): fall back to the whole-conversation
184
+ // eligibility read so owner-only conversations still digest.
185
+ let everyTurnIsOwners = false;
186
+ if (Object.keys(byEntry).length === 0) {
187
+ const eligibility = await host.curation.eligibility(conversationId);
188
+ everyTurnIsOwners =
189
+ eligibility.owner === user && eligibility.ownerOnly && eligibility.unattributed === 0;
190
+ }
191
+ const lines = [];
192
+ let inOwnerRun = false;
193
+ let sawOwnerTurn = false;
194
+ const hasPerEntry = Object.keys(byEntry).length > 0;
195
+ for (const entry of view.entries ?? []) {
196
+ if (USER_KINDS.has(entry.kind)) {
197
+ const author = byEntry[String(entry.id)];
198
+ inOwnerRun = hasPerEntry ? author === user : everyTurnIsOwners;
199
+ if (!inOwnerRun)
200
+ continue;
201
+ const text = entryText(entry, "user");
202
+ if (text === "")
203
+ continue;
204
+ lines.push(`User (${user}): ${text}`);
205
+ sawOwnerTurn = true;
206
+ continue;
207
+ }
208
+ if (entry.kind === "pi.assistant") {
209
+ if (!inOwnerRun)
210
+ continue; // replies to another user's turn
211
+ const text = entryText(entry, "assistant");
212
+ if (text === "")
213
+ continue;
214
+ lines.push(`Assistant: ${text}`);
215
+ }
216
+ }
217
+ if (!sawOwnerTurn)
218
+ return undefined;
219
+ return [`Conversation digest (only ${user}'s turns and the replies to them):`, "", ...lines]
220
+ .join("\n");
221
+ }
222
+ /** One entry's text for `role` (string content, or the text blocks). */
223
+ function entryText(entry, role) {
224
+ const parts = [];
225
+ for (const message of entry.model ?? []) {
226
+ if (message.role !== role)
227
+ continue;
228
+ const content = message.content;
229
+ if (typeof content === "string") {
230
+ parts.push(content);
231
+ }
232
+ else if (Array.isArray(content)) {
233
+ for (const block of content) {
234
+ if (typeof block === "object" &&
235
+ block !== null &&
236
+ typeof block.text === "string") {
237
+ parts.push(block.text);
238
+ }
239
+ }
240
+ }
241
+ }
242
+ return parts.join("\n").trim();
243
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The merge step (`src/steps/merge.ts`, Task B4 — loop 2 step `merge`):
3
+ * ONE pair of learned skills a run judges same-class merges — the run
4
+ * patches the survivor and the step archives the other with
5
+ * `absorbed_into` naming the survivor (the spec's merge shape).
6
+ *
7
+ * The verdict (`{"merge": ["a","b"], "survivor": "a"}`) is the run's
8
+ * final text in the fixed JSON shape. It must name exactly two KNOWN,
9
+ * unarchived, learned skills with a known survivor among them —
10
+ * anything else (unparseable text, unknown names, a mismatched
11
+ * survivor) changes nothing, and a run that already wrote through its
12
+ * tools is reverted with `undoReview` (an unverified merge never
13
+ * stands, mirroring the diet's compare rule).
14
+ */
15
+ import { type ModelStepDeps } from "./run.js";
16
+ /** The loop-2 merge step: at most one judged merge per pass. */
17
+ export declare function mergeStep(deps: ModelStepDeps): Promise<boolean>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The merge step (`src/steps/merge.ts`, Task B4 — loop 2 step `merge`):
3
+ * ONE pair of learned skills a run judges same-class merges — the run
4
+ * patches the survivor and the step archives the other with
5
+ * `absorbed_into` naming the survivor (the spec's merge shape).
6
+ *
7
+ * The verdict (`{"merge": ["a","b"], "survivor": "a"}`) is the run's
8
+ * final text in the fixed JSON shape. It must name exactly two KNOWN,
9
+ * unarchived, learned skills with a known survivor among them —
10
+ * anything else (unparseable text, unknown names, a mismatched
11
+ * survivor) changes nothing, and a run that already wrote through its
12
+ * tools is reverted with `undoReview` (an unverified merge never
13
+ * stands, mirroring the diet's compare rule).
14
+ */
15
+ import { digestRunWithBudget, parseVerdict, stepLog } from "./run.js";
16
+ /** The merge's allow list: the run patches through skill_manage. */
17
+ const MERGE_ALLOW = ["skill_manage"];
18
+ /** The loop-2 merge step: at most one judged merge per pass. */
19
+ export async function mergeStep(deps) {
20
+ const { host, user } = deps;
21
+ const log = stepLog(deps);
22
+ if (deps.budget.left() < 1)
23
+ return false; // the day is spent: stop the pass
24
+ const skills = await host.curation.skills(user).list();
25
+ const active = skills.filter((row) => !row.archived);
26
+ if (active.length < 2)
27
+ return true; // nothing mergeable
28
+ const result = await digestRunWithBudget(deps, {
29
+ input: mergeInput(active),
30
+ allow: MERGE_ALLOW,
31
+ });
32
+ if (result === undefined)
33
+ return false; // no budget left today
34
+ if (result.result.status !== "completed")
35
+ return true;
36
+ const verdict = parseVerdict(result.result.text);
37
+ const plan = verdict === undefined ? undefined : mergePlan(verdict, active);
38
+ if (plan !== undefined) {
39
+ // The run patched the survivor; the step archives the absorbed
40
+ // skill naming it (absorbed_into) — undoable through the feed.
41
+ await host.curation.skills(user).apply(plan.absorbed, { kind: "archive", absorbedInto: plan.survivor }, { reason: `merged into ${plan.survivor}` });
42
+ return true;
43
+ }
44
+ // Unparseable or unusable verdict: a run that wrote anyway is undone
45
+ // — an unverified merge never stands (net: no change).
46
+ if (result.result.changes.length > 0) {
47
+ await host.curation.undoReview(user, result.reviewId).catch((error) => {
48
+ log(`loop 2: ${user} merge undo of review failed: ${error instanceof Error ? error.message : String(error)}`);
49
+ });
50
+ }
51
+ return true;
52
+ }
53
+ /** Validate a verdict into a merge plan; undefined when it names
54
+ * anything but exactly two known active skills and a survivor of them. */
55
+ function mergePlan(verdict, active) {
56
+ if (!Array.isArray(verdict.merge) || verdict.merge.length !== 2)
57
+ return undefined;
58
+ const [first, second] = verdict.merge;
59
+ if (typeof first !== "string" || typeof second !== "string")
60
+ return undefined;
61
+ if (typeof verdict.survivor !== "string")
62
+ return undefined;
63
+ const names = new Set(active.map((row) => row.name));
64
+ if (!names.has(first) || !names.has(second))
65
+ return undefined;
66
+ if (verdict.survivor !== first && verdict.survivor !== second)
67
+ return undefined;
68
+ const absorbed = verdict.survivor === first ? second : first;
69
+ return { survivor: verdict.survivor, absorbed };
70
+ }
71
+ /** The merge run's input: the candidate list and the fixed verdict. */
72
+ function mergeInput(active) {
73
+ const rows = active.map((row) => `- ${row.name} (${row.bytes} bytes): ${row.description}`);
74
+ return [
75
+ "You are the curator. These are the user's learned skills. Decide whether exactly ONE pair covers the same class of task and should merge (the survivor absorbs the other's rules; use your skill_manage tool to patch the survivor so it covers both). Most passes find no pair — that is fine.",
76
+ "",
77
+ ...rows,
78
+ "",
79
+ 'When you merged a pair, answer with ONLY this JSON: {"merge": ["<survivor>", "<absorbed>"], "survivor": "<survivor>"}. Otherwise answer with {}.',
80
+ ].join("\n");
81
+ }
@@ -0,0 +1,92 @@
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 type { CurationOverlayKey, SigrunHost, SigrunLifecycleHandlers } from "@sigrun/sigrun/extension";
35
+ import { type ModelStepDeps } from "./run.js";
36
+ /**
37
+ * The overlay-recheck queue's file name under the user's state dir —
38
+ * the seam B3 writes and Task B4's `overlay-recheck` step consumes.
39
+ */
40
+ export declare const RECHECK_QUEUE_FILE = "overlay-recheck-queue.json";
41
+ /** A stable identity for one overlay key (dedup and diffs). */
42
+ export declare function keyId(key: CurationOverlayKey): string;
43
+ /** Read the queued re-check keys; absent/malformed → []. */
44
+ export declare function readRecheckQueue(host: SigrunHost, user: string): Promise<readonly CurationOverlayKey[]>;
45
+ /**
46
+ * The queue B4 consumes: `take()` drains (read-and-clear — a taken
47
+ * re-check is the re-check step's to finish), `enqueue` appends deduped.
48
+ */
49
+ export declare class OverlayRecheckQueue {
50
+ private readonly host;
51
+ private readonly user;
52
+ constructor(host: SigrunHost, user: string);
53
+ /** Append keys, deduped against what is queued (by identity). */
54
+ enqueue(keys: readonly CurationOverlayKey[]): Promise<void>;
55
+ /** Read and clear the queue (B4's `overlay-recheck` step start). */
56
+ take(): Promise<readonly CurationOverlayKey[]>;
57
+ }
58
+ /**
59
+ * The overlay-detect step: mark orphans with the host and queue every
60
+ * drifted overlay for a re-check (loop 2 step `overlay-detect`).
61
+ */
62
+ export declare function detectOverlayDrift(host: SigrunHost, user: string): Promise<void>;
63
+ /**
64
+ * The overlay-recheck step (loop 2 step `overlay-recheck`): drain the
65
+ * re-check queue and re-check every drifted overlay — ONE digest run
66
+ * per key, under the daily budget. Verdicts apply immediately
67
+ * (keep/drop/rewrite); "unsure" asks the user (the design's only
68
+ * prompt) and the answer applies through {@link overlayQuestionHandler}.
69
+ * Answers FALSE when the daily budget ran out mid-queue (the pass
70
+ * stops for the day; the keys still queued re-check next pass — the
71
+ * re-check queue only drains as keys complete).
72
+ *
73
+ * Crash-durability posture (the B3 carry): `take()` is read-then-clear,
74
+ * never at-least-once — DETECTION is the system of record; a lost
75
+ * queue entry self-heals on the next detect pass. A crash after a
76
+ * run but before its verdict applied wastes one model run, nothing
77
+ * more (the verdict is re-derived next pass).
78
+ */
79
+ export declare function recheckOverlays(deps: ModelStepDeps): Promise<boolean>;
80
+ /**
81
+ * The `interactionSettled` half: apply a settled overlay question's
82
+ * answer. EVERY terminal status (answered, skipped, cancelled, expired)
83
+ * drops the entry from the pending file — the file is bounded by
84
+ * terminal statuses (one open entry per skill key). Only ANSWERED
85
+ * records with a `verdict` of keep/drop apply; `keep` re-anchors the
86
+ * remembered overlay text on the remembered hash; `drop` removes the
87
+ * overlay. The callId is looked up in BOTH the v2 `questions` map and
88
+ * the migrated `legacy` list, so pre-v2 questions stay answerable
89
+ * where they were asked. Idempotent per callId (an applied question
90
+ * leaves the pending file).
91
+ */
92
+ export declare function overlayQuestionHandler(host: SigrunHost): Pick<SigrunLifecycleHandlers, "interactionSettled">;