@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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/dist/backoff.d.ts +22 -0
- package/dist/backoff.js +39 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +143 -0
- package/dist/learn.d.ts +60 -0
- package/dist/learn.js +97 -0
- package/dist/loop1.d.ts +77 -0
- package/dist/loop1.js +262 -0
- package/dist/loop2.d.ts +108 -0
- package/dist/loop2.js +323 -0
- package/dist/notices.d.ts +54 -0
- package/dist/notices.js +127 -0
- package/dist/prompts.d.ts +45 -0
- package/dist/prompts.js +57 -0
- package/dist/queue.d.ts +111 -0
- package/dist/queue.js +237 -0
- package/dist/settings.d.ts +38 -0
- package/dist/settings.js +56 -0
- package/dist/steps/archive.d.ts +40 -0
- package/dist/steps/archive.js +88 -0
- package/dist/steps/diet.d.ts +29 -0
- package/dist/steps/diet.js +131 -0
- package/dist/steps/digests.d.ts +55 -0
- package/dist/steps/digests.js +243 -0
- package/dist/steps/merge.d.ts +17 -0
- package/dist/steps/merge.js +81 -0
- package/dist/steps/overlays.d.ts +92 -0
- package/dist/steps/overlays.js +476 -0
- package/dist/steps/run.d.ts +77 -0
- package/dist/steps/run.js +76 -0
- package/dist/steps/targets.d.ts +16 -0
- package/dist/steps/targets.js +42 -0
- package/dist/view.d.ts +105 -0
- package/dist/view.js +412 -0
- package/package.json +55 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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.2.0] - 2026-11-03
|
|
9
|
+
### Changed
|
|
10
|
+
- The curator view follows core Skills: the learned-skills and overlays
|
|
11
|
+
sections are gone (the core `core/skills` view owns them), replaced by
|
|
12
|
+
a "Skills" page action that navigates to the Skills tab. The view keeps
|
|
13
|
+
the review feed, the pause switch, and the memory targets.
|
|
14
|
+
- The feed is git-backed: entry ids are commit SHAs, `actor` includes
|
|
15
|
+
`outside` (rendered "outside sigrun"), undo groups key on the newest
|
|
16
|
+
commit of a review, and a deep-linked `review` param filters the feed
|
|
17
|
+
(paging up to three feed pages to find older reviews). Undo resolves
|
|
18
|
+
the group sha back to the review id before dispatching; a failing undo
|
|
19
|
+
is a named action error, never a thrown action.
|
|
20
|
+
|
|
21
|
+
## [0.1.1] - 2026-11-03
|
|
22
|
+
### Fixed
|
|
23
|
+
- Curator questions about your skill notes now open their own conversation
|
|
24
|
+
instead of landing in your latest one; unanswered questions no longer
|
|
25
|
+
accumulate. Questions asked before this version stay answerable where they
|
|
26
|
+
were asked.
|
|
27
|
+
- A review in flight is no longer cancelled by a re-rendered conversation
|
|
28
|
+
view; only genuinely new activity on the parent aborts it.
|
|
29
|
+
|
|
30
|
+
## [0.1.0] - 2026-11-02
|
|
31
|
+
### Added
|
|
32
|
+
- Scaffold: the `createCurator` factory (the loader contract) declaring the
|
|
33
|
+
`curator` extension, the `curator-tick` job over the `curator.tick` task,
|
|
34
|
+
the `/learn` command, the `curator.notice` ambient kind, the
|
|
35
|
+
`curator.overlay-question` interaction kind, the curator view surface, and
|
|
36
|
+
per-user settings (`enabled`, `paused`, `idleModel`, `threshold`,
|
|
37
|
+
`dailyRuns`) read with defaults.
|
|
38
|
+
- Loop 2: the idle maintenance pass — the `curator.tick` task running one
|
|
39
|
+
resumable pass fragment per fired tick (a durable per-user step cursor
|
|
40
|
+
under a 5-minute self-deadline), the eligibility gate (quiet user, no
|
|
41
|
+
full pass in 24h, first-run grace), and the deterministic steps:
|
|
42
|
+
archiving skills unused for 30 days (pinned and job-referenced spared)
|
|
43
|
+
and reaping overlays orphaned for 30 days; overlay drift detection
|
|
44
|
+
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
|
-
#
|
|
1
|
+
# @sigrun/ext-curator
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/backoff.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/learn.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/loop1.d.ts
ADDED
|
@@ -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
|
+
};
|