@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
package/dist/settings.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curator's per-user settings (`src/settings.ts`, Task B1): read
|
|
3
|
+
* LIVE through the per-user scoped store, with defaults for every
|
|
4
|
+
* absent or malformed field — the read never throws (a bad edit disables
|
|
5
|
+
* nothing; the loop reads per event, like the gateway's live config).
|
|
6
|
+
*
|
|
7
|
+
* The stored object is the user's scoped settings for THIS extension
|
|
8
|
+
* (`settings/curator.json` in the real host); the fields sit at its top
|
|
9
|
+
* level. Unknown keys ride along untouched — they may belong to a later
|
|
10
|
+
* task of this extension and are none of the reader's business.
|
|
11
|
+
*/
|
|
12
|
+
/** The defaults every absent or malformed field falls back to. */
|
|
13
|
+
export const DEFAULT_CURATOR_SETTINGS = {
|
|
14
|
+
enabled: true,
|
|
15
|
+
paused: false,
|
|
16
|
+
threshold: 10,
|
|
17
|
+
dailyRuns: 12,
|
|
18
|
+
};
|
|
19
|
+
/** A positive integer (thresholds and run budgets are counts). */
|
|
20
|
+
function positiveInteger(value) {
|
|
21
|
+
return (typeof value === "number" &&
|
|
22
|
+
Number.isInteger(value) &&
|
|
23
|
+
value > 0);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Parse a raw settings object into {@link CuratorSettings}: every field
|
|
27
|
+
* validated by TYPE and range, falling back to its default — the read
|
|
28
|
+
* is total, never a throw.
|
|
29
|
+
*/
|
|
30
|
+
export function parseCuratorSettings(raw) {
|
|
31
|
+
return {
|
|
32
|
+
enabled: typeof raw.enabled === "boolean"
|
|
33
|
+
? raw.enabled
|
|
34
|
+
: DEFAULT_CURATOR_SETTINGS.enabled,
|
|
35
|
+
paused: typeof raw.paused === "boolean"
|
|
36
|
+
? raw.paused
|
|
37
|
+
: DEFAULT_CURATOR_SETTINGS.paused,
|
|
38
|
+
...(typeof raw.idleModel === "string" && raw.idleModel.length > 0
|
|
39
|
+
? { idleModel: raw.idleModel }
|
|
40
|
+
: {}),
|
|
41
|
+
threshold: positiveInteger(raw.threshold)
|
|
42
|
+
? raw.threshold
|
|
43
|
+
: DEFAULT_CURATOR_SETTINGS.threshold,
|
|
44
|
+
dailyRuns: positiveInteger(raw.dailyRuns)
|
|
45
|
+
? raw.dailyRuns
|
|
46
|
+
: DEFAULT_CURATOR_SETTINGS.dailyRuns,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Read one user's settings LIVE through the per-user scoped store,
|
|
51
|
+
* merged over the defaults.
|
|
52
|
+
*/
|
|
53
|
+
export async function readSettings(host, user) {
|
|
54
|
+
const raw = await host.stores.user(user).settings.get();
|
|
55
|
+
return parseCuratorSettings(raw);
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The archive step (`src/steps/archive.ts`, Task B3): loop 2's first
|
|
3
|
+
* deterministic step — no model, pure store reads plus applies.
|
|
4
|
+
*
|
|
5
|
+
* Two 30-day rules (spec §6.2), both `>=` on milliseconds:
|
|
6
|
+
*
|
|
7
|
+
* - A LEARNED skill archives when it is unused for 30 days (per
|
|
8
|
+
* `usage(user)`; a skill with no usage row counts as used on its
|
|
9
|
+
* `createdAt` — never used is the oldest it can be) AND created at
|
|
10
|
+
* least 30 days ago, AND it is neither pinned nor referenced by one
|
|
11
|
+
* of the user's job prompts (`jobSkillRefs`, paused jobs included —
|
|
12
|
+
* archiving under a paused job would break its prompt on resume).
|
|
13
|
+
* Already-archived rows are left alone. The archive goes through
|
|
14
|
+
* `skills(user).apply(name, {kind:"archive"}, {reason})` — the feed
|
|
15
|
+
* records it as a curator change, so the view's undo covers it.
|
|
16
|
+
*
|
|
17
|
+
* - An OVERLAY whose upstream vanished (`orphanedSince`, stamped by
|
|
18
|
+
* `markOrphans` during the overlay-detect step) is REMOVED after
|
|
19
|
+
* 30 days orphaned: the detection pass before this one marked it,
|
|
20
|
+
* this pass (a later one) reaps it — a returning upstream in between
|
|
21
|
+
* clears the mark and spares it.
|
|
22
|
+
*
|
|
23
|
+
* The step is idempotent by construction (archived rows are skipped,
|
|
24
|
+
* removed overlays are gone), so a tick that crashes mid-step and
|
|
25
|
+
* resumes re-runs it safely.
|
|
26
|
+
*/
|
|
27
|
+
import type { SigrunHost } from "@sigrun/sigrun/extension";
|
|
28
|
+
/** The archive threshold (spec §5: 30 days), as days for the export. */
|
|
29
|
+
export declare const ARCHIVE_UNUSED_DAYS = 30;
|
|
30
|
+
/** The archive threshold in milliseconds. */
|
|
31
|
+
export declare const ARCHIVE_UNUSED_MS: number;
|
|
32
|
+
/** The feed reason stamped on a skill archive. */
|
|
33
|
+
export declare const ARCHIVE_REASON = "unused for 30 days";
|
|
34
|
+
/** The feed reason stamped on an orphaned-overlay removal. */
|
|
35
|
+
export declare const ORPHAN_REMOVAL_REASON = "orphaned for 30 days";
|
|
36
|
+
/**
|
|
37
|
+
* Archive the user's unused learned skills and reap their long-orphaned
|
|
38
|
+
* overlays (loop 2 step `archive`).
|
|
39
|
+
*/
|
|
40
|
+
export declare function archiveUnused(host: SigrunHost, user: string, now: () => Date): Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The archive step (`src/steps/archive.ts`, Task B3): loop 2's first
|
|
3
|
+
* deterministic step — no model, pure store reads plus applies.
|
|
4
|
+
*
|
|
5
|
+
* Two 30-day rules (spec §6.2), both `>=` on milliseconds:
|
|
6
|
+
*
|
|
7
|
+
* - A LEARNED skill archives when it is unused for 30 days (per
|
|
8
|
+
* `usage(user)`; a skill with no usage row counts as used on its
|
|
9
|
+
* `createdAt` — never used is the oldest it can be) AND created at
|
|
10
|
+
* least 30 days ago, AND it is neither pinned nor referenced by one
|
|
11
|
+
* of the user's job prompts (`jobSkillRefs`, paused jobs included —
|
|
12
|
+
* archiving under a paused job would break its prompt on resume).
|
|
13
|
+
* Already-archived rows are left alone. The archive goes through
|
|
14
|
+
* `skills(user).apply(name, {kind:"archive"}, {reason})` — the feed
|
|
15
|
+
* records it as a curator change, so the view's undo covers it.
|
|
16
|
+
*
|
|
17
|
+
* - An OVERLAY whose upstream vanished (`orphanedSince`, stamped by
|
|
18
|
+
* `markOrphans` during the overlay-detect step) is REMOVED after
|
|
19
|
+
* 30 days orphaned: the detection pass before this one marked it,
|
|
20
|
+
* this pass (a later one) reaps it — a returning upstream in between
|
|
21
|
+
* clears the mark and spares it.
|
|
22
|
+
*
|
|
23
|
+
* The step is idempotent by construction (archived rows are skipped,
|
|
24
|
+
* removed overlays are gone), so a tick that crashes mid-step and
|
|
25
|
+
* resumes re-runs it safely.
|
|
26
|
+
*/
|
|
27
|
+
/** The archive threshold (spec §5: 30 days), as days for the export. */
|
|
28
|
+
export const ARCHIVE_UNUSED_DAYS = 30;
|
|
29
|
+
/** The archive threshold in milliseconds. */
|
|
30
|
+
export const ARCHIVE_UNUSED_MS = ARCHIVE_UNUSED_DAYS * 86_400_000;
|
|
31
|
+
/** The feed reason stamped on a skill archive. */
|
|
32
|
+
export const ARCHIVE_REASON = "unused for 30 days";
|
|
33
|
+
/** The feed reason stamped on an orphaned-overlay removal. */
|
|
34
|
+
export const ORPHAN_REMOVAL_REASON = "orphaned for 30 days";
|
|
35
|
+
/**
|
|
36
|
+
* Whether `iso` is at least `thresholdMs` before `now`. An unparseable
|
|
37
|
+
* stamp answers FALSE — bad data spares the skill/overlay, it never
|
|
38
|
+
* destroys.
|
|
39
|
+
*/
|
|
40
|
+
function atLeast(thresholdMs, now, iso) {
|
|
41
|
+
if (iso === undefined)
|
|
42
|
+
return false;
|
|
43
|
+
const at = Date.parse(iso);
|
|
44
|
+
return Number.isFinite(at) && now.getTime() - at >= thresholdMs;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Archive the user's unused learned skills and reap their long-orphaned
|
|
48
|
+
* overlays (loop 2 step `archive`).
|
|
49
|
+
*/
|
|
50
|
+
export async function archiveUnused(host, user, now) {
|
|
51
|
+
const at = now();
|
|
52
|
+
await archiveSkills(host, user, at);
|
|
53
|
+
await reapOrphanedOverlays(host, user, at);
|
|
54
|
+
}
|
|
55
|
+
/** The learned-skill half of the archive step. */
|
|
56
|
+
async function archiveSkills(host, user, now) {
|
|
57
|
+
const store = host.curation.skills(user);
|
|
58
|
+
const skills = await store.list();
|
|
59
|
+
const usage = await host.curation.usage(user);
|
|
60
|
+
const jobRefs = await host.curation.jobSkillRefs(user);
|
|
61
|
+
for (const skill of skills) {
|
|
62
|
+
if (skill.archived)
|
|
63
|
+
continue;
|
|
64
|
+
if (skill.pinned)
|
|
65
|
+
continue;
|
|
66
|
+
if (jobRefs.has(skill.name))
|
|
67
|
+
continue;
|
|
68
|
+
// Unused ≥ 30 days: never-used counts from creation.
|
|
69
|
+
const lastUsed = usage[skill.name] ?? skill.createdAt;
|
|
70
|
+
if (!atLeast(ARCHIVE_UNUSED_MS, now, lastUsed))
|
|
71
|
+
continue;
|
|
72
|
+
if (!atLeast(ARCHIVE_UNUSED_MS, now, skill.createdAt))
|
|
73
|
+
continue;
|
|
74
|
+
await store.apply(skill.name, { kind: "archive" }, { reason: ARCHIVE_REASON });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The orphaned-overlay half of the archive step. */
|
|
78
|
+
async function reapOrphanedOverlays(host, user, now) {
|
|
79
|
+
const store = host.curation.overlays(user);
|
|
80
|
+
for (const overlay of await store.list()) {
|
|
81
|
+
if (!atLeast(ARCHIVE_UNUSED_MS, now, overlay.orphanedSince))
|
|
82
|
+
continue;
|
|
83
|
+
await store.remove(overlay.key, {
|
|
84
|
+
actor: "curator",
|
|
85
|
+
reason: ORPHAN_REMOVAL_REASON,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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 { type ModelStepDeps } from "./run.js";
|
|
24
|
+
/** The diet threshold (the brief: > 12 KB). */
|
|
25
|
+
export declare const DIET_BYTES: number;
|
|
26
|
+
/** How long a skill must be unchanged before a diet (the brief: 24h). */
|
|
27
|
+
export declare const DIET_UNCHANGED_MS: number;
|
|
28
|
+
/** The loop-2 diet step: diet at most one eligible skill. */
|
|
29
|
+
export declare function dietStep(deps: ModelStepDeps): Promise<boolean>;
|
|
@@ -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>;
|