@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 +22 -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 +250 -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 +88 -0
- package/dist/steps/overlays.js +358 -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 +92 -0
- package/dist/view.js +405 -0
- package/package.json +55 -5
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge step (`src/steps/merge.ts`, Task B4 — loop 2 step `merge`):
|
|
3
|
+
* ONE pair of learned skills a run judges same-class merges — the run
|
|
4
|
+
* patches the survivor and the step archives the other with
|
|
5
|
+
* `absorbed_into` naming the survivor (the spec's merge shape).
|
|
6
|
+
*
|
|
7
|
+
* The verdict (`{"merge": ["a","b"], "survivor": "a"}`) is the run's
|
|
8
|
+
* final text in the fixed JSON shape. It must name exactly two KNOWN,
|
|
9
|
+
* unarchived, learned skills with a known survivor among them —
|
|
10
|
+
* anything else (unparseable text, unknown names, a mismatched
|
|
11
|
+
* survivor) changes nothing, and a run that already wrote through its
|
|
12
|
+
* tools is reverted with `undoReview` (an unverified merge never
|
|
13
|
+
* stands, mirroring the diet's compare rule).
|
|
14
|
+
*/
|
|
15
|
+
import { digestRunWithBudget, parseVerdict, stepLog } from "./run.js";
|
|
16
|
+
/** The merge's allow list: the run patches through skill_manage. */
|
|
17
|
+
const MERGE_ALLOW = ["skill_manage"];
|
|
18
|
+
/** The loop-2 merge step: at most one judged merge per pass. */
|
|
19
|
+
export async function mergeStep(deps) {
|
|
20
|
+
const { host, user } = deps;
|
|
21
|
+
const log = stepLog(deps);
|
|
22
|
+
if (deps.budget.left() < 1)
|
|
23
|
+
return false; // the day is spent: stop the pass
|
|
24
|
+
const skills = await host.curation.skills(user).list();
|
|
25
|
+
const active = skills.filter((row) => !row.archived);
|
|
26
|
+
if (active.length < 2)
|
|
27
|
+
return true; // nothing mergeable
|
|
28
|
+
const result = await digestRunWithBudget(deps, {
|
|
29
|
+
input: mergeInput(active),
|
|
30
|
+
allow: MERGE_ALLOW,
|
|
31
|
+
});
|
|
32
|
+
if (result === undefined)
|
|
33
|
+
return false; // no budget left today
|
|
34
|
+
if (result.result.status !== "completed")
|
|
35
|
+
return true;
|
|
36
|
+
const verdict = parseVerdict(result.result.text);
|
|
37
|
+
const plan = verdict === undefined ? undefined : mergePlan(verdict, active);
|
|
38
|
+
if (plan !== undefined) {
|
|
39
|
+
// The run patched the survivor; the step archives the absorbed
|
|
40
|
+
// skill naming it (absorbed_into) — undoable through the feed.
|
|
41
|
+
await host.curation.skills(user).apply(plan.absorbed, { kind: "archive", absorbedInto: plan.survivor }, { reason: `merged into ${plan.survivor}` });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
// Unparseable or unusable verdict: a run that wrote anyway is undone
|
|
45
|
+
// — an unverified merge never stands (net: no change).
|
|
46
|
+
if (result.result.changes.length > 0) {
|
|
47
|
+
await host.curation.undoReview(user, result.reviewId).catch((error) => {
|
|
48
|
+
log(`loop 2: ${user} merge undo of review failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
/** Validate a verdict into a merge plan; undefined when it names
|
|
54
|
+
* anything but exactly two known active skills and a survivor of them. */
|
|
55
|
+
function mergePlan(verdict, active) {
|
|
56
|
+
if (!Array.isArray(verdict.merge) || verdict.merge.length !== 2)
|
|
57
|
+
return undefined;
|
|
58
|
+
const [first, second] = verdict.merge;
|
|
59
|
+
if (typeof first !== "string" || typeof second !== "string")
|
|
60
|
+
return undefined;
|
|
61
|
+
if (typeof verdict.survivor !== "string")
|
|
62
|
+
return undefined;
|
|
63
|
+
const names = new Set(active.map((row) => row.name));
|
|
64
|
+
if (!names.has(first) || !names.has(second))
|
|
65
|
+
return undefined;
|
|
66
|
+
if (verdict.survivor !== first && verdict.survivor !== second)
|
|
67
|
+
return undefined;
|
|
68
|
+
const absorbed = verdict.survivor === first ? second : first;
|
|
69
|
+
return { survivor: verdict.survivor, absorbed };
|
|
70
|
+
}
|
|
71
|
+
/** The merge run's input: the candidate list and the fixed verdict. */
|
|
72
|
+
function mergeInput(active) {
|
|
73
|
+
const rows = active.map((row) => `- ${row.name} (${row.bytes} bytes): ${row.description}`);
|
|
74
|
+
return [
|
|
75
|
+
"You are the curator. These are the user's learned skills. Decide whether exactly ONE pair covers the same class of task and should merge (the survivor absorbs the other's rules; use your skill_manage tool to patch the survivor so it covers both). Most passes find no pair — that is fine.",
|
|
76
|
+
"",
|
|
77
|
+
...rows,
|
|
78
|
+
"",
|
|
79
|
+
'When you merged a pair, answer with ONLY this JSON: {"merge": ["<survivor>", "<absorbed>"], "survivor": "<survivor>"}. Otherwise answer with {}.',
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay drift detection (`src/steps/overlays.ts`, Task B3 — the
|
|
3
|
+
* DETECTION half; the re-check model step is Task B4's).
|
|
4
|
+
*
|
|
5
|
+
* `detectOverlayDrift` walks every stored overlay and compares the
|
|
6
|
+
* hash it was written against (`upstreamHash`) with the upstream as it
|
|
7
|
+
* stands (`upstream(user, key)?.hash`):
|
|
8
|
+
*
|
|
9
|
+
* - ABSENT upstream → `overlays(user).markOrphans(present, now)`: the
|
|
10
|
+
* host-side pass stamps `orphanedSince` (or clears it when the
|
|
11
|
+
* upstream is back). The present-predicate answers from this pass's
|
|
12
|
+
* upstream reads, so the mark and the drift walk agree on the same
|
|
13
|
+
* observation.
|
|
14
|
+
*
|
|
15
|
+
* - CHANGED hash → the overlay is QUEUED for a re-check in
|
|
16
|
+
* `overlay-recheck-queue.json` under the user's state dir (the file
|
|
17
|
+
* name this repo chose for the seam; the `overlay-recheck` step
|
|
18
|
+
* takes it from there). Queueing is all B3 does — deciding
|
|
19
|
+
* keep/drop/rewrite takes a model and is the next step's business.
|
|
20
|
+
* Keys dedup by their canonical JSON, so a pass that re-observes
|
|
21
|
+
* the same drift twice queues it once.
|
|
22
|
+
*
|
|
23
|
+
* Deterministic by design: reads + one `markOrphans` + one state-file
|
|
24
|
+
* write; no model, no interaction.
|
|
25
|
+
*
|
|
26
|
+
* Task B4 adds the RE-CHECK half: `recheckOverlays` (the loop-2 model
|
|
27
|
+
* step) drains the queue, one `digestRun` per drifted key, and applies
|
|
28
|
+
* the run's verdict — keep (re-anchor the same text on the new hash),
|
|
29
|
+
* drop (remove), rewrite (put the verdict's new text), or unsure (ONE
|
|
30
|
+
* `curator.overlay-question` interaction in the user's most recent
|
|
31
|
+
* owned conversation, applied by `overlayQuestionHandler` from
|
|
32
|
+
* `interactionSettled`). An unparseable verdict changes nothing.
|
|
33
|
+
*/
|
|
34
|
+
import type { CurationOverlayKey, SigrunHost, SigrunLifecycleHandlers } from "@sigrun/sigrun/extension";
|
|
35
|
+
import { type ModelStepDeps } from "./run.js";
|
|
36
|
+
/**
|
|
37
|
+
* The overlay-recheck queue's file name under the user's state dir —
|
|
38
|
+
* the seam B3 writes and Task B4's `overlay-recheck` step consumes.
|
|
39
|
+
*/
|
|
40
|
+
export declare const RECHECK_QUEUE_FILE = "overlay-recheck-queue.json";
|
|
41
|
+
/** A stable identity for one overlay key (dedup and diffs). */
|
|
42
|
+
export declare function keyId(key: CurationOverlayKey): string;
|
|
43
|
+
/** Read the queued re-check keys; absent/malformed → []. */
|
|
44
|
+
export declare function readRecheckQueue(host: SigrunHost, user: string): Promise<readonly CurationOverlayKey[]>;
|
|
45
|
+
/**
|
|
46
|
+
* The queue B4 consumes: `take()` drains (read-and-clear — a taken
|
|
47
|
+
* re-check is the re-check step's to finish), `enqueue` appends deduped.
|
|
48
|
+
*/
|
|
49
|
+
export declare class OverlayRecheckQueue {
|
|
50
|
+
private readonly host;
|
|
51
|
+
private readonly user;
|
|
52
|
+
constructor(host: SigrunHost, user: string);
|
|
53
|
+
/** Append keys, deduped against what is queued (by identity). */
|
|
54
|
+
enqueue(keys: readonly CurationOverlayKey[]): Promise<void>;
|
|
55
|
+
/** Read and clear the queue (B4's `overlay-recheck` step start). */
|
|
56
|
+
take(): Promise<readonly CurationOverlayKey[]>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The overlay-detect step: mark orphans with the host and queue every
|
|
60
|
+
* drifted overlay for a re-check (loop 2 step `overlay-detect`).
|
|
61
|
+
*/
|
|
62
|
+
export declare function detectOverlayDrift(host: SigrunHost, user: string): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* The overlay-recheck step (loop 2 step `overlay-recheck`): drain the
|
|
65
|
+
* re-check queue and re-check every drifted overlay — ONE digest run
|
|
66
|
+
* per key, under the daily budget. Verdicts apply immediately
|
|
67
|
+
* (keep/drop/rewrite); "unsure" asks the user (the design's only
|
|
68
|
+
* prompt) and the answer applies through {@link overlayQuestionHandler}.
|
|
69
|
+
* Answers FALSE when the daily budget ran out mid-queue (the pass
|
|
70
|
+
* stops for the day; the keys still queued re-check next pass — the
|
|
71
|
+
* re-check queue only drains as keys complete).
|
|
72
|
+
*
|
|
73
|
+
* Crash-durability posture (the B3 carry): `take()` is read-then-clear,
|
|
74
|
+
* never at-least-once — DETECTION is the system of record; a lost
|
|
75
|
+
* queue entry self-heals on the next detect pass. A crash after a
|
|
76
|
+
* run but before its verdict applied wastes one model run, nothing
|
|
77
|
+
* more (the verdict is re-derived next pass).
|
|
78
|
+
*/
|
|
79
|
+
export declare function recheckOverlays(deps: ModelStepDeps): Promise<boolean>;
|
|
80
|
+
/**
|
|
81
|
+
* The `interactionSettled` half: apply a settled overlay question's
|
|
82
|
+
* answer. Only ANSWERED records with a `verdict` of keep/drop apply;
|
|
83
|
+
* every other terminal status (skipped, expired, cancelled) changes
|
|
84
|
+
* nothing. `keep` re-anchors the remembered overlay text on the
|
|
85
|
+
* remembered hash; `drop` removes the overlay. Idempotent per callId
|
|
86
|
+
* (an applied question leaves the pending file).
|
|
87
|
+
*/
|
|
88
|
+
export declare function overlayQuestionHandler(host: SigrunHost): Pick<SigrunLifecycleHandlers, "interactionSettled">;
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay drift detection (`src/steps/overlays.ts`, Task B3 — the
|
|
3
|
+
* DETECTION half; the re-check model step is Task B4's).
|
|
4
|
+
*
|
|
5
|
+
* `detectOverlayDrift` walks every stored overlay and compares the
|
|
6
|
+
* hash it was written against (`upstreamHash`) with the upstream as it
|
|
7
|
+
* stands (`upstream(user, key)?.hash`):
|
|
8
|
+
*
|
|
9
|
+
* - ABSENT upstream → `overlays(user).markOrphans(present, now)`: the
|
|
10
|
+
* host-side pass stamps `orphanedSince` (or clears it when the
|
|
11
|
+
* upstream is back). The present-predicate answers from this pass's
|
|
12
|
+
* upstream reads, so the mark and the drift walk agree on the same
|
|
13
|
+
* observation.
|
|
14
|
+
*
|
|
15
|
+
* - CHANGED hash → the overlay is QUEUED for a re-check in
|
|
16
|
+
* `overlay-recheck-queue.json` under the user's state dir (the file
|
|
17
|
+
* name this repo chose for the seam; the `overlay-recheck` step
|
|
18
|
+
* takes it from there). Queueing is all B3 does — deciding
|
|
19
|
+
* keep/drop/rewrite takes a model and is the next step's business.
|
|
20
|
+
* Keys dedup by their canonical JSON, so a pass that re-observes
|
|
21
|
+
* the same drift twice queues it once.
|
|
22
|
+
*
|
|
23
|
+
* Deterministic by design: reads + one `markOrphans` + one state-file
|
|
24
|
+
* write; no model, no interaction.
|
|
25
|
+
*
|
|
26
|
+
* Task B4 adds the RE-CHECK half: `recheckOverlays` (the loop-2 model
|
|
27
|
+
* step) drains the queue, one `digestRun` per drifted key, and applies
|
|
28
|
+
* the run's verdict — keep (re-anchor the same text on the new hash),
|
|
29
|
+
* drop (remove), rewrite (put the verdict's new text), or unsure (ONE
|
|
30
|
+
* `curator.overlay-question` interaction in the user's most recent
|
|
31
|
+
* owned conversation, applied by `overlayQuestionHandler` from
|
|
32
|
+
* `interactionSettled`). An unparseable verdict changes nothing.
|
|
33
|
+
*/
|
|
34
|
+
import { randomUUID } from "node:crypto";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { readStateJson, writeStateJson } from "../queue.js";
|
|
37
|
+
import { CURATOR_OVERLAY_QUESTION_KIND } from "../index.js";
|
|
38
|
+
import { digestRunWithBudget, parseVerdict, stepLog, } from "./run.js";
|
|
39
|
+
/**
|
|
40
|
+
* The overlay-recheck queue's file name under the user's state dir —
|
|
41
|
+
* the seam B3 writes and Task B4's `overlay-recheck` step consumes.
|
|
42
|
+
*/
|
|
43
|
+
export const RECHECK_QUEUE_FILE = "overlay-recheck-queue.json";
|
|
44
|
+
/** A stable identity for one overlay key (dedup and diffs). */
|
|
45
|
+
export function keyId(key) {
|
|
46
|
+
return JSON.stringify([key.root, key.skill, key.workspace ?? null]);
|
|
47
|
+
}
|
|
48
|
+
/** Read the queued re-check keys; absent/malformed → []. */
|
|
49
|
+
export async function readRecheckQueue(host, user) {
|
|
50
|
+
const parsed = await readStateJson(join(host.stores.user(user).paths.state, RECHECK_QUEUE_FILE), {});
|
|
51
|
+
if (!Array.isArray(parsed.keys))
|
|
52
|
+
return [];
|
|
53
|
+
return parsed.keys.filter((key) => typeof key === "object" &&
|
|
54
|
+
key !== null &&
|
|
55
|
+
typeof key.root === "string" &&
|
|
56
|
+
typeof key.skill === "string");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The queue B4 consumes: `take()` drains (read-and-clear — a taken
|
|
60
|
+
* re-check is the re-check step's to finish), `enqueue` appends deduped.
|
|
61
|
+
*/
|
|
62
|
+
export class OverlayRecheckQueue {
|
|
63
|
+
host;
|
|
64
|
+
user;
|
|
65
|
+
constructor(host, user) {
|
|
66
|
+
this.host = host;
|
|
67
|
+
this.user = user;
|
|
68
|
+
}
|
|
69
|
+
/** Append keys, deduped against what is queued (by identity). */
|
|
70
|
+
async enqueue(keys) {
|
|
71
|
+
if (keys.length === 0)
|
|
72
|
+
return;
|
|
73
|
+
const queued = [...(await readRecheckQueue(this.host, this.user))];
|
|
74
|
+
const seen = new Set(queued.map(keyId));
|
|
75
|
+
for (const key of keys) {
|
|
76
|
+
if (seen.has(keyId(key)))
|
|
77
|
+
continue;
|
|
78
|
+
queued.push(key);
|
|
79
|
+
seen.add(keyId(key));
|
|
80
|
+
}
|
|
81
|
+
await writeStateJson(this.host.stores.user(this.user).paths.state, RECHECK_QUEUE_FILE, { keys: queued });
|
|
82
|
+
}
|
|
83
|
+
/** Read and clear the queue (B4's `overlay-recheck` step start). */
|
|
84
|
+
async take() {
|
|
85
|
+
const keys = await readRecheckQueue(this.host, this.user);
|
|
86
|
+
if (keys.length === 0)
|
|
87
|
+
return [];
|
|
88
|
+
await writeStateJson(this.host.stores.user(this.user).paths.state, RECHECK_QUEUE_FILE, { keys: [] });
|
|
89
|
+
return keys;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The overlay-detect step: mark orphans with the host and queue every
|
|
94
|
+
* drifted overlay for a re-check (loop 2 step `overlay-detect`).
|
|
95
|
+
*/
|
|
96
|
+
export async function detectOverlayDrift(host, user) {
|
|
97
|
+
const store = host.curation.overlays(user);
|
|
98
|
+
const overlays = await store.list();
|
|
99
|
+
// One upstream read per overlay — the single observation both the
|
|
100
|
+
// orphan mark and the drift comparison answer from.
|
|
101
|
+
const upstreamByKey = new Map();
|
|
102
|
+
for (const overlay of overlays) {
|
|
103
|
+
upstreamByKey.set(keyId(overlay.key), await host.curation.upstream(user, overlay.key));
|
|
104
|
+
}
|
|
105
|
+
await store.markOrphans((key) => upstreamByKey.get(keyId(key)) !== undefined, new Date().toISOString());
|
|
106
|
+
const drifted = overlays
|
|
107
|
+
.filter((overlay) => {
|
|
108
|
+
const upstream = upstreamByKey.get(keyId(overlay.key));
|
|
109
|
+
return upstream !== undefined && upstream.hash !== overlay.upstreamHash;
|
|
110
|
+
})
|
|
111
|
+
.map((overlay) => overlay.key);
|
|
112
|
+
await new OverlayRecheckQueue(host, user).enqueue(drifted);
|
|
113
|
+
}
|
|
114
|
+
/** The overlay re-check's allow list: it writes nothing itself. */
|
|
115
|
+
const RECHECK_ALLOW = ["skill_manage"];
|
|
116
|
+
/** The pending questions file under the user's state dir. */
|
|
117
|
+
const PENDING_QUESTIONS_FILE = "overlay-questions.json";
|
|
118
|
+
/** Read the user's pending overlay questions; absent/malformed → []. */
|
|
119
|
+
async function readPendingQuestions(host, user) {
|
|
120
|
+
const parsed = await readStateJson(join(host.stores.user(user).paths.state, PENDING_QUESTIONS_FILE), []);
|
|
121
|
+
if (!Array.isArray(parsed))
|
|
122
|
+
return [];
|
|
123
|
+
return parsed.filter((entry) => typeof entry === "object" &&
|
|
124
|
+
entry !== null &&
|
|
125
|
+
typeof entry.user === "string" &&
|
|
126
|
+
typeof entry.key?.root === "string" &&
|
|
127
|
+
typeof entry.key?.skill === "string" &&
|
|
128
|
+
typeof entry.upstreamHash === "string" &&
|
|
129
|
+
typeof entry.overlayText === "string" &&
|
|
130
|
+
typeof entry.callId === "string");
|
|
131
|
+
}
|
|
132
|
+
/** Write the user's pending overlay questions. */
|
|
133
|
+
async function writePendingQuestions(host, user, questions) {
|
|
134
|
+
await writeStateJson(host.stores.user(user).paths.state, PENDING_QUESTIONS_FILE, questions);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The overlay-recheck step (loop 2 step `overlay-recheck`): drain the
|
|
138
|
+
* re-check queue and re-check every drifted overlay — ONE digest run
|
|
139
|
+
* per key, under the daily budget. Verdicts apply immediately
|
|
140
|
+
* (keep/drop/rewrite); "unsure" asks the user (the design's only
|
|
141
|
+
* prompt) and the answer applies through {@link overlayQuestionHandler}.
|
|
142
|
+
* Answers FALSE when the daily budget ran out mid-queue (the pass
|
|
143
|
+
* stops for the day; the keys still queued re-check next pass — the
|
|
144
|
+
* re-check queue only drains as keys complete).
|
|
145
|
+
*
|
|
146
|
+
* Crash-durability posture (the B3 carry): `take()` is read-then-clear,
|
|
147
|
+
* never at-least-once — DETECTION is the system of record; a lost
|
|
148
|
+
* queue entry self-heals on the next detect pass. A crash after a
|
|
149
|
+
* run but before its verdict applied wastes one model run, nothing
|
|
150
|
+
* more (the verdict is re-derived next pass).
|
|
151
|
+
*/
|
|
152
|
+
export async function recheckOverlays(deps) {
|
|
153
|
+
const { host, user } = deps;
|
|
154
|
+
const log = stepLog(deps);
|
|
155
|
+
if (deps.budget.left() < 1)
|
|
156
|
+
return false; // the day is spent: stop the pass
|
|
157
|
+
const keys = [...(await new OverlayRecheckQueue(host, user).take())];
|
|
158
|
+
for (const key of keys) {
|
|
159
|
+
if (deps.now().getTime() >= deps.deadlineAt)
|
|
160
|
+
return false;
|
|
161
|
+
const applied = await recheckOne(deps, key).catch((error) => {
|
|
162
|
+
log(`loop 2: ${user} overlay re-check of ${key.skill} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
if (!applied)
|
|
166
|
+
return false; // out of daily budget: stop for the day
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
/** Re-check one key: run, parse the verdict, apply it. */
|
|
171
|
+
async function recheckOne(deps, key) {
|
|
172
|
+
const { host, user } = deps;
|
|
173
|
+
const store = host.curation.overlays(user);
|
|
174
|
+
const overlay = await store.get(key);
|
|
175
|
+
const upstream = await host.curation.upstream(user, key);
|
|
176
|
+
if (overlay === undefined || upstream === undefined)
|
|
177
|
+
return true;
|
|
178
|
+
const run = await digestRunWithBudget(deps, {
|
|
179
|
+
input: recheckInput(overlay.text, overlay.upstreamHash, upstream.body, upstream.hash),
|
|
180
|
+
allow: RECHECK_ALLOW,
|
|
181
|
+
});
|
|
182
|
+
if (run === undefined)
|
|
183
|
+
return false;
|
|
184
|
+
if (run.result.status !== "completed")
|
|
185
|
+
return true;
|
|
186
|
+
const verdict = parseVerdict(run.result.text);
|
|
187
|
+
if (verdict === undefined)
|
|
188
|
+
return true; // unparseable: no change
|
|
189
|
+
const meta = {
|
|
190
|
+
actor: "curator",
|
|
191
|
+
reason: "overlay re-check after upstream drift",
|
|
192
|
+
reviewId: run.reviewId,
|
|
193
|
+
};
|
|
194
|
+
switch (verdict.verdict) {
|
|
195
|
+
case "keep":
|
|
196
|
+
await store.put(key, overlay.text, upstream.hash, meta);
|
|
197
|
+
return true;
|
|
198
|
+
case "drop":
|
|
199
|
+
await store.remove(key, meta);
|
|
200
|
+
return true;
|
|
201
|
+
case "rewrite":
|
|
202
|
+
if (typeof verdict.text !== "string" || verdict.text === "")
|
|
203
|
+
return true;
|
|
204
|
+
await store.put(key, verdict.text, upstream.hash, meta);
|
|
205
|
+
return true;
|
|
206
|
+
case "unsure":
|
|
207
|
+
await askOverlayQuestion(deps, key, overlay.text, upstream.hash);
|
|
208
|
+
return true;
|
|
209
|
+
default:
|
|
210
|
+
return true; // unknown verdict word: no change
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** The re-check run's input: the overlay, the version it was written
|
|
214
|
+
* against, and the NEW upstream — the three things the brief names. */
|
|
215
|
+
function recheckInput(overlayText, oldHash, newBody, newHash) {
|
|
216
|
+
return [
|
|
217
|
+
"You are the curator. This overlay (the user's notes for a skill they do not own) was written against an upstream skill that has since changed. Decide what happens to it.",
|
|
218
|
+
"",
|
|
219
|
+
"Your notes for the old upstream:",
|
|
220
|
+
overlayText,
|
|
221
|
+
"",
|
|
222
|
+
"The old upstream's version id:",
|
|
223
|
+
oldHash,
|
|
224
|
+
"",
|
|
225
|
+
"The new upstream in full:",
|
|
226
|
+
newBody,
|
|
227
|
+
"",
|
|
228
|
+
`Its new version id is ${newHash}.`,
|
|
229
|
+
"",
|
|
230
|
+
'Decide, and answer with ONLY this JSON: {"verdict":"keep"} when the notes still apply to the new upstream (they will be re-anchored to it); {"verdict":"drop"} when the change makes the notes obsolete; {"verdict":"rewrite","text":"…"} when the notes need a rewrite for the new upstream (the new notes text); or {"verdict":"unsure"} when you cannot decide — the user will be asked.',
|
|
231
|
+
].join("\n");
|
|
232
|
+
}
|
|
233
|
+
/** The zero-signal context the seed call runs under (a tick has no
|
|
234
|
+
* caller signal — the job's deadline is the bound that matters). */
|
|
235
|
+
const SEED_CONTEXT = { abortSignal: undefined };
|
|
236
|
+
/**
|
|
237
|
+
* Seed ONE `curator.overlay-question` in the user's MOST RECENT owned
|
|
238
|
+
* conversation and remember it durably (the settled handler needs the
|
|
239
|
+
* overlay text and hash — the interaction's `details` carry them, and
|
|
240
|
+
* the pending-questions file is the crash-safe copy: an answered
|
|
241
|
+
* question whose apply crashed re-applies on the next handler fire).
|
|
242
|
+
*/
|
|
243
|
+
async function askOverlayQuestion(deps, key, overlayText, upstreamHash) {
|
|
244
|
+
const { host, user } = deps;
|
|
245
|
+
const owned = await host.conversations.list({ owner: user });
|
|
246
|
+
const withActivity = owned
|
|
247
|
+
.filter((row) => row.lastActivityAt !== undefined)
|
|
248
|
+
.sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt));
|
|
249
|
+
const target = withActivity[0] ?? owned[owned.length - 1];
|
|
250
|
+
if (target === undefined)
|
|
251
|
+
return; // no owned conversation to ask in
|
|
252
|
+
const callId = `overlay-question-${randomUUID()}`;
|
|
253
|
+
const details = {
|
|
254
|
+
user,
|
|
255
|
+
key,
|
|
256
|
+
upstreamHash,
|
|
257
|
+
overlayText,
|
|
258
|
+
callId,
|
|
259
|
+
};
|
|
260
|
+
const record = {
|
|
261
|
+
kind: CURATOR_OVERLAY_QUESTION_KIND,
|
|
262
|
+
callId,
|
|
263
|
+
status: "pending",
|
|
264
|
+
createdAt: deps.now().getTime(),
|
|
265
|
+
body: questionBody(key, overlayText),
|
|
266
|
+
// SAFETY: cast — `details` is opaque extension-owned JSON by
|
|
267
|
+
// contract; the structural QuestionDetails shape is JSON-safe.
|
|
268
|
+
details: details,
|
|
269
|
+
};
|
|
270
|
+
await host.seedInteraction(target.id, record, SEED_CONTEXT);
|
|
271
|
+
await writePendingQuestions(host, user, [...(await readPendingQuestions(host, user)).filter((q) => q.callId !== callId), details]);
|
|
272
|
+
}
|
|
273
|
+
/** The question's form body: exactly keep / drop. The overlay text
|
|
274
|
+
* rides a section (a form's children are fields and sections only;
|
|
275
|
+
* free blocks live inside a section — the interactions validator's
|
|
276
|
+
* rule). */
|
|
277
|
+
function questionBody(key, overlayText) {
|
|
278
|
+
return {
|
|
279
|
+
v: 1,
|
|
280
|
+
root: {
|
|
281
|
+
type: "form",
|
|
282
|
+
mode: "submit",
|
|
283
|
+
bind: "answer",
|
|
284
|
+
submitLabel: "Answer",
|
|
285
|
+
children: [
|
|
286
|
+
{
|
|
287
|
+
type: "section",
|
|
288
|
+
title: `The upstream skill "${key.skill}" changed under your notes`,
|
|
289
|
+
children: [
|
|
290
|
+
{ type: "markdown", text: overlayText },
|
|
291
|
+
{
|
|
292
|
+
type: "choice",
|
|
293
|
+
name: "verdict",
|
|
294
|
+
label: "Keep the notes?",
|
|
295
|
+
options: [
|
|
296
|
+
{ id: "keep", label: "Keep the notes" },
|
|
297
|
+
{ id: "drop", label: "Drop the notes" },
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The `interactionSettled` half: apply a settled overlay question's
|
|
308
|
+
* answer. Only ANSWERED records with a `verdict` of keep/drop apply;
|
|
309
|
+
* every other terminal status (skipped, expired, cancelled) changes
|
|
310
|
+
* nothing. `keep` re-anchors the remembered overlay text on the
|
|
311
|
+
* remembered hash; `drop` removes the overlay. Idempotent per callId
|
|
312
|
+
* (an applied question leaves the pending file).
|
|
313
|
+
*/
|
|
314
|
+
export function overlayQuestionHandler(host) {
|
|
315
|
+
return {
|
|
316
|
+
interactionSettled: async (event, _api, _context) => {
|
|
317
|
+
if (event.kind !== CURATOR_OVERLAY_QUESTION_KIND)
|
|
318
|
+
return;
|
|
319
|
+
if (event.status !== "answered")
|
|
320
|
+
return;
|
|
321
|
+
const verdict = event.answer?.["verdict"];
|
|
322
|
+
if (verdict !== "keep" && verdict !== "drop")
|
|
323
|
+
return;
|
|
324
|
+
const questions = await readQuestionsFor(host, event.callId);
|
|
325
|
+
for (const question of questions) {
|
|
326
|
+
const store = host.curation.overlays(question.user);
|
|
327
|
+
const meta = {
|
|
328
|
+
actor: "curator",
|
|
329
|
+
reason: `user chose to ${verdict} the overlay`,
|
|
330
|
+
};
|
|
331
|
+
if (verdict === "keep") {
|
|
332
|
+
await store.put(question.key, question.overlayText, question.upstreamHash, meta);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
await store.remove(question.key, meta);
|
|
336
|
+
}
|
|
337
|
+
await dropQuestion(host, question.user, question.callId);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/** Read one user's pending questions — the handler scans every user's
|
|
343
|
+
* file (the event names the callId, not the user). */
|
|
344
|
+
async function readQuestionsFor(host, callId) {
|
|
345
|
+
const found = [];
|
|
346
|
+
for (const row of await host.users.list()) {
|
|
347
|
+
for (const question of await readPendingQuestions(host, row.user)) {
|
|
348
|
+
if (question.callId === callId)
|
|
349
|
+
found.push(question);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return found;
|
|
353
|
+
}
|
|
354
|
+
/** Remove an applied question from its user's pending file. */
|
|
355
|
+
async function dropQuestion(host, user, callId) {
|
|
356
|
+
const remaining = (await readPendingQuestions(host, user)).filter((question) => question.callId !== callId);
|
|
357
|
+
await writePendingQuestions(host, user, remaining);
|
|
358
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-step plumbing (`src/steps/run.ts`, Task B4): the shared
|
|
3
|
+
* dependencies every loop-2 MODEL step runs under, and the one
|
|
4
|
+
* `digestRun` helper they all spend their budget through.
|
|
5
|
+
*
|
|
6
|
+
* The budget rule (the brief): every model step is ONE `digestRun` on
|
|
7
|
+
* `CuratorSettings.idleModel` and decrements the per-user daily budget
|
|
8
|
+
* (`dailyRuns`); at the cap the pass stops for the day. {@link RunBudget}
|
|
9
|
+
* is the cursor-backed handle: `left()` answers what remains, `spend()`
|
|
10
|
+
* takes one (persisting the increment through the caller's callback) or
|
|
11
|
+
* answers false when the day is spent.
|
|
12
|
+
*
|
|
13
|
+
* The verdict rule (the brief): steps that need a verdict ask for it as
|
|
14
|
+
* the run's final text in a fixed JSON shape and parse `result.text`;
|
|
15
|
+
* {@link parseVerdict} accepts the whole trimmed text, or the outermost
|
|
16
|
+
* `{…}` span inside it (models are told to answer with the JSON alone —
|
|
17
|
+
* the span fallback tolerates a stray lead-in line). An unparseable
|
|
18
|
+
* verdict is the caller's "no change".
|
|
19
|
+
*/
|
|
20
|
+
import type { CurationRunResult, SigrunHost } from "@sigrun/sigrun/extension";
|
|
21
|
+
/** The tool-call budget one model step's digest run runs under. */
|
|
22
|
+
export declare const MODEL_STEP_MAX_TOOL_CALLS = 16;
|
|
23
|
+
/** The output-token budget one model step's digest run runs under. */
|
|
24
|
+
export declare const MODEL_STEP_MAX_OUTPUT_TOKENS = 4096;
|
|
25
|
+
/**
|
|
26
|
+
* The per-user daily model-run budget. `spend` persists its increment
|
|
27
|
+
* (the cursor's `dailyRunsUsed`) before the run starts — a crash after
|
|
28
|
+
* a spend may waste a run, it never overspends the day.
|
|
29
|
+
*/
|
|
30
|
+
export interface RunBudget {
|
|
31
|
+
/** How many daily runs remain (0 = the day is spent). */
|
|
32
|
+
left(): number;
|
|
33
|
+
/** Take one run; false when none remain (nothing was taken). */
|
|
34
|
+
spend(): Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
/** What every loop-2 model step receives. */
|
|
37
|
+
export interface ModelStepDeps {
|
|
38
|
+
/** The host the step reads and writes through. */
|
|
39
|
+
readonly host: SigrunHost;
|
|
40
|
+
/** The curated user. */
|
|
41
|
+
readonly user: string;
|
|
42
|
+
/** The clock (tests fake it). */
|
|
43
|
+
readonly now: () => Date;
|
|
44
|
+
/** The tick's deadline (epoch ms); runs and loops respect it. */
|
|
45
|
+
readonly deadlineAt: number;
|
|
46
|
+
/** The daily model-run budget. */
|
|
47
|
+
readonly budget: RunBudget;
|
|
48
|
+
/** One line per unexpected condition; defaults to `console.error`. */
|
|
49
|
+
readonly log?: (message: string) => void;
|
|
50
|
+
}
|
|
51
|
+
/** The default step logger. */
|
|
52
|
+
export declare function stepLog(deps: ModelStepDeps): (message: string) => void;
|
|
53
|
+
/** What {@link digestRunWithBudget} submits. */
|
|
54
|
+
export interface DigestRunInput {
|
|
55
|
+
/** The run's only input (the material plus its instruction). */
|
|
56
|
+
readonly input: string;
|
|
57
|
+
/** The run's allow list (non-empty — the gate names it). */
|
|
58
|
+
readonly allow: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
/** One spent digest run: its result and the review id its changes carry. */
|
|
61
|
+
export interface DigestRun {
|
|
62
|
+
readonly result: CurationRunResult;
|
|
63
|
+
/** The run's identity — what `undoReview(user, id)` reverts. */
|
|
64
|
+
readonly reviewId: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* ONE digest run under the budget: spend first (false → `undefined`,
|
|
68
|
+
* the caller stops for the day), then run on the user's idle model with
|
|
69
|
+
* the step bounds and the tick's remaining wall clock.
|
|
70
|
+
*/
|
|
71
|
+
export declare function digestRunWithBudget(deps: ModelStepDeps, input: DigestRunInput): Promise<DigestRun | undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* Parse a run's final text as one fixed-shape verdict object. The whole
|
|
74
|
+
* trimmed text first, then its outermost `{…}` span; anything else —
|
|
75
|
+
* prose, an empty answer, a JSON scalar — is `undefined` ("no change").
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseVerdict<T extends object>(text: string): T | undefined;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-step plumbing (`src/steps/run.ts`, Task B4): the shared
|
|
3
|
+
* dependencies every loop-2 MODEL step runs under, and the one
|
|
4
|
+
* `digestRun` helper they all spend their budget through.
|
|
5
|
+
*
|
|
6
|
+
* The budget rule (the brief): every model step is ONE `digestRun` on
|
|
7
|
+
* `CuratorSettings.idleModel` and decrements the per-user daily budget
|
|
8
|
+
* (`dailyRuns`); at the cap the pass stops for the day. {@link RunBudget}
|
|
9
|
+
* is the cursor-backed handle: `left()` answers what remains, `spend()`
|
|
10
|
+
* takes one (persisting the increment through the caller's callback) or
|
|
11
|
+
* answers false when the day is spent.
|
|
12
|
+
*
|
|
13
|
+
* The verdict rule (the brief): steps that need a verdict ask for it as
|
|
14
|
+
* the run's final text in a fixed JSON shape and parse `result.text`;
|
|
15
|
+
* {@link parseVerdict} accepts the whole trimmed text, or the outermost
|
|
16
|
+
* `{…}` span inside it (models are told to answer with the JSON alone —
|
|
17
|
+
* the span fallback tolerates a stray lead-in line). An unparseable
|
|
18
|
+
* verdict is the caller's "no change".
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { readSettings } from "../settings.js";
|
|
22
|
+
/** The tool-call budget one model step's digest run runs under. */
|
|
23
|
+
export const MODEL_STEP_MAX_TOOL_CALLS = 16;
|
|
24
|
+
/** The output-token budget one model step's digest run runs under. */
|
|
25
|
+
export const MODEL_STEP_MAX_OUTPUT_TOKENS = 4096;
|
|
26
|
+
/** The default step logger. */
|
|
27
|
+
export function stepLog(deps) {
|
|
28
|
+
return (deps.log ?? ((message) => console.error(`curator: ${message}`)));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* ONE digest run under the budget: spend first (false → `undefined`,
|
|
32
|
+
* the caller stops for the day), then run on the user's idle model with
|
|
33
|
+
* the step bounds and the tick's remaining wall clock.
|
|
34
|
+
*/
|
|
35
|
+
export async function digestRunWithBudget(deps, input) {
|
|
36
|
+
if (!(await deps.budget.spend()))
|
|
37
|
+
return undefined;
|
|
38
|
+
const settings = await readSettings(deps.host, deps.user);
|
|
39
|
+
const reviewId = `digest-${randomUUID()}`;
|
|
40
|
+
const opts = {
|
|
41
|
+
instruction: input.input,
|
|
42
|
+
allow: [...input.allow],
|
|
43
|
+
maxToolCalls: MODEL_STEP_MAX_TOOL_CALLS,
|
|
44
|
+
maxOutputTokens: MODEL_STEP_MAX_OUTPUT_TOKENS,
|
|
45
|
+
deadlineMs: Math.max(1, deps.deadlineAt - deps.now().getTime()),
|
|
46
|
+
reviewId,
|
|
47
|
+
input: input.input,
|
|
48
|
+
...(settings.idleModel === undefined ? {} : { model: settings.idleModel }),
|
|
49
|
+
};
|
|
50
|
+
const result = await deps.host.curation.digestRun(deps.user, opts);
|
|
51
|
+
return { result, reviewId };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse a run's final text as one fixed-shape verdict object. The whole
|
|
55
|
+
* trimmed text first, then its outermost `{…}` span; anything else —
|
|
56
|
+
* prose, an empty answer, a JSON scalar — is `undefined` ("no change").
|
|
57
|
+
*/
|
|
58
|
+
export function parseVerdict(text) {
|
|
59
|
+
const trimmed = text.trim();
|
|
60
|
+
const candidates = [trimmed];
|
|
61
|
+
const first = trimmed.indexOf("{");
|
|
62
|
+
const last = trimmed.lastIndexOf("}");
|
|
63
|
+
if (first >= 0 && last > first)
|
|
64
|
+
candidates.push(trimmed.slice(first, last + 1));
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
try {
|
|
67
|
+
const value = JSON.parse(candidate);
|
|
68
|
+
if (typeof value === "object" && value !== null)
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// try the next candidate
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|