@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,111 @@
1
+ /**
2
+ * The review queues (`src/queue.ts`, Task B2):
3
+ *
4
+ * - `ReviewQueue` — one in-flight review PER USER, FIFO per user (a
5
+ * burst of settles on one user's conversations reviews in order,
6
+ * never concurrently; two users never serialize against each other).
7
+ * `onRunSettled` enqueues and returns; the queue owns the run. The
8
+ * auto-namer's pattern: a per-key promise chain, `idle()` for tests.
9
+ * - `queueDigest` — the mixed-conversation spill: loop 1 never forks a
10
+ * shared conversation, it appends the id to the per-user
11
+ * `digest-queue.json` (state), and loop 2's digest step (Task B4)
12
+ * consumes the list. Dedup by conversation id — one pending entry per
13
+ * conversation, however many runs triggered it — with the taken
14
+ * hints merged into the entry (the decision took them read-and-clear;
15
+ * the digest run folds them into its instruction).
16
+ * - the refunded-hints file (`pending-hints.json`) — the fix for the
17
+ * dropped-hints bug: `takeHints` is read-and-clear, so a review that
18
+ * ends failed / cancelled / deadline would otherwise lose its hints
19
+ * for good. Non-completed reviews write them back (refund, merged by
20
+ * hint id); the decision merges a conversation's refunded batch into
21
+ * every fresh take; a COMPLETED review (or a digest entry that takes
22
+ * ownership) clears the ids it consumed. The transfer is deliberately
23
+ * at-least-once: a crash between refund and clear duplicates a hint
24
+ * (dedup absorbs it), never drops one.
25
+ *
26
+ * Errors never break a chain: the queue's contract is that a job's
27
+ * failure is the job's business (the loop records it); the chain keeps
28
+ * draining.
29
+ *
30
+ * Every hints helper here dedups by hint id: a hint may briefly live in
31
+ * two places (host + refund file, refund file + digest entry) around a
32
+ * crash, and dedup makes the overlap harmless.
33
+ */
34
+ import type { SigrunHost, CurationHint } from "@sigrun/sigrun/extension";
35
+ import type { ConversationId } from "@earendil-works/pi-durable";
36
+ /** One queued review: the user it belongs to, and the work. */
37
+ export interface ReviewJob {
38
+ /** The reviewed user — the one-in-flight key. */
39
+ readonly user: string;
40
+ /** The review body; must not throw (record failures inside). */
41
+ readonly work: () => Promise<void>;
42
+ }
43
+ /**
44
+ * The per-user FIFO review queue. `enqueue` never runs the work on the
45
+ * caller's stack — it appends to the user's chain; `idle()` resolves
46
+ * when NO user has work in flight or queued.
47
+ */
48
+ export declare class ReviewQueue {
49
+ /** One promise chain per user (the FIFO + the one-in-flight rule). */
50
+ private readonly chains;
51
+ /** Jobs not yet finished, across every user (the `idle()` counter). */
52
+ private pending;
53
+ /** Every live `idle()` waiter, released together at drain. */
54
+ private waiters;
55
+ /** Append one job to its user's chain. */
56
+ enqueue(job: ReviewJob): void;
57
+ /**
58
+ * Resolve when every enqueued job has finished (immediately when
59
+ * the queue is empty). Tests await this; production never does.
60
+ */
61
+ idle(): Promise<void>;
62
+ }
63
+ /**
64
+ * Read one JSON state file with a fallback: absent or malformed → the
65
+ * fallback value (state files are the loop's own bookkeeping — a bad
66
+ * file degrades to defaults, never a broken loop).
67
+ */
68
+ export declare function readStateJson<T>(file: string, fallback: T): Promise<T>;
69
+ /** Write one JSON state file under the user's state dir (tab indents). */
70
+ export declare function writeStateJson<T>(stateDir: string, name: string, value: T): Promise<void>;
71
+ /** The digest-queue file: pending mixed conversations, deduped by id. */
72
+ export interface DigestQueueFile {
73
+ readonly conversations: readonly DigestQueueEntry[];
74
+ }
75
+ /** One pending digest: the conversation, with the hints it triggered on. */
76
+ export interface DigestQueueEntry {
77
+ readonly id: ConversationId;
78
+ readonly hints: readonly CurationHint[];
79
+ }
80
+ /** Read the digest queue's entries for one user; absent/malformed → []. */
81
+ export declare function readDigestQueue(host: SigrunHost, user: string): Promise<readonly DigestQueueEntry[]>;
82
+ /** Overwrite the digest queue's entries (the digest step's write-back). */
83
+ export declare function writeDigestQueue(host: SigrunHost, user: string, conversations: readonly DigestQueueEntry[]): Promise<void>;
84
+ /**
85
+ * Remove one conversation's digest entry (the digest step, after the
86
+ * review completed and took ownership of its hints). Idempotent.
87
+ */
88
+ export declare function removeDigestEntry(host: SigrunHost, user: string, conversationId: ConversationId): Promise<void>;
89
+ /**
90
+ * Queue one conversation for loop 2's digest (Task B4 consumes): append
91
+ * to `digest-queue.json` under the user's state dir, dedup by id, the
92
+ * taken hints merged into the existing entry by hint id (never dropped).
93
+ */
94
+ export declare function queueDigest(host: SigrunHost, user: string, conversationId: ConversationId, hints?: readonly CurationHint[]): Promise<void>;
95
+ /** Merge two hint batches into one, deduped by hint id. */
96
+ export declare function mergeHints(first: readonly CurationHint[], second: readonly CurationHint[]): readonly CurationHint[];
97
+ /** The hints refunded for one conversation (a read; nothing is cleared). */
98
+ export declare function pendingHintsFor(host: SigrunHost, user: string, conversationId: ConversationId): Promise<readonly CurationHint[]>;
99
+ /**
100
+ * Refund: write a non-completed review's hints back, merged into the
101
+ * conversation's refunded batch by hint id (never dropped, never
102
+ * duplicated — an unchanged entry is not rewritten).
103
+ */
104
+ export declare function refundHints(host: SigrunHost, user: string, conversationId: ConversationId, hints: readonly CurationHint[]): Promise<void>;
105
+ /**
106
+ * Clear: drop the hint ids a consumer took ownership of (a completed
107
+ * review, or a digest entry that persists them); the conversation's
108
+ * entry leaves when it empties. An unknown id (e.g. a fresh hint that
109
+ * was never refunded) is a no-op, and so is an untouched file.
110
+ */
111
+ export declare function clearPendingHints(host: SigrunHost, user: string, conversationId: ConversationId, hints: readonly CurationHint[]): Promise<void>;
package/dist/queue.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The review queues (`src/queue.ts`, Task B2):
3
+ *
4
+ * - `ReviewQueue` — one in-flight review PER USER, FIFO per user (a
5
+ * burst of settles on one user's conversations reviews in order,
6
+ * never concurrently; two users never serialize against each other).
7
+ * `onRunSettled` enqueues and returns; the queue owns the run. The
8
+ * auto-namer's pattern: a per-key promise chain, `idle()` for tests.
9
+ * - `queueDigest` — the mixed-conversation spill: loop 1 never forks a
10
+ * shared conversation, it appends the id to the per-user
11
+ * `digest-queue.json` (state), and loop 2's digest step (Task B4)
12
+ * consumes the list. Dedup by conversation id — one pending entry per
13
+ * conversation, however many runs triggered it — with the taken
14
+ * hints merged into the entry (the decision took them read-and-clear;
15
+ * the digest run folds them into its instruction).
16
+ * - the refunded-hints file (`pending-hints.json`) — the fix for the
17
+ * dropped-hints bug: `takeHints` is read-and-clear, so a review that
18
+ * ends failed / cancelled / deadline would otherwise lose its hints
19
+ * for good. Non-completed reviews write them back (refund, merged by
20
+ * hint id); the decision merges a conversation's refunded batch into
21
+ * every fresh take; a COMPLETED review (or a digest entry that takes
22
+ * ownership) clears the ids it consumed. The transfer is deliberately
23
+ * at-least-once: a crash between refund and clear duplicates a hint
24
+ * (dedup absorbs it), never drops one.
25
+ *
26
+ * Errors never break a chain: the queue's contract is that a job's
27
+ * failure is the job's business (the loop records it); the chain keeps
28
+ * draining.
29
+ *
30
+ * Every hints helper here dedups by hint id: a hint may briefly live in
31
+ * two places (host + refund file, refund file + digest entry) around a
32
+ * crash, and dedup makes the overlap harmless.
33
+ */
34
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
35
+ import { join } from "node:path";
36
+ /**
37
+ * The per-user FIFO review queue. `enqueue` never runs the work on the
38
+ * caller's stack — it appends to the user's chain; `idle()` resolves
39
+ * when NO user has work in flight or queued.
40
+ */
41
+ export class ReviewQueue {
42
+ /** One promise chain per user (the FIFO + the one-in-flight rule). */
43
+ chains = new Map();
44
+ /** Jobs not yet finished, across every user (the `idle()` counter). */
45
+ pending = 0;
46
+ /** Every live `idle()` waiter, released together at drain. */
47
+ waiters = [];
48
+ /** Append one job to its user's chain. */
49
+ enqueue(job) {
50
+ const previous = this.chains.get(job.user) ?? Promise.resolve();
51
+ this.pending += 1;
52
+ const next = previous
53
+ .then(job.work)
54
+ .catch(() => undefined)
55
+ .finally(() => {
56
+ this.pending -= 1;
57
+ if (this.chains.get(job.user) === next)
58
+ this.chains.delete(job.user);
59
+ if (this.pending === 0 && this.waiters.length > 0) {
60
+ const waiters = this.waiters;
61
+ this.waiters = [];
62
+ for (const resolve of waiters)
63
+ resolve();
64
+ }
65
+ });
66
+ this.chains.set(job.user, next);
67
+ }
68
+ /**
69
+ * Resolve when every enqueued job has finished (immediately when
70
+ * the queue is empty). Tests await this; production never does.
71
+ */
72
+ idle() {
73
+ if (this.pending === 0)
74
+ return Promise.resolve();
75
+ return new Promise((resolve) => {
76
+ this.waiters.push(resolve);
77
+ });
78
+ }
79
+ }
80
+ /** The digest-queue file name under the user's state dir. */
81
+ const DIGEST_QUEUE_FILE = "digest-queue.json";
82
+ /**
83
+ * Read one JSON state file with a fallback: absent or malformed → the
84
+ * fallback value (state files are the loop's own bookkeeping — a bad
85
+ * file degrades to defaults, never a broken loop).
86
+ */
87
+ export async function readStateJson(file, fallback) {
88
+ let raw;
89
+ try {
90
+ raw = await readFile(file, "utf8");
91
+ }
92
+ catch {
93
+ return fallback; // absent: never written
94
+ }
95
+ try {
96
+ return JSON.parse(raw);
97
+ }
98
+ catch {
99
+ return fallback;
100
+ }
101
+ }
102
+ /** Write one JSON state file under the user's state dir (tab indents). */
103
+ export async function writeStateJson(stateDir, name, value) {
104
+ await mkdir(stateDir, { recursive: true });
105
+ await writeFile(join(stateDir, name), `${JSON.stringify(value, null, "\t")}\n`, "utf8");
106
+ }
107
+ /** Read the digest queue file path for one user's state dir. */
108
+ function digestQueueFile(stateDir) {
109
+ return join(stateDir, DIGEST_QUEUE_FILE);
110
+ }
111
+ /** Read the digest queue from a file; absent/malformed → []. */
112
+ async function readDigestQueueFile(file) {
113
+ const parsed = await readStateJson(file, {});
114
+ if (!Array.isArray(parsed.conversations))
115
+ return [];
116
+ return parsed.conversations.filter((entry) => typeof entry === "object" &&
117
+ entry !== null &&
118
+ typeof entry.id === "number" &&
119
+ Array.isArray(entry.hints));
120
+ }
121
+ /** Read the digest queue's entries for one user; absent/malformed → []. */
122
+ export async function readDigestQueue(host, user) {
123
+ return await readDigestQueueFile(digestQueueFile(host.stores.user(user).paths.state));
124
+ }
125
+ /** Overwrite the digest queue's entries (the digest step's write-back). */
126
+ export async function writeDigestQueue(host, user, conversations) {
127
+ await writeStateJson(host.stores.user(user).paths.state, DIGEST_QUEUE_FILE, { conversations });
128
+ }
129
+ /**
130
+ * Remove one conversation's digest entry (the digest step, after the
131
+ * review completed and took ownership of its hints). Idempotent.
132
+ */
133
+ export async function removeDigestEntry(host, user, conversationId) {
134
+ const queue = [...(await readDigestQueue(host, user))];
135
+ const next = queue.filter((entry) => entry.id !== conversationId);
136
+ if (next.length === queue.length)
137
+ return; // absent: nothing to remove
138
+ await writeDigestQueue(host, user, next);
139
+ }
140
+ /**
141
+ * Queue one conversation for loop 2's digest (Task B4 consumes): append
142
+ * to `digest-queue.json` under the user's state dir, dedup by id, the
143
+ * taken hints merged into the existing entry by hint id (never dropped).
144
+ */
145
+ export async function queueDigest(host, user, conversationId, hints = []) {
146
+ const file = digestQueueFile(host.stores.user(user).paths.state);
147
+ const queue = [...(await readDigestQueueFile(file))];
148
+ const existing = queue.find((entry) => entry.id === conversationId);
149
+ if (existing === undefined) {
150
+ queue.push({ id: conversationId, hints });
151
+ }
152
+ else {
153
+ const seen = new Set(existing.hints.map((hint) => hint.id));
154
+ const merged = [
155
+ ...existing.hints,
156
+ ...hints.filter((hint) => !seen.has(hint.id)),
157
+ ];
158
+ if (merged.length === existing.hints.length)
159
+ return; // nothing new
160
+ queue[queue.indexOf(existing)] = { id: conversationId, hints: merged };
161
+ }
162
+ await writeDigestQueue(host, user, queue);
163
+ }
164
+ /** The refunded-hints file name under the user's state dir. */
165
+ const PENDING_HINTS_FILE = "pending-hints.json";
166
+ /** Read the refunded-hints file; absent/malformed → []. */
167
+ async function readPendingHints(file) {
168
+ const parsed = await readStateJson(file, []);
169
+ if (!Array.isArray(parsed))
170
+ return [];
171
+ return parsed.filter((entry) => typeof entry === "object" &&
172
+ entry !== null &&
173
+ typeof entry.conversationId === "number" &&
174
+ Array.isArray(entry.hints));
175
+ }
176
+ /** Merge two hint batches into one, deduped by hint id. */
177
+ export function mergeHints(first, second) {
178
+ const seen = new Set(first.map((hint) => hint.id));
179
+ return [...first, ...second.filter((hint) => !seen.has(hint.id))];
180
+ }
181
+ /** The hints refunded for one conversation (a read; nothing is cleared). */
182
+ export async function pendingHintsFor(host, user, conversationId) {
183
+ const entries = await readPendingHints(join(host.stores.user(user).paths.state, PENDING_HINTS_FILE));
184
+ return entries.find((entry) => entry.conversationId === conversationId)
185
+ ?.hints ?? [];
186
+ }
187
+ /**
188
+ * Refund: write a non-completed review's hints back, merged into the
189
+ * conversation's refunded batch by hint id (never dropped, never
190
+ * duplicated — an unchanged entry is not rewritten).
191
+ */
192
+ export async function refundHints(host, user, conversationId, hints) {
193
+ if (hints.length === 0)
194
+ return;
195
+ const stateDir = host.stores.user(user).paths.state;
196
+ const entries = [
197
+ ...(await readPendingHints(join(stateDir, PENDING_HINTS_FILE))),
198
+ ];
199
+ const existing = entries.find((entry) => entry.conversationId === conversationId);
200
+ if (existing === undefined) {
201
+ entries.push({ conversationId, hints });
202
+ }
203
+ else {
204
+ const merged = mergeHints(existing.hints, hints);
205
+ if (merged.length === existing.hints.length)
206
+ return; // nothing new
207
+ entries[entries.indexOf(existing)] = {
208
+ conversationId,
209
+ hints: merged,
210
+ };
211
+ }
212
+ await writeStateJson(stateDir, PENDING_HINTS_FILE, entries);
213
+ }
214
+ /**
215
+ * Clear: drop the hint ids a consumer took ownership of (a completed
216
+ * review, or a digest entry that persists them); the conversation's
217
+ * entry leaves when it empties. An unknown id (e.g. a fresh hint that
218
+ * was never refunded) is a no-op, and so is an untouched file.
219
+ */
220
+ export async function clearPendingHints(host, user, conversationId, hints) {
221
+ const stateDir = host.stores.user(user).paths.state;
222
+ const entries = [
223
+ ...(await readPendingHints(join(stateDir, PENDING_HINTS_FILE))),
224
+ ];
225
+ const index = entries.findIndex((entry) => entry.conversationId === conversationId);
226
+ if (index === -1)
227
+ return; // nothing refunded: no entry to clear
228
+ const consumed = new Set(hints.map((hint) => hint.id));
229
+ const remaining = entries[index].hints.filter((hint) => !consumed.has(hint.id));
230
+ if (remaining.length === entries[index].hints.length)
231
+ return; // untouched
232
+ if (remaining.length === 0)
233
+ entries.splice(index, 1);
234
+ else
235
+ entries[index] = { conversationId, hints: remaining };
236
+ await writeStateJson(stateDir, PENDING_HINTS_FILE, entries);
237
+ }
@@ -0,0 +1,38 @@
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
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
13
+ /** The per-user curator settings (spec §6.2). */
14
+ export interface CuratorSettings {
15
+ /** Whether the curator runs for this user at all (restart-free: read per event). */
16
+ readonly enabled: boolean;
17
+ /** Pauses the loops without disabling the view or `/learn` (a vacation switch). */
18
+ readonly paused: boolean;
19
+ /** The model idle-pass digest runs use (absent: the server default). */
20
+ readonly idleModel?: string;
21
+ /** Model calls between reviews (spec §5 pins 10). */
22
+ readonly threshold: number;
23
+ /** Idle-pass digest runs per day (spec §5 pins 12). */
24
+ readonly dailyRuns: number;
25
+ }
26
+ /** The defaults every absent or malformed field falls back to. */
27
+ export declare const DEFAULT_CURATOR_SETTINGS: Readonly<CuratorSettings>;
28
+ /**
29
+ * Parse a raw settings object into {@link CuratorSettings}: every field
30
+ * validated by TYPE and range, falling back to its default — the read
31
+ * is total, never a throw.
32
+ */
33
+ export declare function parseCuratorSettings(raw: Record<string, unknown>): CuratorSettings;
34
+ /**
35
+ * Read one user's settings LIVE through the per-user scoped store,
36
+ * merged over the defaults.
37
+ */
38
+ export declare function readSettings(host: SigrunHost, user: string): Promise<CuratorSettings>;
@@ -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>;