@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,54 @@
1
+ /**
2
+ * The post-review notice (`src/notices.ts`, Task B5): what the user
3
+ * sees when a review changed something — ONE ambient record on the
4
+ * originating conversation (`curator.notice`, place `status`), one
5
+ * line per change (`Learned: <summary>`), with a `navigate` action to
6
+ * the curator view anchored at the review.
7
+ *
8
+ * The ambient seam is the ONLY carrier (the brief): a notice is never
9
+ * a transcript entry (a fork would replay it) and never a notifier or
10
+ * channel delivery (gateway channels get no notice by default, spec
11
+ * P8). Ambient records are display-only — no action route reaches
12
+ * them — so Undo lives on the curator VIEW; the notice's button just
13
+ * navigates there.
14
+ *
15
+ * The body must pass the server's own ambient validation (a `status`
16
+ * slot allows ONE line-level block, literal content only — ambient
17
+ * bodies carry no data document, so bindings are rejected): a single
18
+ * `callout` whose title joins the change lines and whose actions
19
+ * carry the navigate button. One line-level block per seam rule.
20
+ *
21
+ * DURABILITY: each notice is remembered in `notices.json` under the
22
+ * user's state dir (`readNotices` is the view's lookup) — an Undo
23
+ * after a restart still knows which conversation's ambient record to
24
+ * clear. The file is bounded: recording a notice replaces the entry
25
+ * of the SAME review id (a re-run of one review id is one notice) and
26
+ * the file keeps the newest 50 (matching the feed window the view
27
+ * shows — older notices have nothing left to undo once the feed rolls
28
+ * past them).
29
+ */
30
+ import type { ConversationId } from "@earendil-works/pi-durable";
31
+ import type { CurationRunResult, SigrunHost } from "@sigrun/sigrun/extension";
32
+ import { CURATOR_NOTICE_KIND } from "./index.js";
33
+ export { CURATOR_NOTICE_KIND };
34
+ /** What one recorded notice remembers (the view's undo lookup). */
35
+ export interface NoticeRecord {
36
+ /** The review the notice announced (the undo key). */
37
+ readonly reviewId: string;
38
+ /** The conversation whose ambient slot carries the notice. */
39
+ readonly conversationId: ConversationId;
40
+ /** When the notice was set (ISO). */
41
+ readonly at: string;
42
+ }
43
+ /**
44
+ * Announce one completed review: when the result carries changes, set
45
+ * the `curator.notice` ambient record on the conversation and record
46
+ * the notice durably. No changes → nothing (an unchanged conversation
47
+ * stays untouched). Answers the notice record (undefined when no
48
+ * notice was set).
49
+ */
50
+ export declare function notify(host: SigrunHost, conversationId: ConversationId, user: string, result: CurationRunResult & {
51
+ readonly reviewId?: string;
52
+ }): Promise<NoticeRecord | undefined>;
53
+ /** Read the user's recorded notices (newest first); absent/malformed → []. */
54
+ export declare function readNotices(host: SigrunHost, user: string): Promise<readonly NoticeRecord[]>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The post-review notice (`src/notices.ts`, Task B5): what the user
3
+ * sees when a review changed something — ONE ambient record on the
4
+ * originating conversation (`curator.notice`, place `status`), one
5
+ * line per change (`Learned: <summary>`), with a `navigate` action to
6
+ * the curator view anchored at the review.
7
+ *
8
+ * The ambient seam is the ONLY carrier (the brief): a notice is never
9
+ * a transcript entry (a fork would replay it) and never a notifier or
10
+ * channel delivery (gateway channels get no notice by default, spec
11
+ * P8). Ambient records are display-only — no action route reaches
12
+ * them — so Undo lives on the curator VIEW; the notice's button just
13
+ * navigates there.
14
+ *
15
+ * The body must pass the server's own ambient validation (a `status`
16
+ * slot allows ONE line-level block, literal content only — ambient
17
+ * bodies carry no data document, so bindings are rejected): a single
18
+ * `callout` whose title joins the change lines and whose actions
19
+ * carry the navigate button. One line-level block per seam rule.
20
+ *
21
+ * DURABILITY: each notice is remembered in `notices.json` under the
22
+ * user's state dir (`readNotices` is the view's lookup) — an Undo
23
+ * after a restart still knows which conversation's ambient record to
24
+ * clear. The file is bounded: recording a notice replaces the entry
25
+ * of the SAME review id (a re-run of one review id is one notice) and
26
+ * the file keeps the newest 50 (matching the feed window the view
27
+ * shows — older notices have nothing left to undo once the feed rolls
28
+ * past them).
29
+ */
30
+ import { join } from "node:path";
31
+ import { CURATOR_NOTICE_KIND } from "./index.js";
32
+ export { CURATOR_NOTICE_KIND };
33
+ /** The notice ledger's file name under the user's state dir. */
34
+ const NOTICES_FILE = "notices.json";
35
+ /** How many notices the ledger keeps (the view's feed window). */
36
+ const NOTICE_LEDGER_MAX = 50;
37
+ /** The zero-signal context the ambient write runs under (the review
38
+ * body has no caller signal of its own — the run's deadline bound it). */
39
+ const CONTEXT = { abortSignal: undefined };
40
+ /**
41
+ * Announce one completed review: when the result carries changes, set
42
+ * the `curator.notice` ambient record on the conversation and record
43
+ * the notice durably. No changes → nothing (an unchanged conversation
44
+ * stays untouched). Answers the notice record (undefined when no
45
+ * notice was set).
46
+ */
47
+ export async function notify(host, conversationId, user, result) {
48
+ if (result.status !== "completed")
49
+ return undefined;
50
+ if (result.changes.length === 0)
51
+ return undefined;
52
+ const reviewId = result.reviewId;
53
+ if (reviewId === undefined || reviewId === "")
54
+ return undefined;
55
+ const at = new Date().toISOString();
56
+ await host.setAmbient(conversationId, {
57
+ key: CURATOR_NOTICE_KIND,
58
+ place: "status",
59
+ body: noticeBody(result.changes.map((change) => change.summary), reviewId),
60
+ }, CONTEXT);
61
+ const record = { reviewId, conversationId, at };
62
+ await recordNotice(host, user, record);
63
+ return record;
64
+ }
65
+ /**
66
+ * The notice body: ONE line-level `callout` (the `status` slot's
67
+ * one-line rule) whose title joins the change summaries and whose
68
+ * actions navigate to the curator view anchored at the review.
69
+ */
70
+ function noticeBody(summaries, reviewId) {
71
+ const lines = summaries.map((summary) => `Learned: ${summary}`);
72
+ return {
73
+ v: 1,
74
+ root: {
75
+ type: "callout",
76
+ id: "curator-notice",
77
+ tone: "neutral",
78
+ // The one-line rule makes the title the line the channels show;
79
+ // multiple changes join on the same line (a statusline slot).
80
+ title: lines.join(" · "),
81
+ body: "Open the curator view to review or undo.",
82
+ actions: [
83
+ {
84
+ id: "open",
85
+ label: "Curator",
86
+ icon: "book",
87
+ action: {
88
+ type: "navigate",
89
+ target: {
90
+ view: {
91
+ extension: "curator",
92
+ id: "curator",
93
+ params: { review: reviewId },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ ],
99
+ },
100
+ };
101
+ }
102
+ /** The ledger file path for one user. */
103
+ function noticesFile(host, user) {
104
+ return join(host.stores.user(user).paths.state, NOTICES_FILE);
105
+ }
106
+ /** Read the user's recorded notices (newest first); absent/malformed → []. */
107
+ export async function readNotices(host, user) {
108
+ const { readStateJson } = await import("./queue.js");
109
+ const parsed = await readStateJson(noticesFile(host, user), []);
110
+ if (!Array.isArray(parsed))
111
+ return [];
112
+ return parsed.filter((entry) => typeof entry === "object" &&
113
+ entry !== null &&
114
+ typeof entry.reviewId === "string" &&
115
+ typeof entry.conversationId === "number" &&
116
+ typeof entry.at === "string");
117
+ }
118
+ /** Record one notice: replace the same review's entry, keep the newest 50. */
119
+ async function recordNotice(host, user, record) {
120
+ const { writeStateJson } = await import("./queue.js");
121
+ const current = await readNotices(host, user);
122
+ const next = [
123
+ record,
124
+ ...current.filter((entry) => entry.reviewId !== record.reviewId),
125
+ ].slice(0, NOTICE_LEDGER_MAX);
126
+ await writeStateJson(host.stores.user(user).paths.state, NOTICES_FILE, next);
127
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The review prompt (`src/prompts.ts`, Task B2): what a fork review or
3
+ * digest review submits as its only input. The fork carries the
4
+ * parent's transcript, so the instruction is the reviewer's brief, not
5
+ * the material.
6
+ *
7
+ * The CONTENT is spec §6.2's review-prompt requirements (the wording is
8
+ * ours; the snapshot test pins it once written):
9
+ *
10
+ * - what a skill is — a procedure for a repeatable class of task, for
11
+ * this user; pitfalls as a rule plus one clause of why; no incident
12
+ * narration, dates, PR numbers or quoted chatter;
13
+ * - the do-not-capture list — environment failures, negative claims
14
+ * about tools, transient errors, unresolved failures presented as
15
+ * workflows, one-off tasks, codebase facts (the repo's AGENTS.md),
16
+ * secrets, and personal facts (a memory target when one is offered);
17
+ * - the routing rule — each lesson goes in exactly one place: one
18
+ * skill, one overlay, or one target;
19
+ * - update the skill in play before creating one; class-level names;
20
+ * - "Nothing to save" is a valid outcome;
21
+ * - never store another participant's words;
22
+ * - the hint arguments and exactly the allowed tools.
23
+ *
24
+ * Loop 2's digest reviews reuse this instruction verbatim (Task B4).
25
+ */
26
+ import type { CurationHint } from "@sigrun/sigrun/extension";
27
+ /** What `reviewInstruction` needs (the brief's exact shape). */
28
+ export interface ReviewInstructionOpts {
29
+ /** Owner write attempts the review folds in (empty: none pending). */
30
+ readonly hints: readonly CurationHint[];
31
+ /** Curation targets offered to this run, with their guidance. */
32
+ readonly targets: readonly {
33
+ readonly id: string;
34
+ readonly guidance: readonly string[];
35
+ }[];
36
+ /** The run's allow list — the instruction names these and no others. */
37
+ readonly allow: readonly string[];
38
+ /** `/learn`'s optional focus, riding along when given. */
39
+ readonly focus?: string;
40
+ }
41
+ /**
42
+ * Build the review instruction (the run's only input). Deterministic:
43
+ * fixed inputs produce the fixed text the snapshot pins.
44
+ */
45
+ export declare function reviewInstruction(opts: ReviewInstructionOpts): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The review prompt (`src/prompts.ts`, Task B2): what a fork review or
3
+ * digest review submits as its only input. The fork carries the
4
+ * parent's transcript, so the instruction is the reviewer's brief, not
5
+ * the material.
6
+ *
7
+ * The CONTENT is spec §6.2's review-prompt requirements (the wording is
8
+ * ours; the snapshot test pins it once written):
9
+ *
10
+ * - what a skill is — a procedure for a repeatable class of task, for
11
+ * this user; pitfalls as a rule plus one clause of why; no incident
12
+ * narration, dates, PR numbers or quoted chatter;
13
+ * - the do-not-capture list — environment failures, negative claims
14
+ * about tools, transient errors, unresolved failures presented as
15
+ * workflows, one-off tasks, codebase facts (the repo's AGENTS.md),
16
+ * secrets, and personal facts (a memory target when one is offered);
17
+ * - the routing rule — each lesson goes in exactly one place: one
18
+ * skill, one overlay, or one target;
19
+ * - update the skill in play before creating one; class-level names;
20
+ * - "Nothing to save" is a valid outcome;
21
+ * - never store another participant's words;
22
+ * - the hint arguments and exactly the allowed tools.
23
+ *
24
+ * Loop 2's digest reviews reuse this instruction verbatim (Task B4).
25
+ */
26
+ /**
27
+ * Build the review instruction (the run's only input). Deterministic:
28
+ * fixed inputs produce the fixed text the snapshot pins.
29
+ */
30
+ export function reviewInstruction(opts) {
31
+ const lines = [
32
+ "You are the curator. Review the conversation above and distill durable lessons for this user.",
33
+ ];
34
+ if (opts.focus !== undefined) {
35
+ lines.push(`Focus: ${opts.focus}.`);
36
+ }
37
+ lines.push("", "A skill is a procedure for a repeatable class of task, for this user. State pitfalls as a rule plus one clause of why. No incident narration, no dates, no PR numbers, no quoted chatter.", "", "Do NOT capture: environment failures; negative claims about tools; transient errors; unresolved failures presented as workflows; one-off tasks; codebase facts (those belong in the repo's AGENTS.md); secrets; personal facts (those go to the memory target when one is offered). Never store another participant's words.", "", "Each lesson goes in exactly one place: one skill, one overlay, or one target. Update the skill in play before creating one; pick class-level names.", "", '"Nothing to save" is a valid outcome — when nothing qualifies, finish with that sentence and change nothing.');
38
+ if (opts.targets.length > 0) {
39
+ lines.push("", "Targets:");
40
+ for (const target of opts.targets) {
41
+ for (const line of target.guidance) {
42
+ lines.push(`- ${target.id}: ${line}`);
43
+ }
44
+ if (target.guidance.length === 0) {
45
+ lines.push(`- ${target.id}.`);
46
+ }
47
+ }
48
+ }
49
+ if (opts.hints.length > 0) {
50
+ lines.push("", "The user made these write attempts between reviews — fold their intent in:");
51
+ for (const [index, hint] of opts.hints.entries()) {
52
+ lines.push(`${index + 1}. ${hint.tool} ${JSON.stringify(hint.args)} at ${hint.at}`);
53
+ }
54
+ }
55
+ lines.push("", `Your tools: ${opts.allow.join(", ")}. Nothing else.`);
56
+ return `${lines.join("\n")}\n`;
57
+ }
@@ -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>;