@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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skill-diet step (`src/steps/diet.ts`, Task B4 — loop 2 step
|
|
3
|
+
* `diet`): ONE oversized learned skill per pass is rewritten so its
|
|
4
|
+
* `##` sections move out to `references/` files verbatim (openclaw's
|
|
5
|
+
* diet), and a SECOND model run compares old and new and answers
|
|
6
|
+
* `{"loss": false}` — anything else (a reported loss, or an unparseable
|
|
7
|
+
* answer) undoes the rewrite through `skills.undo`, so no unverified
|
|
8
|
+
* diet ever stands.
|
|
9
|
+
*
|
|
10
|
+
* Eligibility (the brief): a learned skill whose SKILL.md is > 12 KB
|
|
11
|
+
* and unchanged for 24h — "unchanged" reads the skill store's feed: the
|
|
12
|
+
* newest curator/agent-authored entry for the skill must be older than
|
|
13
|
+
* 24h (a skill with no feed entry at all counts as its creation, so a
|
|
14
|
+
* freshly created giant waits a day before its first diet).
|
|
15
|
+
*
|
|
16
|
+
* The runs: the FIRST carries the full SKILL.md and instructs the
|
|
17
|
+
* move-to-references rewrite (the run performs it through its allowed
|
|
18
|
+
* `skill_manage` tool — `write_file` under `references/` plus the
|
|
19
|
+
* trimmed patch); the SECOND carries old and new side by side and asks
|
|
20
|
+
* only the fixed JSON loss answer. Both are ordinary budget-spending
|
|
21
|
+
* digest runs on the idle model.
|
|
22
|
+
*/
|
|
23
|
+
import { digestRunWithBudget, parseVerdict, stepLog } from "./run.js";
|
|
24
|
+
/** The diet threshold (the brief: > 12 KB). */
|
|
25
|
+
export const DIET_BYTES = 12 * 1024;
|
|
26
|
+
/** How long a skill must be unchanged before a diet (the brief: 24h). */
|
|
27
|
+
export const DIET_UNCHANGED_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
/** The diet's allow list: the rewrite runs through skill_manage. */
|
|
29
|
+
const DIET_ALLOW = ["skill_manage"];
|
|
30
|
+
/** The loop-2 diet step: diet at most one eligible skill. */
|
|
31
|
+
export async function dietStep(deps) {
|
|
32
|
+
const { host, user } = deps;
|
|
33
|
+
const log = stepLog(deps);
|
|
34
|
+
if (deps.budget.left() < 1)
|
|
35
|
+
return false; // the day is spent: stop the pass
|
|
36
|
+
const store = host.curation.skills(user);
|
|
37
|
+
const feed = await store.feed(200);
|
|
38
|
+
for (const row of await store.list()) {
|
|
39
|
+
if (deps.now().getTime() >= deps.deadlineAt)
|
|
40
|
+
return false;
|
|
41
|
+
if (row.archived)
|
|
42
|
+
continue;
|
|
43
|
+
if (row.bytes <= DIET_BYTES)
|
|
44
|
+
continue;
|
|
45
|
+
const last = newestEntry(feed, row.name) ?? row.createdAt;
|
|
46
|
+
if (deps.now().getTime() - Date.parse(last) < DIET_UNCHANGED_MS)
|
|
47
|
+
continue;
|
|
48
|
+
const done = await dietOne(deps, row.name).catch((error) => {
|
|
49
|
+
log(`loop 2: ${user} diet of skill ${row.name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
50
|
+
return true;
|
|
51
|
+
});
|
|
52
|
+
return done; // ONE skill per pass, whatever happened
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
/** Diet one skill: rewrite, compare, undo on loss or unparseable. */
|
|
57
|
+
async function dietOne(deps, name) {
|
|
58
|
+
const { host, user } = deps;
|
|
59
|
+
const store = host.curation.skills(user);
|
|
60
|
+
const oldBody = await store.view(name);
|
|
61
|
+
if (oldBody === "")
|
|
62
|
+
return true; // unreadable: nothing to diet safely
|
|
63
|
+
// Run 1 — the rewrite (the run moves `##` sections to references/
|
|
64
|
+
// verbatim through its skill_manage tool).
|
|
65
|
+
const rewrite = await digestRunWithBudget(deps, {
|
|
66
|
+
input: rewriteInput(name, oldBody),
|
|
67
|
+
allow: DIET_ALLOW,
|
|
68
|
+
});
|
|
69
|
+
if (rewrite === undefined)
|
|
70
|
+
return false; // no budget left today
|
|
71
|
+
if (rewrite.result.status !== "completed" || rewrite.result.changes.length === 0) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
// Run 2 — the loss compare over old and NEW bodies.
|
|
75
|
+
const newBody = await store.view(name);
|
|
76
|
+
const compare = await digestRunWithBudget(deps, {
|
|
77
|
+
input: compareInput(name, oldBody, newBody),
|
|
78
|
+
allow: DIET_ALLOW, // the compare may read files through skill_manage
|
|
79
|
+
});
|
|
80
|
+
if (compare === undefined) {
|
|
81
|
+
// Budget ran out mid-diet: an UNVERIFIED rewrite must not stand.
|
|
82
|
+
await undoChanges(store, rewrite.result);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const verdict = compare.result.status === "completed"
|
|
86
|
+
? parseVerdict(compare.result.text)
|
|
87
|
+
: undefined;
|
|
88
|
+
if (verdict !== undefined && verdict.loss === false)
|
|
89
|
+
return true; // verified
|
|
90
|
+
// A reported loss, an unparseable answer, or a failed compare run:
|
|
91
|
+
// the rewrite is undone (the brief — or the change is undone).
|
|
92
|
+
await undoChanges(store, rewrite.result);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
/** Undo one run's changes through the skill store. */
|
|
96
|
+
async function undoChanges(store, run) {
|
|
97
|
+
const ids = run.changes
|
|
98
|
+
.map((change) => change.changeId)
|
|
99
|
+
.filter((id) => typeof id === "string" && id !== "");
|
|
100
|
+
if (ids.length > 0)
|
|
101
|
+
await store.undo(ids);
|
|
102
|
+
}
|
|
103
|
+
/** The feed's newest entry for one skill (feed is newest-first). */
|
|
104
|
+
function newestEntry(feed, skill) {
|
|
105
|
+
return feed.find((entry) => entry.skill === skill)?.at;
|
|
106
|
+
}
|
|
107
|
+
/** The rewrite run's input (run 1). */
|
|
108
|
+
function rewriteInput(name, body) {
|
|
109
|
+
return [
|
|
110
|
+
`You are the curator. The learned skill "${name}" is too large. Diet it: keep the skill's SKILL.md to its opening summary and its core procedure, and move every \`##\` section that is reference material (examples, long tables, background) into files under \`references/\` VERBATIM — unchanged text, only relocated. Re-link them from SKILL.md with a one-line pointer each.`,
|
|
111
|
+
"",
|
|
112
|
+
"The current SKILL.md in full:",
|
|
113
|
+
body,
|
|
114
|
+
"",
|
|
115
|
+
"Use your skill_manage tool to write the files (write_file for each references/ file, then patch SKILL.md). Move text verbatim; never summarize, merge, or drop a rule. When you are done, answer with one line naming what you moved.",
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
/** The loss-compare run's input (run 2). */
|
|
119
|
+
function compareInput(name, oldBody, newBody) {
|
|
120
|
+
return [
|
|
121
|
+
`You are the curator. The learned skill "${name}" was just dieted: sections moved to references/ files. Compare the OLD body with the NEW body plus the reference files' pointers, and decide whether any rule, pitfall, or instruction present in the old body was dropped or weakened anywhere in the new shape.`,
|
|
122
|
+
"",
|
|
123
|
+
"The OLD SKILL.md:",
|
|
124
|
+
oldBody,
|
|
125
|
+
"",
|
|
126
|
+
"The NEW SKILL.md (references/ files are reachable through its pointers):",
|
|
127
|
+
newBody,
|
|
128
|
+
"",
|
|
129
|
+
'Answer with ONLY this JSON: {"loss": false} when nothing was dropped or weakened, or {"loss": true} when something was.',
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mixed-conversation digest step (`src/steps/digests.ts`, Task B4 —
|
|
3
|
+
* loop 2 step `digests`): the conversations loop 1 refused to fork
|
|
4
|
+
* (mixed authorship, unattributed entries) are reviewed here, NEVER
|
|
5
|
+
* forked — a fresh internal digest run over a text digest of the
|
|
6
|
+
* OWNER'S attributed turns plus the assistant replies to them.
|
|
7
|
+
*
|
|
8
|
+
* Attribution (fail closed, P1's rule — another user's words can never
|
|
9
|
+
* enter the owner's digest): the PRIMARY per-entry source is
|
|
10
|
+
* `curation.authors(conversationId)` — core's map of every user-input
|
|
11
|
+
* entry in the active context to its author (an entry ABSENT from the
|
|
12
|
+
* map or mapped to `undefined` is unattributed and never the owner's).
|
|
13
|
+
* When the map is EMPTY (no attributed entry at all — a pre-P1-shaped
|
|
14
|
+
* or fresh conversation), the step falls back to `curation.eligibility`:
|
|
15
|
+
* an owner-only conversation with zero unattributed entries attributes
|
|
16
|
+
* EVERY user turn to the owner (that is what `ownerOnly` means). A
|
|
17
|
+
* mixed conversation whose map has no entry for the digesting user has
|
|
18
|
+
* no attributable turn — none is included, the fail-closed skip below
|
|
19
|
+
* leaves it queued.
|
|
20
|
+
*
|
|
21
|
+
* ✅ HOST GAP RESOLVED (2026-11-01): this step originally read per-entry
|
|
22
|
+
* authors from a `sigrun.authors` doc projected onto the watched view
|
|
23
|
+
* (`docs["sigrun.authors"].byEntry`) — but pi-durable's structural view
|
|
24
|
+
* mounts only its five built-in docs, so that projection never existed
|
|
25
|
+
* and mixed conversations digested NOTHING. Core now serves the map
|
|
26
|
+
* directly (`host.curation.authors`, core commit "feat(curation):
|
|
27
|
+
* per-entry author map on host.curation"); the view-doc path is gone.
|
|
28
|
+
*
|
|
29
|
+
* Assistant replies: an assistant entry is included only when it
|
|
30
|
+
* belongs to the run an OWNER turn started — the entries after an
|
|
31
|
+
* owner-attributed user entry, up to the next user entry (a reply that
|
|
32
|
+
* follows another user's turn answers that turn, and is excluded with
|
|
33
|
+
* it).
|
|
34
|
+
*
|
|
35
|
+
* Durability: the queue file is read WITHOUT clearing; an entry is
|
|
36
|
+
* removed only after its review COMPLETED (a failed run leaves it for
|
|
37
|
+
* the next pass). The B2 carry: consumption dedups hints by id — the
|
|
38
|
+
* entry's own hints merge with the conversation's refunded batch
|
|
39
|
+
* (`pending-hints.json`), and completing the review drains both.
|
|
40
|
+
*/
|
|
41
|
+
import type { ConversationId } from "@earendil-works/pi-durable";
|
|
42
|
+
import { type ModelStepDeps } from "./run.js";
|
|
43
|
+
/** The digest step's own view-acquisition grace: how long to wait for
|
|
44
|
+
* the watched conversation's first frame (the real host delivers the
|
|
45
|
+
* snapshot before `watch` resolves; the grace only bounds an anomaly). */
|
|
46
|
+
export declare const DIGEST_VIEW_GRACE_MS = 10000;
|
|
47
|
+
/** The loop-2 digest step: review every queued mixed conversation.
|
|
48
|
+
* Answers FALSE when the daily budget ran out (the pass stops). */
|
|
49
|
+
export declare function digestsStep(deps: ModelStepDeps): Promise<boolean>;
|
|
50
|
+
/**
|
|
51
|
+
* Review ONE conversation (exported for the step loop): watch its view,
|
|
52
|
+
* build the owner's digest, run the review, and consume the queue
|
|
53
|
+
* entry on completion. False = out of budget (the caller stops).
|
|
54
|
+
*/
|
|
55
|
+
export declare function reviewDigest(deps: ModelStepDeps, conversationId: ConversationId): Promise<boolean>;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mixed-conversation digest step (`src/steps/digests.ts`, Task B4 —
|
|
3
|
+
* loop 2 step `digests`): the conversations loop 1 refused to fork
|
|
4
|
+
* (mixed authorship, unattributed entries) are reviewed here, NEVER
|
|
5
|
+
* forked — a fresh internal digest run over a text digest of the
|
|
6
|
+
* OWNER'S attributed turns plus the assistant replies to them.
|
|
7
|
+
*
|
|
8
|
+
* Attribution (fail closed, P1's rule — another user's words can never
|
|
9
|
+
* enter the owner's digest): the PRIMARY per-entry source is
|
|
10
|
+
* `curation.authors(conversationId)` — core's map of every user-input
|
|
11
|
+
* entry in the active context to its author (an entry ABSENT from the
|
|
12
|
+
* map or mapped to `undefined` is unattributed and never the owner's).
|
|
13
|
+
* When the map is EMPTY (no attributed entry at all — a pre-P1-shaped
|
|
14
|
+
* or fresh conversation), the step falls back to `curation.eligibility`:
|
|
15
|
+
* an owner-only conversation with zero unattributed entries attributes
|
|
16
|
+
* EVERY user turn to the owner (that is what `ownerOnly` means). A
|
|
17
|
+
* mixed conversation whose map has no entry for the digesting user has
|
|
18
|
+
* no attributable turn — none is included, the fail-closed skip below
|
|
19
|
+
* leaves it queued.
|
|
20
|
+
*
|
|
21
|
+
* ✅ HOST GAP RESOLVED (2026-11-01): this step originally read per-entry
|
|
22
|
+
* authors from a `sigrun.authors` doc projected onto the watched view
|
|
23
|
+
* (`docs["sigrun.authors"].byEntry`) — but pi-durable's structural view
|
|
24
|
+
* mounts only its five built-in docs, so that projection never existed
|
|
25
|
+
* and mixed conversations digested NOTHING. Core now serves the map
|
|
26
|
+
* directly (`host.curation.authors`, core commit "feat(curation):
|
|
27
|
+
* per-entry author map on host.curation"); the view-doc path is gone.
|
|
28
|
+
*
|
|
29
|
+
* Assistant replies: an assistant entry is included only when it
|
|
30
|
+
* belongs to the run an OWNER turn started — the entries after an
|
|
31
|
+
* owner-attributed user entry, up to the next user entry (a reply that
|
|
32
|
+
* follows another user's turn answers that turn, and is excluded with
|
|
33
|
+
* it).
|
|
34
|
+
*
|
|
35
|
+
* Durability: the queue file is read WITHOUT clearing; an entry is
|
|
36
|
+
* removed only after its review COMPLETED (a failed run leaves it for
|
|
37
|
+
* the next pass). The B2 carry: consumption dedups hints by id — the
|
|
38
|
+
* entry's own hints merge with the conversation's refunded batch
|
|
39
|
+
* (`pending-hints.json`), and completing the review drains both.
|
|
40
|
+
*/
|
|
41
|
+
import { clearPendingHints, mergeHints, pendingHintsFor, readDigestQueue, removeDigestEntry, } from "../queue.js";
|
|
42
|
+
import { notify } from "../notices.js";
|
|
43
|
+
import { reviewInstruction } from "../prompts.js";
|
|
44
|
+
import { digestRunWithBudget, stepLog } from "./run.js";
|
|
45
|
+
/** The digest step's own view-acquisition grace: how long to wait for
|
|
46
|
+
* the watched conversation's first frame (the real host delivers the
|
|
47
|
+
* snapshot before `watch` resolves; the grace only bounds an anomaly). */
|
|
48
|
+
export const DIGEST_VIEW_GRACE_MS = 10_000;
|
|
49
|
+
/** The entry kinds that carry a user turn (attribution.ts's set). */
|
|
50
|
+
const USER_KINDS = new Set([
|
|
51
|
+
"pi.user",
|
|
52
|
+
"sigrun.user.expanded",
|
|
53
|
+
"sigrun.blocks.answer",
|
|
54
|
+
]);
|
|
55
|
+
/** The loop-2 digest step: review every queued mixed conversation.
|
|
56
|
+
* Answers FALSE when the daily budget ran out (the pass stops). */
|
|
57
|
+
export async function digestsStep(deps) {
|
|
58
|
+
const { host, user } = deps;
|
|
59
|
+
const log = stepLog(deps);
|
|
60
|
+
if (deps.budget.left() < 1)
|
|
61
|
+
return false; // the day is spent: stop the pass
|
|
62
|
+
const entries = await readDigestQueue(host, user);
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (deps.now().getTime() >= deps.deadlineAt)
|
|
65
|
+
return false;
|
|
66
|
+
const done = await reviewDigest(deps, entry.id).catch((error) => {
|
|
67
|
+
log(`loop 2: ${user} digest of conversation ${String(entry.id)} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
if (!done)
|
|
71
|
+
return false; // out of daily budget: stop for the day
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Review ONE conversation (exported for the step loop): watch its view,
|
|
77
|
+
* build the owner's digest, run the review, and consume the queue
|
|
78
|
+
* entry on completion. False = out of budget (the caller stops).
|
|
79
|
+
*/
|
|
80
|
+
export async function reviewDigest(deps, conversationId) {
|
|
81
|
+
const { host, user } = deps;
|
|
82
|
+
const log = stepLog(deps);
|
|
83
|
+
const view = await watchFirstFrame(deps, conversationId);
|
|
84
|
+
if (view === undefined) {
|
|
85
|
+
log(`loop 2: ${user} digest of conversation ${String(conversationId)} saw no view`);
|
|
86
|
+
return true; // unreadable: leave it queued, move on
|
|
87
|
+
}
|
|
88
|
+
const digest = await ownerDigest(deps, conversationId, view);
|
|
89
|
+
if (digest === undefined) {
|
|
90
|
+
log(`loop 2: ${user} digest of conversation ${String(conversationId)} has no attributable turns`);
|
|
91
|
+
return true; // fail closed: leave it queued for a per-entry source
|
|
92
|
+
}
|
|
93
|
+
// The B2 carry: the entry owns its hints; the refund file may hold
|
|
94
|
+
// the same ids (a crash between queueDigest and the clear) — merge
|
|
95
|
+
// by hint id so a stranded hint is consumed exactly once.
|
|
96
|
+
const hints = mergeHints(await pendingHintsFor(host, user, conversationId), (await readDigestQueue(host, user)).find((entry) => entry.id === conversationId)?.hints ?? []);
|
|
97
|
+
const targets = await host.curation.targets(user, conversationId);
|
|
98
|
+
const selection = await host.curation.tools(conversationId);
|
|
99
|
+
const allow = [
|
|
100
|
+
...new Set([
|
|
101
|
+
"skill_manage",
|
|
102
|
+
...targets.flatMap((target) => [...target.tools]),
|
|
103
|
+
...(selection.includes("read") ? ["read"] : []),
|
|
104
|
+
]),
|
|
105
|
+
];
|
|
106
|
+
const instruction = reviewInstruction({
|
|
107
|
+
hints,
|
|
108
|
+
targets: targets.map((target) => ({
|
|
109
|
+
id: target.id,
|
|
110
|
+
guidance: [...target.guidance],
|
|
111
|
+
})),
|
|
112
|
+
allow,
|
|
113
|
+
});
|
|
114
|
+
const run = await digestRunWithBudget(deps, {
|
|
115
|
+
input: `${digest}\n\n${instruction}`,
|
|
116
|
+
allow,
|
|
117
|
+
});
|
|
118
|
+
if (run === undefined)
|
|
119
|
+
return false; // no budget left today
|
|
120
|
+
if (run.result.status !== "completed")
|
|
121
|
+
return true; // leave it queued
|
|
122
|
+
// The review consumed these hints for good: the queue entry goes and
|
|
123
|
+
// the refund file drains of the same ids.
|
|
124
|
+
await removeDigestEntry(deps.host, user, conversationId);
|
|
125
|
+
await clearPendingHints(host, user, conversationId, hints);
|
|
126
|
+
// Task B5: a digest review that CHANGED something announces itself
|
|
127
|
+
// on the originating (mixed) conversation — the same notice shape a
|
|
128
|
+
// fork review sets, undoable from the view. The notice is isolated:
|
|
129
|
+
// a failing setAmbient or ledger write degrades to a missing notice,
|
|
130
|
+
// never a failed step (the catch above would strand a COMPLETED
|
|
131
|
+
// review's entry back in the queue as if the run had failed).
|
|
132
|
+
await notify(host, conversationId, user, {
|
|
133
|
+
...run.result,
|
|
134
|
+
reviewId: run.reviewId,
|
|
135
|
+
}).catch((error) => {
|
|
136
|
+
log(`loop 2: ${user} notice after the digest of conversation ${String(conversationId)} could not be set: ${error instanceof Error ? error.message : String(error)}`);
|
|
137
|
+
});
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Watch the conversation and resolve its FIRST frame (the snapshot the
|
|
142
|
+
* real host pushes before `watch` resolves), bounded by the grace.
|
|
143
|
+
*/
|
|
144
|
+
async function watchFirstFrame(deps, conversationId) {
|
|
145
|
+
let frame;
|
|
146
|
+
let releaseFrame;
|
|
147
|
+
const first = new Promise((resolve) => {
|
|
148
|
+
releaseFrame = resolve;
|
|
149
|
+
});
|
|
150
|
+
const stop = await deps.host.conversations.watch(conversationId, (view) => {
|
|
151
|
+
frame = view;
|
|
152
|
+
releaseFrame();
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
if (frame === undefined) {
|
|
156
|
+
const grace = Math.min(DIGEST_VIEW_GRACE_MS, Math.max(0, deps.deadlineAt - deps.now().getTime()));
|
|
157
|
+
await Promise.race([
|
|
158
|
+
first,
|
|
159
|
+
new Promise((resolve) => setTimeout(resolve, grace)),
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
await stop();
|
|
165
|
+
}
|
|
166
|
+
return frame;
|
|
167
|
+
}
|
|
168
|
+
/** The owner's digest of one view, or undefined when no user turn is
|
|
169
|
+
* attributable (the fail-closed skip). */
|
|
170
|
+
async function ownerDigest(deps, conversationId, view) {
|
|
171
|
+
const { host, user } = deps;
|
|
172
|
+
// Per-entry attribution, the PRIMARY source: entry id → author,
|
|
173
|
+
// skipping unattributed entries (author undefined — an entry absent
|
|
174
|
+
// from this record reads unattributed, so `undefined === user` is
|
|
175
|
+
// false and the turn is excluded: never learn from unattributed).
|
|
176
|
+
const attributed = await host.curation.authors(conversationId);
|
|
177
|
+
const byEntry = {};
|
|
178
|
+
for (const [entryId, author] of attributed) {
|
|
179
|
+
if (author !== undefined)
|
|
180
|
+
byEntry[String(entryId)] = author;
|
|
181
|
+
}
|
|
182
|
+
// The empty map means "no attributed entry at all" (a pre-P1-shaped
|
|
183
|
+
// or fresh conversation): fall back to the whole-conversation
|
|
184
|
+
// eligibility read so owner-only conversations still digest.
|
|
185
|
+
let everyTurnIsOwners = false;
|
|
186
|
+
if (Object.keys(byEntry).length === 0) {
|
|
187
|
+
const eligibility = await host.curation.eligibility(conversationId);
|
|
188
|
+
everyTurnIsOwners =
|
|
189
|
+
eligibility.owner === user && eligibility.ownerOnly && eligibility.unattributed === 0;
|
|
190
|
+
}
|
|
191
|
+
const lines = [];
|
|
192
|
+
let inOwnerRun = false;
|
|
193
|
+
let sawOwnerTurn = false;
|
|
194
|
+
const hasPerEntry = Object.keys(byEntry).length > 0;
|
|
195
|
+
for (const entry of view.entries ?? []) {
|
|
196
|
+
if (USER_KINDS.has(entry.kind)) {
|
|
197
|
+
const author = byEntry[String(entry.id)];
|
|
198
|
+
inOwnerRun = hasPerEntry ? author === user : everyTurnIsOwners;
|
|
199
|
+
if (!inOwnerRun)
|
|
200
|
+
continue;
|
|
201
|
+
const text = entryText(entry, "user");
|
|
202
|
+
if (text === "")
|
|
203
|
+
continue;
|
|
204
|
+
lines.push(`User (${user}): ${text}`);
|
|
205
|
+
sawOwnerTurn = true;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (entry.kind === "pi.assistant") {
|
|
209
|
+
if (!inOwnerRun)
|
|
210
|
+
continue; // replies to another user's turn
|
|
211
|
+
const text = entryText(entry, "assistant");
|
|
212
|
+
if (text === "")
|
|
213
|
+
continue;
|
|
214
|
+
lines.push(`Assistant: ${text}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!sawOwnerTurn)
|
|
218
|
+
return undefined;
|
|
219
|
+
return [`Conversation digest (only ${user}'s turns and the replies to them):`, "", ...lines]
|
|
220
|
+
.join("\n");
|
|
221
|
+
}
|
|
222
|
+
/** One entry's text for `role` (string content, or the text blocks). */
|
|
223
|
+
function entryText(entry, role) {
|
|
224
|
+
const parts = [];
|
|
225
|
+
for (const message of entry.model ?? []) {
|
|
226
|
+
if (message.role !== role)
|
|
227
|
+
continue;
|
|
228
|
+
const content = message.content;
|
|
229
|
+
if (typeof content === "string") {
|
|
230
|
+
parts.push(content);
|
|
231
|
+
}
|
|
232
|
+
else if (Array.isArray(content)) {
|
|
233
|
+
for (const block of content) {
|
|
234
|
+
if (typeof block === "object" &&
|
|
235
|
+
block !== null &&
|
|
236
|
+
typeof block.text === "string") {
|
|
237
|
+
parts.push(block.text);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return parts.join("\n").trim();
|
|
243
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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 { type ModelStepDeps } from "./run.js";
|
|
16
|
+
/** The loop-2 merge step: at most one judged merge per pass. */
|
|
17
|
+
export declare function mergeStep(deps: ModelStepDeps): Promise<boolean>;
|
|
@@ -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,92 @@
|
|
|
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 per skill in ONE new
|
|
31
|
+
* user-owned conversation per pass, applied by `overlayQuestionHandler`
|
|
32
|
+
* from `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. EVERY terminal status (answered, skipped, cancelled, expired)
|
|
83
|
+
* drops the entry from the pending file — the file is bounded by
|
|
84
|
+
* terminal statuses (one open entry per skill key). Only ANSWERED
|
|
85
|
+
* records with a `verdict` of keep/drop apply; `keep` re-anchors the
|
|
86
|
+
* remembered overlay text on the remembered hash; `drop` removes the
|
|
87
|
+
* overlay. The callId is looked up in BOTH the v2 `questions` map and
|
|
88
|
+
* the migrated `legacy` list, so pre-v2 questions stay answerable
|
|
89
|
+
* where they were asked. Idempotent per callId (an applied question
|
|
90
|
+
* leaves the pending file).
|
|
91
|
+
*/
|
|
92
|
+
export declare function overlayQuestionHandler(host: SigrunHost): Pick<SigrunLifecycleHandlers, "interactionSettled">;
|