@sigrun/ext-curator 0.0.0-stage → 0.1.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,56 @@
1
+ /**
2
+ * The curator's per-user settings (`src/settings.ts`, Task B1): read
3
+ * LIVE through the per-user scoped store, with defaults for every
4
+ * absent or malformed field — the read never throws (a bad edit disables
5
+ * nothing; the loop reads per event, like the gateway's live config).
6
+ *
7
+ * The stored object is the user's scoped settings for THIS extension
8
+ * (`settings/curator.json` in the real host); the fields sit at its top
9
+ * level. Unknown keys ride along untouched — they may belong to a later
10
+ * task of this extension and are none of the reader's business.
11
+ */
12
+ /** The defaults every absent or malformed field falls back to. */
13
+ export const DEFAULT_CURATOR_SETTINGS = {
14
+ enabled: true,
15
+ paused: false,
16
+ threshold: 10,
17
+ dailyRuns: 12,
18
+ };
19
+ /** A positive integer (thresholds and run budgets are counts). */
20
+ function positiveInteger(value) {
21
+ return (typeof value === "number" &&
22
+ Number.isInteger(value) &&
23
+ value > 0);
24
+ }
25
+ /**
26
+ * Parse a raw settings object into {@link CuratorSettings}: every field
27
+ * validated by TYPE and range, falling back to its default — the read
28
+ * is total, never a throw.
29
+ */
30
+ export function parseCuratorSettings(raw) {
31
+ return {
32
+ enabled: typeof raw.enabled === "boolean"
33
+ ? raw.enabled
34
+ : DEFAULT_CURATOR_SETTINGS.enabled,
35
+ paused: typeof raw.paused === "boolean"
36
+ ? raw.paused
37
+ : DEFAULT_CURATOR_SETTINGS.paused,
38
+ ...(typeof raw.idleModel === "string" && raw.idleModel.length > 0
39
+ ? { idleModel: raw.idleModel }
40
+ : {}),
41
+ threshold: positiveInteger(raw.threshold)
42
+ ? raw.threshold
43
+ : DEFAULT_CURATOR_SETTINGS.threshold,
44
+ dailyRuns: positiveInteger(raw.dailyRuns)
45
+ ? raw.dailyRuns
46
+ : DEFAULT_CURATOR_SETTINGS.dailyRuns,
47
+ };
48
+ }
49
+ /**
50
+ * Read one user's settings LIVE through the per-user scoped store,
51
+ * merged over the defaults.
52
+ */
53
+ export async function readSettings(host, user) {
54
+ const raw = await host.stores.user(user).settings.get();
55
+ return parseCuratorSettings(raw);
56
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The archive step (`src/steps/archive.ts`, Task B3): loop 2's first
3
+ * deterministic step — no model, pure store reads plus applies.
4
+ *
5
+ * Two 30-day rules (spec §6.2), both `>=` on milliseconds:
6
+ *
7
+ * - A LEARNED skill archives when it is unused for 30 days (per
8
+ * `usage(user)`; a skill with no usage row counts as used on its
9
+ * `createdAt` — never used is the oldest it can be) AND created at
10
+ * least 30 days ago, AND it is neither pinned nor referenced by one
11
+ * of the user's job prompts (`jobSkillRefs`, paused jobs included —
12
+ * archiving under a paused job would break its prompt on resume).
13
+ * Already-archived rows are left alone. The archive goes through
14
+ * `skills(user).apply(name, {kind:"archive"}, {reason})` — the feed
15
+ * records it as a curator change, so the view's undo covers it.
16
+ *
17
+ * - An OVERLAY whose upstream vanished (`orphanedSince`, stamped by
18
+ * `markOrphans` during the overlay-detect step) is REMOVED after
19
+ * 30 days orphaned: the detection pass before this one marked it,
20
+ * this pass (a later one) reaps it — a returning upstream in between
21
+ * clears the mark and spares it.
22
+ *
23
+ * The step is idempotent by construction (archived rows are skipped,
24
+ * removed overlays are gone), so a tick that crashes mid-step and
25
+ * resumes re-runs it safely.
26
+ */
27
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
28
+ /** The archive threshold (spec §5: 30 days), as days for the export. */
29
+ export declare const ARCHIVE_UNUSED_DAYS = 30;
30
+ /** The archive threshold in milliseconds. */
31
+ export declare const ARCHIVE_UNUSED_MS: number;
32
+ /** The feed reason stamped on a skill archive. */
33
+ export declare const ARCHIVE_REASON = "unused for 30 days";
34
+ /** The feed reason stamped on an orphaned-overlay removal. */
35
+ export declare const ORPHAN_REMOVAL_REASON = "orphaned for 30 days";
36
+ /**
37
+ * Archive the user's unused learned skills and reap their long-orphaned
38
+ * overlays (loop 2 step `archive`).
39
+ */
40
+ export declare function archiveUnused(host: SigrunHost, user: string, now: () => Date): Promise<void>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The archive step (`src/steps/archive.ts`, Task B3): loop 2's first
3
+ * deterministic step — no model, pure store reads plus applies.
4
+ *
5
+ * Two 30-day rules (spec §6.2), both `>=` on milliseconds:
6
+ *
7
+ * - A LEARNED skill archives when it is unused for 30 days (per
8
+ * `usage(user)`; a skill with no usage row counts as used on its
9
+ * `createdAt` — never used is the oldest it can be) AND created at
10
+ * least 30 days ago, AND it is neither pinned nor referenced by one
11
+ * of the user's job prompts (`jobSkillRefs`, paused jobs included —
12
+ * archiving under a paused job would break its prompt on resume).
13
+ * Already-archived rows are left alone. The archive goes through
14
+ * `skills(user).apply(name, {kind:"archive"}, {reason})` — the feed
15
+ * records it as a curator change, so the view's undo covers it.
16
+ *
17
+ * - An OVERLAY whose upstream vanished (`orphanedSince`, stamped by
18
+ * `markOrphans` during the overlay-detect step) is REMOVED after
19
+ * 30 days orphaned: the detection pass before this one marked it,
20
+ * this pass (a later one) reaps it — a returning upstream in between
21
+ * clears the mark and spares it.
22
+ *
23
+ * The step is idempotent by construction (archived rows are skipped,
24
+ * removed overlays are gone), so a tick that crashes mid-step and
25
+ * resumes re-runs it safely.
26
+ */
27
+ /** The archive threshold (spec §5: 30 days), as days for the export. */
28
+ export const ARCHIVE_UNUSED_DAYS = 30;
29
+ /** The archive threshold in milliseconds. */
30
+ export const ARCHIVE_UNUSED_MS = ARCHIVE_UNUSED_DAYS * 86_400_000;
31
+ /** The feed reason stamped on a skill archive. */
32
+ export const ARCHIVE_REASON = "unused for 30 days";
33
+ /** The feed reason stamped on an orphaned-overlay removal. */
34
+ export const ORPHAN_REMOVAL_REASON = "orphaned for 30 days";
35
+ /**
36
+ * Whether `iso` is at least `thresholdMs` before `now`. An unparseable
37
+ * stamp answers FALSE — bad data spares the skill/overlay, it never
38
+ * destroys.
39
+ */
40
+ function atLeast(thresholdMs, now, iso) {
41
+ if (iso === undefined)
42
+ return false;
43
+ const at = Date.parse(iso);
44
+ return Number.isFinite(at) && now.getTime() - at >= thresholdMs;
45
+ }
46
+ /**
47
+ * Archive the user's unused learned skills and reap their long-orphaned
48
+ * overlays (loop 2 step `archive`).
49
+ */
50
+ export async function archiveUnused(host, user, now) {
51
+ const at = now();
52
+ await archiveSkills(host, user, at);
53
+ await reapOrphanedOverlays(host, user, at);
54
+ }
55
+ /** The learned-skill half of the archive step. */
56
+ async function archiveSkills(host, user, now) {
57
+ const store = host.curation.skills(user);
58
+ const skills = await store.list();
59
+ const usage = await host.curation.usage(user);
60
+ const jobRefs = await host.curation.jobSkillRefs(user);
61
+ for (const skill of skills) {
62
+ if (skill.archived)
63
+ continue;
64
+ if (skill.pinned)
65
+ continue;
66
+ if (jobRefs.has(skill.name))
67
+ continue;
68
+ // Unused ≥ 30 days: never-used counts from creation.
69
+ const lastUsed = usage[skill.name] ?? skill.createdAt;
70
+ if (!atLeast(ARCHIVE_UNUSED_MS, now, lastUsed))
71
+ continue;
72
+ if (!atLeast(ARCHIVE_UNUSED_MS, now, skill.createdAt))
73
+ continue;
74
+ await store.apply(skill.name, { kind: "archive" }, { reason: ARCHIVE_REASON });
75
+ }
76
+ }
77
+ /** The orphaned-overlay half of the archive step. */
78
+ async function reapOrphanedOverlays(host, user, now) {
79
+ const store = host.curation.overlays(user);
80
+ for (const overlay of await store.list()) {
81
+ if (!atLeast(ARCHIVE_UNUSED_MS, now, overlay.orphanedSince))
82
+ continue;
83
+ await store.remove(overlay.key, {
84
+ actor: "curator",
85
+ reason: ORPHAN_REMOVAL_REASON,
86
+ });
87
+ }
88
+ }
@@ -0,0 +1,29 @@
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 { type ModelStepDeps } from "./run.js";
24
+ /** The diet threshold (the brief: > 12 KB). */
25
+ export declare const DIET_BYTES: number;
26
+ /** How long a skill must be unchanged before a diet (the brief: 24h). */
27
+ export declare const DIET_UNCHANGED_MS: number;
28
+ /** The loop-2 diet step: diet at most one eligible skill. */
29
+ export declare function dietStep(deps: ModelStepDeps): Promise<boolean>;
@@ -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>;