@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-11-02
9
+ ### Added
10
+ - Scaffold: the `createCurator` factory (the loader contract) declaring the
11
+ `curator` extension, the `curator-tick` job over the `curator.tick` task,
12
+ the `/learn` command, the `curator.notice` ambient kind, the
13
+ `curator.overlay-question` interaction kind, the curator view surface, and
14
+ per-user settings (`enabled`, `paused`, `idleModel`, `threshold`,
15
+ `dailyRuns`) read with defaults.
16
+ - Loop 2: the idle maintenance pass — the `curator.tick` task running one
17
+ resumable pass fragment per fired tick (a durable per-user step cursor
18
+ under a 5-minute self-deadline), the eligibility gate (quiet user, no
19
+ full pass in 24h, first-run grace), and the deterministic steps:
20
+ archiving skills unused for 30 days (pinned and job-referenced spared)
21
+ and reaping overlays orphaned for 30 days; overlay drift detection
22
+ queues changed upstreams for re-check.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael MacLeod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the
10
+ Software is furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # @sigrun/ext-curator
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The curator built-in extension for
4
+ [sigrun](https://github.com/sigrun-ai/sigrun): per-user curation loops
5
+ over learned skills and curation targets.
6
+
7
+ - **Loop 1 — fork review.** After a settled run in an owned conversation
8
+ (threshold model calls since the last review, or pending review hints),
9
+ the curator forks the conversation at its newest entry and drives a
10
+ bounded review run whose writes land in the owner's learned-skill
11
+ store.
12
+ - **Loop 2 — idle pass.** The `curator-tick` job (every 15 minutes)
13
+ walks idle users through a resumable pass: archiving unused skills,
14
+ re-checking drifted overlays, digesting conversations, dieting and
15
+ merging skills, and running target maintenance.
16
+
17
+ Per-user settings (via the user's scoped settings store) control it:
18
+ `enabled` (default true), `paused` (default false), `idleModel`,
19
+ `threshold` (default 10), `dailyRuns` (default 12). `/learn [focus]`
20
+ requests a review on demand; the curator view lists skills, overlays and
21
+ the change feed with per-review undo.
22
+
23
+ ## What you see
24
+
25
+ Mostly nothing. When a review CHANGES something, the originating
26
+ conversation's status line carries one quiet notice — a
27
+ `Learned: …` line per change with a button to the **Curator** view
28
+ (the notice never lands in the transcript, so forks never replay it,
29
+ and it is never pushed to a notifier or chat channel).
30
+
31
+ The **Curator** view (per user; only your own data ever renders) lists
32
+
33
+ - **Learned skills** — name, description, last used, with a pin toggle
34
+ and archive/restore on each;
35
+ - **Overlays** — your notes on upstream skills, flagged `orphaned`
36
+ when the upstream vanished;
37
+ - **The change feed** — the newest 50 changes, grouped by review, each
38
+ group with an **Undo review** button that reverts every change that
39
+ review made (and clears its notice);
40
+ - a **pause switch** — `Pause curation` stops both loops for you
41
+ without disabling anything else; the view and `/learn` keep working
42
+ while paused.
43
+
44
+ Undo is two clicks (notice → view → Undo), not one: the notice lives
45
+ on the display-only ambient seam, so the undo action lives on the
46
+ view.
47
+
48
+ ## `/learn [focus]`
49
+
50
+ `/learn` asks for a review of the conversation NOW — bypassing the
51
+ call-count threshold but keeping every eligibility rule. In a
52
+ conversation with other participants the answer says so and the
53
+ owner's hints are queued; the digest reviews the OWNER'S attributed
54
+ turns only — another participant's words never enter it. Focus, when
55
+ given, rides the review's instruction. `/learn` works
56
+ while paused: the pause switch stops the automatic loops, not your
57
+ own explicit ask.
58
+
59
+ This package is a sigrun built-in: sigrun core ships and loads it by
60
+ default (`builtinExtensions.curator: false` disables it). It is not
61
+ useful outside sigrun.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The per-user review backoff (`src/backoff.ts`, Task B2): a failed
3
+ * review pauses THAT user's reviews on openclaw's ladder — 15, then
4
+ * 30, then 60 minutes (spec §6.2) — so background curation never adds
5
+ * to an already degraded provider. A success resets the ladder.
6
+ *
7
+ * In-memory by design (the plan's interface): the ladder protects the
8
+ * provider, not the user's data, and a restart re-arms it clean.
9
+ */
10
+ /**
11
+ * The backoff ladder, per user. Every method takes `now` (the fake
12
+ * clock seam) — the loop reads one clock for every decision.
13
+ */
14
+ export declare class Backoff {
15
+ private readonly users;
16
+ /** Whether `user`'s reviews are paused at `now`. */
17
+ blocked(user: string, now: Date): boolean;
18
+ /** Step the ladder: block `user` for 15, then 30, then 60 minutes. */
19
+ failure(user: string, now: Date): void;
20
+ /** A success resets the ladder entirely. */
21
+ success(user: string): void;
22
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The per-user review backoff (`src/backoff.ts`, Task B2): a failed
3
+ * review pauses THAT user's reviews on openclaw's ladder — 15, then
4
+ * 30, then 60 minutes (spec §6.2) — so background curation never adds
5
+ * to an already degraded provider. A success resets the ladder.
6
+ *
7
+ * In-memory by design (the plan's interface): the ladder protects the
8
+ * provider, not the user's data, and a restart re-arms it clean.
9
+ */
10
+ /** The ladder steps, in minutes (15 → 30 → 60). */
11
+ const STEPS_MINUTES = [15, 30, 60];
12
+ /**
13
+ * The backoff ladder, per user. Every method takes `now` (the fake
14
+ * clock seam) — the loop reads one clock for every decision.
15
+ */
16
+ export class Backoff {
17
+ users = new Map();
18
+ /** Whether `user`'s reviews are paused at `now`. */
19
+ blocked(user, now) {
20
+ const state = this.users.get(user);
21
+ if (state === undefined || state.unblockAt === undefined)
22
+ return false;
23
+ return now.getTime() < state.unblockAt;
24
+ }
25
+ /** Step the ladder: block `user` for 15, then 30, then 60 minutes. */
26
+ failure(user, now) {
27
+ const state = this.users.get(user) ?? { failures: 0 };
28
+ const step = Math.min(state.failures, STEPS_MINUTES.length - 1);
29
+ const minutes = STEPS_MINUTES[step];
30
+ this.users.set(user, {
31
+ failures: state.failures + 1,
32
+ unblockAt: now.getTime() + minutes * 60_000,
33
+ });
34
+ }
35
+ /** A success resets the ladder entirely. */
36
+ success(user) {
37
+ this.users.delete(user);
38
+ }
39
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * sigrun-ext-curator: the curator factory (the loader contract).
3
+ *
4
+ * The sigrun extension-package contract (sigrun's src/harness/loader.ts):
5
+ * the default export is a FACTORY DECLARING the host parameter (arity 1 —
6
+ * the host rule), called once at boot, before `Harness.open` reads the
7
+ * registry. The factory registers handlers only — no timers, no I/O in
8
+ * the factory body (the lifecycle rule); the loops start through their
9
+ * own surfaces (the settled-run handler and the job's task).
10
+ *
11
+ * What the scaffold declares (Task B1; the behaviors land with their
12
+ * tasks):
13
+ *
14
+ * - `extension` — `defineExtension({ name: "curator", tasks: [tickTask] })`:
15
+ * the name is the loader slug; the task is the durable body the job
16
+ * fires.
17
+ * - `jobs` — `curator-tick`, `every: "15m"` (spec §5 pins the interval),
18
+ * `action: { kind: "task", task: "curator.tick" }` — jobs are the only
19
+ * time-based scheduler, so loop 2's cadence is a job, never a timer.
20
+ * - `runSettled` — loop 1's decision hook (Task B2): decides on settle
21
+ * and enqueues a review; the review itself runs on the per-user
22
+ * queue, never inside the hook deadline.
23
+ * - `commands` — `/learn [focus]`: enqueue a loop 1 review of this
24
+ * conversation now, bypassing the threshold but not eligibility
25
+ * (Task B5).
26
+ * - `ambientKinds` / `interactionKinds` — the seam's write-gate
27
+ * declarations: `curator.notice` (the post-review ambient notice) and
28
+ * `curator.overlay-question` (the unsure-overlay question).
29
+ * - `surfaces` — the curator view: learned skills (pin,
30
+ * archive/restore), overlays, the feed grouped by review with undo,
31
+ * and the pause switch (Task B5).
32
+ *
33
+ * Per-user settings (`src/settings.ts`) are read live per event through
34
+ * the host's per-user scoped store — nothing is captured at factory time.
35
+ */
36
+ import type { AnyTask } from "@earendil-works/pi-durable";
37
+ import type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
38
+ /** The durable task kind the tick job fires (TaskRecord.kind). */
39
+ export declare const CURATOR_TICK_TASK = "curator.tick";
40
+ /** The definition version persisted with the task. */
41
+ export declare const CURATOR_TICK_VERSION = 1;
42
+ /** The tick job's name (spec §5: every 15 minutes). */
43
+ export declare const CURATOR_TICK_JOB = "curator-tick";
44
+ /** The post-review ambient notice key (Task B5's display surface). */
45
+ export declare const CURATOR_NOTICE_KIND = "curator.notice";
46
+ /** The unsure-overlay question kind (Task B4's interaction). */
47
+ export declare const CURATOR_OVERLAY_QUESTION_KIND = "curator.overlay-question";
48
+ /**
49
+ * Loop 2's durable body (Task B3): one phase calling `tick(host, now)`
50
+ * — as many pass fragments as the tick's own 5-minute deadline
51
+ * allows; unfinished steps resume on the next fired tick through the
52
+ * per-user pass cursor (`pass.json`). The scaffold's terminal-commit
53
+ * shape stays: whatever the pass did, the task phase itself only
54
+ * runs the tick and completes.
55
+ */
56
+ export declare function createTickTask(host: SigrunHost): AnyTask;
57
+ export { createLearnCommand } from "./learn.js";
58
+ export type { LearnNow, LearnOutcome } from "./learn.js";
59
+ export { createCuratorView, viewAccess, groupFeed, VIEW_FEED_LIMIT } from "./view.js";
60
+ export type { CuratorViewData } from "./view.js";
61
+ /**
62
+ * The sigrun extension-package factory: builds the curator's declared
63
+ * surface over the host. One factory call, one wiring. The constructed
64
+ * loop-1 state (the review queue, the backoff ladder) is plain memory
65
+ * the handlers use — no timers, no I/O run here.
66
+ */
67
+ export default function createCurator(host: SigrunHost): SigrunExtensionResult;
68
+ export { parseCuratorSettings, readSettings, DEFAULT_CURATOR_SETTINGS, } from "./settings.js";
69
+ export { notify, readNotices } from "./notices.js";
70
+ export type { NoticeRecord } from "./notices.js";
71
+ export type { CuratorSettings } from "./settings.js";
72
+ export { createLoop1, CURSORS_FILE, LOOP1_MAX_TOOL_CALLS, LOOP1_MAX_OUTPUT_TOKENS, LOOP1_DEADLINE_MS, } from "./loop1.js";
73
+ export type { Loop1Deps } from "./loop1.js";
74
+ export { enqueueLearn, learnReviewId } from "./learn.js";
75
+ export { createLoop2, eligibleUsers, tick, IDLE_THRESHOLD_MS, LOOP2_TICK_DEADLINE_MS, FIRST_RUN_GRACE_MS, FULL_PASS_INTERVAL_MS, PASS_FILE, PASS_STEPS, FIRST_TICK_FILE, } from "./loop2.js";
76
+ export type { StepId, PassCursor, TickOptions, Loop2Deps } from "./loop2.js";
77
+ export { archiveUnused, ARCHIVE_UNUSED_DAYS, ARCHIVE_UNUSED_MS, } from "./steps/archive.js";
78
+ export { detectOverlayDrift, OverlayRecheckQueue, RECHECK_QUEUE_FILE, recheckOverlays, overlayQuestionHandler, } from "./steps/overlays.js";
79
+ export { digestsStep, reviewDigest, DIGEST_VIEW_GRACE_MS } from "./steps/digests.js";
80
+ export { dietStep, DIET_BYTES, DIET_UNCHANGED_MS } from "./steps/diet.js";
81
+ export { mergeStep } from "./steps/merge.js";
82
+ export { maintainTargets } from "./steps/targets.js";
83
+ export { digestRunWithBudget, parseVerdict, MODEL_STEP_MAX_TOOL_CALLS, MODEL_STEP_MAX_OUTPUT_TOKENS, } from "./steps/run.js";
84
+ export type { ModelStepDeps, RunBudget, DigestRun, DigestRunInput } from "./steps/run.js";
85
+ export { Backoff } from "./backoff.js";
86
+ export { ReviewQueue, queueDigest, readDigestQueue, removeDigestEntry, } from "./queue.js";
87
+ export type { ReviewJob } from "./queue.js";
88
+ export { reviewInstruction } from "./prompts.js";
89
+ export type { ReviewInstructionOpts } from "./prompts.js";
package/dist/index.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * sigrun-ext-curator: the curator factory (the loader contract).
3
+ *
4
+ * The sigrun extension-package contract (sigrun's src/harness/loader.ts):
5
+ * the default export is a FACTORY DECLARING the host parameter (arity 1 —
6
+ * the host rule), called once at boot, before `Harness.open` reads the
7
+ * registry. The factory registers handlers only — no timers, no I/O in
8
+ * the factory body (the lifecycle rule); the loops start through their
9
+ * own surfaces (the settled-run handler and the job's task).
10
+ *
11
+ * What the scaffold declares (Task B1; the behaviors land with their
12
+ * tasks):
13
+ *
14
+ * - `extension` — `defineExtension({ name: "curator", tasks: [tickTask] })`:
15
+ * the name is the loader slug; the task is the durable body the job
16
+ * fires.
17
+ * - `jobs` — `curator-tick`, `every: "15m"` (spec §5 pins the interval),
18
+ * `action: { kind: "task", task: "curator.tick" }` — jobs are the only
19
+ * time-based scheduler, so loop 2's cadence is a job, never a timer.
20
+ * - `runSettled` — loop 1's decision hook (Task B2): decides on settle
21
+ * and enqueues a review; the review itself runs on the per-user
22
+ * queue, never inside the hook deadline.
23
+ * - `commands` — `/learn [focus]`: enqueue a loop 1 review of this
24
+ * conversation now, bypassing the threshold but not eligibility
25
+ * (Task B5).
26
+ * - `ambientKinds` / `interactionKinds` — the seam's write-gate
27
+ * declarations: `curator.notice` (the post-review ambient notice) and
28
+ * `curator.overlay-question` (the unsure-overlay question).
29
+ * - `surfaces` — the curator view: learned skills (pin,
30
+ * archive/restore), overlays, the feed grouped by review with undo,
31
+ * and the pause switch (Task B5).
32
+ *
33
+ * Per-user settings (`src/settings.ts`) are read live per event through
34
+ * the host's per-user scoped store — nothing is captured at factory time.
35
+ */
36
+ import { defineExtension, defineTask } from "@earendil-works/pi-durable";
37
+ import { createLoop1 } from "./loop1.js";
38
+ import { createLoop2 } from "./loop2.js";
39
+ import { createLearnCommand } from "./learn.js";
40
+ import { createCuratorView } from "./view.js";
41
+ import { Backoff } from "./backoff.js";
42
+ import { ReviewQueue } from "./queue.js";
43
+ import { overlayQuestionHandler } from "./steps/overlays.js";
44
+ /** The durable task kind the tick job fires (TaskRecord.kind). */
45
+ export const CURATOR_TICK_TASK = "curator.tick";
46
+ /** The definition version persisted with the task. */
47
+ export const CURATOR_TICK_VERSION = 1;
48
+ /** The tick job's name (spec §5: every 15 minutes). */
49
+ export const CURATOR_TICK_JOB = "curator-tick";
50
+ /** The post-review ambient notice key (Task B5's display surface). */
51
+ export const CURATOR_NOTICE_KIND = "curator.notice";
52
+ /** The unsure-overlay question kind (Task B4's interaction). */
53
+ export const CURATOR_OVERLAY_QUESTION_KIND = "curator.overlay-question";
54
+ /**
55
+ * Loop 2's durable body (Task B3): one phase calling `tick(host, now)`
56
+ * — as many pass fragments as the tick's own 5-minute deadline
57
+ * allows; unfinished steps resume on the next fired tick through the
58
+ * per-user pass cursor (`pass.json`). The scaffold's terminal-commit
59
+ * shape stays: whatever the pass did, the task phase itself only
60
+ * runs the tick and completes.
61
+ */
62
+ export function createTickTask(host) {
63
+ return defineTask({
64
+ name: CURATOR_TICK_TASK,
65
+ version: CURATOR_TICK_VERSION,
66
+ initial: () => ({ phase: "tick" }),
67
+ phases: {
68
+ tick: async (_task, runtime, context) => {
69
+ await createLoop2({ host }).tick();
70
+ await runtime.commit(() => ({
71
+ status: "terminal",
72
+ outcome: { status: "completed", result: undefined },
73
+ }), context);
74
+ },
75
+ },
76
+ abort: async (_task, runtime, context) => {
77
+ await runtime.commit(() => ({
78
+ status: "terminal",
79
+ outcome: { status: "aborted", reason: "job disabled or deleted" },
80
+ }), context);
81
+ },
82
+ });
83
+ }
84
+ export { createLearnCommand } from "./learn.js";
85
+ export { createCuratorView, viewAccess, groupFeed, VIEW_FEED_LIMIT } from "./view.js";
86
+ /**
87
+ * The sigrun extension-package factory: builds the curator's declared
88
+ * surface over the host. One factory call, one wiring. The constructed
89
+ * loop-1 state (the review queue, the backoff ladder) is plain memory
90
+ * the handlers use — no timers, no I/O run here.
91
+ */
92
+ export default function createCurator(host) {
93
+ const loop1 = createLoop1({
94
+ host,
95
+ queue: new ReviewQueue(),
96
+ backoff: new Backoff(),
97
+ });
98
+ // Loop 2 needs no factory-time binding: the tick task builds its own
99
+ // `createLoop2({ host })` per phase (the job is the only scheduler).
100
+ // The overlay question's answer applies here (Task B4): the
101
+ // unsure-overlay interaction seeded by the re-check step settles
102
+ // through the generic interaction lifecycle.
103
+ const overlayAnswers = overlayQuestionHandler(host);
104
+ return {
105
+ extension: defineExtension({
106
+ name: "curator",
107
+ tasks: [createTickTask(host)],
108
+ }),
109
+ // Loop 1's decision hook: decide + enqueue + return (the review
110
+ // runs on the queue; the hook deadline never bounds it).
111
+ runSettled: loop1.onRunSettled,
112
+ // The overlay question's answer hook (the design's only prompt).
113
+ interactionSettled: overlayAnswers.interactionSettled,
114
+ // Loop 2's cadence: the ONLY time-based scheduler (the worker
115
+ // `every` is retired; jobs are the timer).
116
+ jobs: [
117
+ {
118
+ name: CURATOR_TICK_JOB,
119
+ schedule: { every: "15m" },
120
+ action: { kind: "task", task: CURATOR_TICK_TASK },
121
+ },
122
+ ],
123
+ commands: [createLearnCommand(host, loop1.learnNow)],
124
+ ambientKinds: [CURATOR_NOTICE_KIND],
125
+ interactionKinds: [CURATOR_OVERLAY_QUESTION_KIND],
126
+ surfaces: { views: [createCuratorView(host)] },
127
+ };
128
+ }
129
+ export { parseCuratorSettings, readSettings, DEFAULT_CURATOR_SETTINGS, } from "./settings.js";
130
+ export { notify, readNotices } from "./notices.js";
131
+ export { createLoop1, CURSORS_FILE, LOOP1_MAX_TOOL_CALLS, LOOP1_MAX_OUTPUT_TOKENS, LOOP1_DEADLINE_MS, } from "./loop1.js";
132
+ export { enqueueLearn, learnReviewId } from "./learn.js";
133
+ export { createLoop2, eligibleUsers, tick, IDLE_THRESHOLD_MS, LOOP2_TICK_DEADLINE_MS, FIRST_RUN_GRACE_MS, FULL_PASS_INTERVAL_MS, PASS_FILE, PASS_STEPS, FIRST_TICK_FILE, } from "./loop2.js";
134
+ export { archiveUnused, ARCHIVE_UNUSED_DAYS, ARCHIVE_UNUSED_MS, } from "./steps/archive.js";
135
+ export { detectOverlayDrift, OverlayRecheckQueue, RECHECK_QUEUE_FILE, recheckOverlays, overlayQuestionHandler, } from "./steps/overlays.js";
136
+ export { digestsStep, reviewDigest, DIGEST_VIEW_GRACE_MS } from "./steps/digests.js";
137
+ export { dietStep, DIET_BYTES, DIET_UNCHANGED_MS } from "./steps/diet.js";
138
+ export { mergeStep } from "./steps/merge.js";
139
+ export { maintainTargets } from "./steps/targets.js";
140
+ export { digestRunWithBudget, parseVerdict, MODEL_STEP_MAX_TOOL_CALLS, MODEL_STEP_MAX_OUTPUT_TOKENS, } from "./steps/run.js";
141
+ export { Backoff } from "./backoff.js";
142
+ export { ReviewQueue, queueDigest, readDigestQueue, removeDigestEntry, } from "./queue.js";
143
+ export { reviewInstruction } from "./prompts.js";
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `/learn [focus]` (`src/learn.ts`, Task B5): the foreground command
3
+ * that asks for a review NOW — the same review loop 1 would run, but
4
+ * bypassing the THRESHOLD (the user asked; no call-count gate stands
5
+ * between them and a review) while keeping every ELIGIBILITY rule (an
6
+ * internal conversation, or a conversation whose owner is not the
7
+ * caller, still gets nothing).
8
+ *
9
+ * The command only ENQUEUES (loop 1's queue owns the review body —
10
+ * the command's route deadline never bounds a review) and answers
11
+ * `{notice}` naming what will happen:
12
+ *
13
+ * - owner-only conversation → "a curator review of this conversation
14
+ * will run now" (with the focus when given);
15
+ * - a MIXED conversation (loop 1 would never fork it) → the answer
16
+ * says the curator only learns from the owner's own messages: the
17
+ * conversation goes to loop 2's digest queue, which reviews only
18
+ * the owner's attributed turns.
19
+ *
20
+ * The hint set folds in like every review (pending hints, refunded
21
+ * batches), and `focus` rides the instruction (`reviewInstruction`).
22
+ */
23
+ import type { ConversationId, EntryId } from "@earendil-works/pi-durable";
24
+ import type { CurationHint, ExtensionCommandContribution, SigrunHost } from "@sigrun/sigrun/extension";
25
+ /** What {@link createLearnCommand} runs the request through (loop 1's
26
+ * `learnNow`, so the command and the settle hook share ONE review
27
+ * path). */
28
+ export type LearnNow = (conversationId: ConversationId, user: string, focus: string | undefined) => Promise<LearnOutcome>;
29
+ /** What `learnNow` answers: which path took the request. */
30
+ export type LearnOutcome = {
31
+ readonly kind: "queued";
32
+ } | {
33
+ readonly kind: "digest";
34
+ } | {
35
+ readonly kind: "refused";
36
+ readonly reason: string;
37
+ };
38
+ /**
39
+ * The `/learn` command contribution, bound to the host and loop 1's
40
+ * `learnNow`. Runs with the invoking principal: its `user` is the
41
+ * owner the review would learn for.
42
+ */
43
+ export declare function createLearnCommand(host: SigrunHost, learnNow: LearnNow): ExtensionCommandContribution;
44
+ /**
45
+ * The `/learn` enqueue path, shared by the command and usable directly:
46
+ * decide eligibility, then fork-queue or digest-queue — never running a
47
+ * review on the caller's stack. Exported for loop 1 to expose as its
48
+ * `learnNow` seam (the same decision tree the settle hook walks, minus
49
+ * the threshold gate and the paused/enabled gates: `/learn` works while
50
+ * paused — the vacation switch stops the LOOPS, not the user's own
51
+ * explicit ask).
52
+ */
53
+ export declare function enqueueLearn(host: SigrunHost, queue: {
54
+ enqueue(job: {
55
+ readonly user: string;
56
+ readonly work: () => Promise<void>;
57
+ }): void;
58
+ }, conversationId: ConversationId, user: string, focus: string | undefined, reviewBody: (owner: string, id: ConversationId, hints: readonly CurationHint[], newestEntry: EntryId | undefined, focus: string | undefined) => Promise<void>): Promise<LearnOutcome>;
59
+ /** The review id a `/learn`-triggered review runs under. */
60
+ export declare function learnReviewId(): string;
package/dist/learn.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `/learn [focus]` (`src/learn.ts`, Task B5): the foreground command
3
+ * that asks for a review NOW — the same review loop 1 would run, but
4
+ * bypassing the THRESHOLD (the user asked; no call-count gate stands
5
+ * between them and a review) while keeping every ELIGIBILITY rule (an
6
+ * internal conversation, or a conversation whose owner is not the
7
+ * caller, still gets nothing).
8
+ *
9
+ * The command only ENQUEUES (loop 1's queue owns the review body —
10
+ * the command's route deadline never bounds a review) and answers
11
+ * `{notice}` naming what will happen:
12
+ *
13
+ * - owner-only conversation → "a curator review of this conversation
14
+ * will run now" (with the focus when given);
15
+ * - a MIXED conversation (loop 1 would never fork it) → the answer
16
+ * says the curator only learns from the owner's own messages: the
17
+ * conversation goes to loop 2's digest queue, which reviews only
18
+ * the owner's attributed turns.
19
+ *
20
+ * The hint set folds in like every review (pending hints, refunded
21
+ * batches), and `focus` rides the instruction (`reviewInstruction`).
22
+ */
23
+ import { randomUUID } from "node:crypto";
24
+ import { clearPendingHints, mergeHints, pendingHintsFor, queueDigest, } from "./queue.js";
25
+ /** The command's `{notice}` for each outcome (the wording is pinned by
26
+ * the tests only in the mixed case; the queued wording names focus). */
27
+ function noticeFor(outcome, focus) {
28
+ switch (outcome.kind) {
29
+ case "queued": {
30
+ const withFocus = focus === "" ? "" : ` (focus: ${focus})`;
31
+ return `A curator review of this conversation will run now${withFocus}.`;
32
+ }
33
+ case "digest":
34
+ return "This conversation has other participants: the curator only learns from your own messages, and the review will run from those alone.";
35
+ case "refused":
36
+ return `No curator review: ${outcome.reason}`;
37
+ }
38
+ }
39
+ /**
40
+ * The `/learn` command contribution, bound to the host and loop 1's
41
+ * `learnNow`. Runs with the invoking principal: its `user` is the
42
+ * owner the review would learn for.
43
+ */
44
+ export function createLearnCommand(host, learnNow) {
45
+ return {
46
+ name: "learn",
47
+ description: "Ask the curator to review this conversation now",
48
+ args: "[focus]",
49
+ run: async (ctx) => {
50
+ const user = ctx.principal.user;
51
+ if (user === undefined) {
52
+ return { notice: "No curator review: you are not signed in." };
53
+ }
54
+ const focus = ctx.args.trim();
55
+ const outcome = await learnNow(ctx.conversationId, user, focus === "" ? undefined : focus);
56
+ return { notice: noticeFor(outcome, focus === "" ? undefined : focus) };
57
+ },
58
+ };
59
+ }
60
+ /**
61
+ * The `/learn` enqueue path, shared by the command and usable directly:
62
+ * decide eligibility, then fork-queue or digest-queue — never running a
63
+ * review on the caller's stack. Exported for loop 1 to expose as its
64
+ * `learnNow` seam (the same decision tree the settle hook walks, minus
65
+ * the threshold gate and the paused/enabled gates: `/learn` works while
66
+ * paused — the vacation switch stops the LOOPS, not the user's own
67
+ * explicit ask).
68
+ */
69
+ export async function enqueueLearn(host, queue, conversationId, user, focus, reviewBody) {
70
+ const eligibility = await host.curation.eligibility(conversationId);
71
+ if (eligibility.internal) {
72
+ return { kind: "refused", reason: "this conversation is internal" };
73
+ }
74
+ if (eligibility.owner !== user) {
75
+ return {
76
+ kind: "refused",
77
+ reason: "this conversation belongs to another user",
78
+ };
79
+ }
80
+ const hints = mergeHints(await pendingHintsFor(host, user, conversationId), await host.curation.takeHints(conversationId));
81
+ if (eligibility.ownerOnly && eligibility.unattributed === 0) {
82
+ queue.enqueue({
83
+ user,
84
+ work: () => reviewBody(user, conversationId, hints, eligibility.newestEntry, focus).catch(() => undefined),
85
+ });
86
+ return { kind: "queued" };
87
+ }
88
+ // Mixed authorship or unattributed entries: never forked — the
89
+ // digest queue (loop 2 reviews only the owner's attributed turns).
90
+ await queueDigest(host, user, conversationId, hints);
91
+ await clearPendingHints(host, user, conversationId, hints);
92
+ return { kind: "digest" };
93
+ }
94
+ /** The review id a `/learn`-triggered review runs under. */
95
+ export function learnReviewId() {
96
+ return `learn-${randomUUID()}`;
97
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Loop 1 — the post-run fork review (`src/loop1.ts`, Task B2, spec
3
+ * §6.2).
4
+ *
5
+ * `onRunSettled` DECIDES and ENQUEUES, then returns: runSettled
6
+ * handlers are abandoned at `SIGRUN_HOOK_DEADLINE` (30 s), so the
7
+ * review body NEVER runs on the handler's stack — it runs through the
8
+ * per-user `ReviewQueue`.
9
+ *
10
+ * The decision tree (the brief, verbatim authority):
11
+ *
12
+ * 1. owner present, settings enabled and not paused, not backed off —
13
+ * else return (the hints are left pending, never consumed by a
14
+ * review that will not run);
15
+ * 2. an internal conversation returns (the bus already withholds
16
+ * internal settles; this read-side gate is the belt);
17
+ * 3. `eligibility.ownerOnly && unattributed === 0` → the fork path:
18
+ * take the hints (read-and-clear) merged with any refunded batch
19
+ * (`pending-hints.json` — hints a previous review consumed but
20
+ * never reviewed), count `modelCallsSince(id, lastReviewedEntry)`,
21
+ * and review when hints are pending (answered or not — an aborted
22
+ * run's hints stay pending until the next settle) or the run
23
+ * answered at/over the threshold;
24
+ * 4. anything else (mixed authorship, unattributed entries) → the
25
+ * threshold trigger alone queues a `queueDigest` entry: never
26
+ * forked, handed to loop 2 (Task B4). (Hints ride in that durable
27
+ * entry — taken read-and-clear and folded into the digest run's
28
+ * instruction by Task B4 — and the refund file drains into it, so
29
+ * no hint can wait on both.)
30
+ *
31
+ * The review itself: the allow-list is `skill_manage` + the served
32
+ * targets' tools + `read` when the parent's selection carries it; the
33
+ * bounds are the spec's (8 tool calls, 2048 output tokens, 2 minutes);
34
+ * a watch on the parent aborts the run's signal the moment the parent
35
+ * moves. Success anchors the per-conversation cursor
36
+ * (`cursors.json`) at the eligibility snapshot's newest entry; a
37
+ * failed run steps that user's backoff ladder; any NON-completed
38
+ * outcome (failed, cancelled, deadline — or a thrown review) refunds
39
+ * its hints to `pending-hints.json`, so a consumed-but-unreviewed
40
+ * hint survives until some review completes with it in hand.
41
+ */
42
+ import type { ConversationId } from "@earendil-works/pi-durable";
43
+ import type { ExtensionRunSettledHandler, SigrunHost } from "@sigrun/sigrun/extension";
44
+ import { Backoff } from "./backoff.js";
45
+ import { type LearnOutcome } from "./learn.js";
46
+ /** The per-conversation cursor file under the user's state dir. */
47
+ export declare const CURSORS_FILE = "cursors.json";
48
+ /** The fork-review bounds (spec §6.2: a handful of calls, small output, ~2 minutes). */
49
+ export declare const LOOP1_MAX_TOOL_CALLS = 8;
50
+ export declare const LOOP1_MAX_OUTPUT_TOKENS = 2048;
51
+ export declare const LOOP1_DEADLINE_MS = 120000;
52
+ /** What {@link createLoop1} needs (all but `now` are required). */
53
+ export interface Loop1Deps {
54
+ readonly host: SigrunHost;
55
+ /** The per-user review queue — the handler never runs a review itself. */
56
+ readonly queue: {
57
+ enqueue(job: {
58
+ readonly user: string;
59
+ readonly work: () => Promise<void>;
60
+ }): void;
61
+ };
62
+ /** The per-user failure ladder this loop steps. */
63
+ readonly backoff: Backoff;
64
+ /** The clock every backoff decision reads (tests fake it). */
65
+ readonly now?: () => Date;
66
+ /** One line per unexpected error; defaults to `console.error`. */
67
+ readonly log?: (message: string) => void;
68
+ }
69
+ /**
70
+ * Create loop 1: the `runSettled` handler the factory wires, and the
71
+ * `learnNow` seam `/learn` runs the same review path through. The
72
+ * handler owns only the decision; the queue owns the review.
73
+ */
74
+ export declare function createLoop1(deps: Loop1Deps): {
75
+ readonly onRunSettled: ExtensionRunSettledHandler;
76
+ readonly learnNow: (conversationId: ConversationId, user: string, focus: string | undefined) => Promise<LearnOutcome>;
77
+ };